Compare commits

...

392 Commits
v4.3.2 ... main

Author SHA1 Message Date
xRangerDE
0e81a47e9e release: 4.6.155 aria-live on clipStatus + viewer-modal status fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:17:04 +02:00
xRangerDE
70643b4c08 a11y: aria-live=polite on clipStatus + chatViewer/eventsViewer status
Three more user-triggered status messages that updated silently for screen-reader users:
- clipStatus — clip download progress/result text below the URL input on the Clips tab
- eventsViewerStatus — loading/error message inside the events viewer modal
- chatViewerStatus — loading/error message + filter result count inside the chat viewer modal

All three get role="status" + aria-live="polite". Same reasoning as 4.6.153/154 — the user explicitly clicks a button or opens a modal, then the result fills in asynchronously. Without aria-live the announcement was lost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:16:49 +02:00
xRangerDE
86d68466f9 release: 4.6.154 aria-live on 3 more refresh-result status messages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:11:27 +02:00
xRangerDE
ae156ff395 a11y: aria-live=polite on 3 more refresh-result status messages
Following the cleanupReport fix in 4.6.153, three more user-triggered refresh result strings were updating silently:

- statsLastScannedLabel — "Letzter Scan: <timestamp>" / "Scanne..." in the Archive-Statistik header after clicking Aktualisieren
- archiveSearchSummary — "{matchCount} matches (scanned {scanned}…)" / "Scanne..." after clicking Suchen
- storageSummary — "Total: {files} files, {size} — Free disk: {free}" / "Scanne..." after clicking Aktualisieren on the Storage card

All three are textContent updates triggered by an explicit user action that finishes asynchronously. Without aria-live, screen reader users hear nothing after pressing the action button — the result text fills in off-screen.

Added role="status" + aria-live="polite" to all three. "polite" because the result isn't urgent — the user requested it and waiting for a natural break in speech is fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:11:13 +02:00
xRangerDE
2d109077a0 release: 4.6.153 cleanupReport role=status + aria-live
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:06:58 +02:00
xRangerDE
25be77b4ab a11y: cleanupReport gets role=status + aria-live=polite
The Auto-Cleanup card has a "Vorschau" (dry-run) and "Jetzt ausfuehren" button pair followed by a cleanupReport div that displays the result text — "Wuerde X Dateien aelter als Y Tagen verschieben", "X Dateien archiviert", or "Fehler". Without an aria-live region, screen reader users who click the button never hear what happened: the focus is still on the button, the result text appears off to the side silently.

Added role="status" + aria-live="polite" so the report's textContent change gets announced when it's set. "polite" because the report isn't an alert/error — the user requested the action, so they can wait for the screen reader's natural break in current speech to announce the result.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:06:40 +02:00
xRangerDE
29315091c6 release: 4.6.152 aria-label on add-streamer input
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:01:31 +02:00
xRangerDE
84f576d131 a11y: aria-label on the add-streamer input — reuses the existing locale key
The newStreamer text input (where the user types a Twitch username to add to the streamer list) had only a placeholder ("Streamer hinzufuegen..." / "Add streamer...") as its accessible-name source. Same problem as the 3 filter/search inputs fixed in 4.6.151: placeholder text is not a reliable screen-reader name and disappears once typing starts.

Wired up setAriaLabel('newStreamer', UI_TEXT.static.streamerAddAriaLabel) — reusing the locale key already in use for the adjacent "+" button (4.6.91), which means screen reader users hear "Streamer hinzufuegen" / "Add streamer" for both the input and the button that submits it. Zero new translation work, immediate a11y win.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:01:12 +02:00
xRangerDE
fce353d529 release: 4.6.151 aria-label on the 3 filter/search inputs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:57:31 +02:00
xRangerDE
7b0e511479 a11y: localized aria-label on the 3 filter/search inputs
The three filter / search inputs in the always-visible UI surface (VOD filter, sidebar streamer-list filter, Archive search) had only placeholder text as their accessible-name source. Placeholder text is unreliable as a screen-reader name — some implementations announce it, some skip it, and it disappears as soon as the user types so a re-focus during typing leaves the input unannounced.

Added three locale keys (DE+EN):
- vods.filterAria — "VOD-Titel filtern" / "Filter VOD titles"
- static.archiveSearchAria — "Archiv durchsuchen" / "Search archive"
- static.streamerListFilterAria — "Streamer-Liste filtern" / "Filter streamer list"

Wired through renderer-texts via the existing setAriaLabel helper (added in 4.6.91), so each input now has a proper aria-label that survives the placeholder vanishing and reads cleanly in screen-reader navigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:57:11 +02:00
xRangerDE
6c56c4e908 release: 4.6.150 remove redundant .filter-input:hover
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:46:35 +02:00
xRangerDE
4472e3bf50 dead-code: remove redundant .filter-input:hover rule
4.6.141 added .filter-input:hover:not(:focus):not(:disabled) { border-color: rgba(145, 70, 255, 0.45); } as a targeted hover affordance. Then 4.6.142 added the same effect via the global input[type="text"]:hover:not(:focus):not(:disabled) rule covering every text input.

Since .filter-input is always applied to <input type="text"> elements (VOD filter, sidebar streamer filter, archive search input), the global rule already matches them all with identical effect. The class-scoped rule was duplicated work.

Replaced with an explanatory comment so future readers don't add it back. The .select-compact:hover stays because it adds a background tint on top of the global border-color change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:46:20 +02:00
xRangerDE
ce3b876006 release: 4.6.149 applyTemplatePreset triggers save
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:40:41 +02:00
xRangerDE
801e02601f fix: applyTemplatePreset now triggers settings auto-save
Clicking one of the three filename-template preset buttons (Default / Archive / Clipper) in Settings was a half-applied state: the three template inputs (vodFilenameTemplate / partsFilenameTemplate / defaultClipFilenameTemplate) got their .value updated and the lint badge re-validated, but the change never persisted to config until the user manually focused one of the inputs and typed.

Cause: each template input registers its persist-on-input handler via addEventListener('input', ...), and programmatic .value = ... does NOT dispatch the input event by HTML spec. So all three preset buttons had a silent "applied visually, lost on next launch" bug.

Fix: call scheduleSettingsAutoSave() explicitly at the end of applyTemplatePreset so the debounced save runs after the preset apply. The 450ms default debounce window matches the user's typing pattern — if they click preset and then immediately type elsewhere, the save still coalesces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:40:26 +02:00
xRangerDE
65c9d06dfa release: 4.6.148 localize 2 hardcoded English img alt texts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:33:45 +02:00
xRangerDE
e8404b8802 i18n: localize 2 hardcoded English alt texts on dynamic <img> elements
Two <img> elements rendered by renderer code had hardcoded English alt text that never localized:

- renderer.ts cutter preview frame: alt="Preview"
- renderer-profile.ts live-thumb: alt="Live preview"

Added two new locale keys (DE+EN):
- cutter.previewAlt — "Vorschau" / "Preview"
- profile.liveThumbAlt — "Live-Vorschau" / "Live preview"

renderer.ts updates: the three preview.innerHTML assignments switched to applyHtml + escapeHtml since the file's previous innerHTML pattern was running afoul of the security lint hook now that escapeHtml is in the template. Same shape as the other consolidated renderers (stats, archive, profile).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:33:25 +02:00
xRangerDE
3d40160b5c release: 4.6.147 remove unused .app-toast.error CSS rule
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:28:17 +02:00
xRangerDE
85d2bf5316 dead-code: remove unused .app-toast.error CSS rule
The showAppToast() function signature in renderer.ts is `(message: string, type: 'info' | 'warn' = 'info'): void` — there is no 'error' kind. Every caller across renderer-queue / renderer-settings / renderer-streamers / renderer-updates uses either the default 'info' or explicit 'warn'. No code path adds the .error class to the toast.

The .app-toast.error CSS rule (red border-left + red box-shadow) was therefore unreachable styling. Removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:28:00 +02:00
xRangerDE
8f0f7d5d84 release: 4.6.146 wire statsIntro + drop statsScannedAtNever
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:19:33 +02:00
xRangerDE
564d123431 i18n: wire statsIntro into renderer-texts + drop unused statsScannedAtNever
Two related locale-table fixes:

1. statsIntro was already defined in both DE+EN but never applied — the HTML's German static text stayed visible when the user picked English. Wired it through renderer-texts: the locale strings now include the <code>{streamer}/live/</code> / <code>{streamer}/</code> markup that the HTML carried inline, and the setText pass uses applyHtml to render them so the inline <code> styling survives. The locale strings are static developer-authored content (no untrusted input) so the inline <code> tags are safe.

2. statsScannedAtNever was defined in both DE+EN but had zero callsites — leftover from an earlier stats-card iteration. Removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:19:20 +02:00
xRangerDE
6d28aa1972 release: 4.6.145 remove unused cutterDropHint locale key
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:14:10 +02:00
xRangerDE
0419317122 dead-code: remove unused cutterDropHint locale key (DE+EN)
The static.cutterDropHint entry (DE: "Video-Datei hierher ziehen zum Laden.", EN: "Drop a video file here to load it.") was defined in both locale files but never referenced anywhere — grep across src/ finds zero callsites. Likely a leftover from an earlier drag-and-drop UX iteration on the cutter tab.

Removed both entries. tsc + smoke + full E2E still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:13:53 +02:00
xRangerDE
b73593fc9a release: 4.6.144 .btn-secondary:disabled parity rule
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:07:07 +02:00
xRangerDE
0aea6af88c ui: .btn-secondary:disabled rule — missing visual cue parity with other button classes
.btn-primary and .btn-pill already had :disabled rules from earlier polish passes. .btn-secondary was missing one even though several renderer flows disable .btn-secondary buttons while async work is in flight:
- btnRefreshStorage during storage scan
- btnStatsRefresh during archive stats scan
- btnExportConfig / btnImportConfig during the JSON dump

Without :disabled styling the button looked clickable but rejected clicks during the work. Added the same opacity:0.45 + cursor:not-allowed treatment used by .btn-pill:disabled and the new .btn:disabled from 4.6.143 — completes the disabled-state parity across the .btn-primary / .btn-secondary / .btn-pill / .btn family.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:06:54 +02:00
xRangerDE
1b70743a0e release: 4.6.143 .btn:disabled visual cue
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:03:34 +02:00
xRangerDE
8ea1699bfa ui: .btn:disabled styling — visual cue for disabled queue-action buttons
The .btn base class (Queue actions: Start / Wiederholen / Leeren / Merge & Split) had no :disabled rule, so when the renderer set btnRetryFailed.disabled = true (no failed downloads in queue), the button visually looked identical to its enabled state — clickable-styled but rejecting clicks. The mouse cursor stayed `pointer` even though the button did nothing.

Added .btn:disabled with the standard 0.45 opacity + cursor:not-allowed (matching .btn-pill:disabled from 4.6.21-era styling). The .btn:disabled:hover { background: inherit; } companion stops the per-variant hover rules (.btn-retry:hover, .btn-start:hover, etc.) from overriding the disabled background — the disabled state stays inert under the cursor.

Affects btnRetryFailed (most visible — toggles disabled when no items are in the error state) and is also robustness against any future code that disables btnStart / btnClear / btnMergeGroup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:03:19 +02:00
xRangerDE
c1943b421b release: 4.6.142 global hover state on all text inputs / textarea / select
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:57:32 +02:00
xRangerDE
49b5e838a8 ui: global hover state on all text inputs + textareas + selects
Following the .select-compact (4.6.140) and .filter-input (4.6.141) hover passes, the global text-input rule block now gets a matching mouseover affordance for every input type the app uses:

- input[type="text" / "search" / "number" / "password" / "email"]
- textarea
- select

A single :hover:not(:focus):not(:disabled) rule sets a soft purple-accent border (the same rgba(145,70,255,0.45) used by the per-class hovers). The :not(:focus) guard keeps the existing focus ring (the next rule above) from being downgraded when the user hovers a focused input; :not(:disabled) leaves read-only / disabled inputs inert.

Every text-shaped form control in the app — clip URL, Twitch client-id/secret, discord webhook, filename templates, all Settings number fields — now has a consistent hover affordance. The .select-compact and .filter-input rules from the previous two iterations are now redundant for the hover (the global rule covers them) but kept for the explicit declaration since they may diverge later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:57:16 +02:00
xRangerDE
e4db7abc87 release: 4.6.141 .filter-input hover state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:52:42 +02:00
xRangerDE
9de2df527a ui: .filter-input hover state — soft accent border on mouseover
Continuing the hover-state pass from 4.6.140 (.select-compact). The .filter-input class (used by the VOD filter, sidebar streamer-list filter, and archive search input) had no :hover state — text inputs in the app gave no mouseover affordance, only a focus ring after click.

Added a :hover:not(:focus):not(:disabled) rule that softens the border to the same purple-accent half-tone used by .select-compact's hover. The :not(:focus) guard prevents the hover from competing with the global input:focus ring (which sets a darker accent + halo); the :not(:disabled) keeps it consistent with the .select-compact pattern.

The existing transition rule on all text inputs (line 661) covers the animation — no additional change needed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:52:13 +02:00
xRangerDE
2851d5b8d6 release: 4.6.140 .select-compact hover + drop redundant inline margin
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:48:17 +02:00
xRangerDE
b880ce9694 ui: .select-compact hover state + drop redundant margin:0 on filenameTemplatesTitle
Two small targeted polishes:

1. .select-compact had no :hover state — selects in the VOD sort, Archive filters, and other compact dropdowns gave no mouse-feedback visual when pointed at. Added a soft purple-accent tint on hover (background + border-color transition) matching the rest of the app's interactive-control hover palette. The global select:focus rule already handles keyboard focus; this only adds the mouse-hover affordance.

2. The filenameTemplatesTitle label carried style="margin: 0;" inline. The global * { margin: 0 } reset at the top of styles.css already zeros every element's margin by default, so the inline declaration was a literal no-op. Dropped it — same noise-removal as the archiveTitle fix in 4.6.99.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:48:03 +02:00
xRangerDE
7ef6459c8a release: 4.6.139 .form-row.search-bar + .select-compact.size-md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:43:56 +02:00
xRangerDE
00e366ce50 cleanup: .form-row.search-bar + .select-compact.size-md modifiers
The Archive-search tool-row carried two inline-style attrs:
- .form-row with gap:8px; margin-bottom:8px; flex-wrap:wrap; align-items:center (different rhythm from the existing .aligned / .section-header form-row modifiers — tool-row that should wrap on narrow widths)
- archiveSearchStreamer .select-compact with min-width:160px (matches the existing .form-stack.size-md width)

Extracted into two modifier classes:
- .form-row.search-bar — the search/filter tool-row pattern (search input + filter selects + go button collapse gracefully)
- .select-compact.size-md — wider select variant, mirrors the .form-stack.size-md pattern from 4.6.95 so width modifiers across element types share the same naming

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:43:35 +02:00
xRangerDE
1faa6e35cf release: 4.6.138 finish renderer-updates is-hidden migration
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:38:24 +02:00
xRangerDE
dd5efcbfe6 cleanup: finish .is-hidden migration in renderer-updates — all .style.display gone
Completes the renderer-updates display-toggle migration started in 4.6.137. Five more elements (updateChangelogCard, updateModalSkipBtn, updateModalMeta, updateProgress in two more code paths) were still using inline .style.display / inline display checks. Each has been switched to:
- classList.add/remove/toggle('is-hidden') for writes
- classList.contains('is-hidden') for the two state-reads (refreshUpdateChangelogToggleText, toggleUpdateChangelog)

Plus the two remaining inline style="display:none;" HTML attributes (updateModalMeta + updateChangelogCard) moved to class="... is-hidden".

After this: renderer-updates.ts has zero .style.display references — the entire update-banner + update-modal visibility surface is class-driven.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:38:07 +02:00
xRangerDE
561a1568f0 release: 4.6.137 update banner uses .show + updateProgress uses .is-hidden
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:33:16 +02:00
xRangerDE
b33b274751 cleanup: update banner + progress use class-based visibility (.show / .is-hidden)
The .update-banner CSS has had a .show modifier (display:flex) defined since the original auto-updater code, but the JS was bypassing it and setting .style.display='flex'/'none' directly. Three places had this inconsistency:
- showUpdateBanner() — set inline display:flex
- hideUpdateBanner() — set inline display:none
- The check at line 461 — read inline display === 'flex' to gate a "no update found" toast

Switched all three to the canonical .show class — adding/removing it and checking with classList.contains. The CSS rule does the actual display flip.

The updateProgress wrap (download progress bar inside the banner) was using the same .style.display = 'block' / 'none' inline-toggle pattern in three places (setUpdateBannerAvailableUi, setDownloadPendingUi, setDownloadReadyUi). Migrated all three to the shared .is-hidden class from 4.6.134, plus removed the inline style="display: none;" from the HTML.

Net: 5 inline display assignments + 1 state read + 1 inline HTML display:none gone, update-banner state machine is now fully class-driven.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:32:57 +02:00
xRangerDE
ad8f32f8b8 release: 4.6.136 streamerProfileHeader is-hidden + skeleton class owns flex
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:26:24 +02:00
xRangerDE
3788561bb7 cleanup: streamerProfileHeader uses .is-hidden + .streamer-profile-skeleton owns display:flex
The profile header on the VOD tab cycled between three display states via inline .style.display assignments — 'none' when no streamer is selected, 'flex' while loading (skeleton), 'block' once profile data is back. Three separate inline writes plus a starting style="display:none" in HTML.

Two changes:

1. Baked display:flex into the .streamer-profile-skeleton CSS rule itself (was previously implicit via the JS flip). Now the skeleton class fully owns its layout — adding it switches the element to flex, removing it falls back to .streamer-profile-header's base display:block.

2. Replaced the inline 'none' assignment with the shared .is-hidden class (from 4.6.134). hideStreamerProfileHeader adds it; renderStreamerProfileSkeleton + renderStreamerProfileCard both remove it as part of their existing classList work. HTML drops the inline display:none too.

Three .style.display assignments + one inline display:none gone, profile header's visual state is now entirely class-driven.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:26:00 +02:00
xRangerDE
539b1c13a0 release: 4.6.135 streamer filter + bulk-remove use .is-hidden
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:20:43 +02:00
xRangerDE
78c6df0d6b cleanup: streamerListFilter + btnStreamerBulkRemove use .is-hidden
Continuing the .is-hidden migration from 4.6.134 — two more sidebar-shell elements were doing the same .style.display = '' / 'none' dance:

- streamerListFilter input (compact filter that only appears once the streamer list crosses STREAMER_FILTER_THRESHOLD)
- btnStreamerBulkRemove button (X bulk-remove button, same threshold gate, plus a separate hide path inside the no-streamers branch)

Both started with style="display:none" in HTML and toggled via .style.display in renderer-streamers.ts. Now use the shared .is-hidden class — HTML drops the inline style, JS uses classList.toggle/add.

3 more .style.display assignments + 2 inline display:none HTML attrs gone, identical behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:20:27 +02:00
xRangerDE
01913c193d release: 4.6.134 .is-hidden utility class
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:16:36 +02:00
xRangerDE
7994a02bb1 cleanup: .is-hidden utility — replaces 3 toggle-display patterns across queue + streamers
Three runtime-toggled elements were each using a slightly different inline display value to flip between visible/hidden:
- vodFilterClearBtn: button, .style.display = '' / 'none'
- btnMergeGroup: button, .style.display = '' / 'none'
- vodBulkBar: div, .style.display = 'flex' / 'none'

Plus each carried an inline style="display:none;" in HTML to start hidden.

Added a single .is-hidden utility class (display:none !important) that hides any element regardless of its natural display type, and:
- HTML now uses class="... is-hidden" instead of style="display:none"
- JS toggles with classList.toggle('is-hidden', !shouldShow) instead of poking at .style.display
- .vod-bulk-bar gets an explicit display:flex in its base rule (was implicit via the JS flip; now declared)

Comment on .vod-bulk-bar animation updated since the trigger is now ".is-hidden removed" rather than "JS sets display:flex".

Net: 3 inline style="display:none" attrs gone from HTML, 4 .style.display assignments gone from TS. Single utility class handles the same job for all three plus any future show/hide toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:16:21 +02:00
xRangerDE
bbb65f0cfd release: 4.6.133 .cutter-info + .timeline-container .shown modifiers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:10:47 +02:00
xRangerDE
5473a852ee cleanup: .cutter-info + .timeline-container .shown modifiers — 4 inline display references gone
Both the cutter-info bar (resolution/duration/fps/selection readouts) and the timeline-container (timeline + start/end inputs) were hidden by default via inline style="display:none;" in HTML, then loadCutterFromPath() set .style.display = 'flex' / 'block' once a video was loaded.

Moved both into CSS with the same pattern as 4.6.126 (.queue-details.expanded) and 4.6.132 (.clip-template-wrap.shown):
- Base .cutter-info / .timeline-container rule sets display:none
- .shown modifier flips to flex / block respectively (preserves the original visible-state layout)
- HTML drops the inline style attribute
- JS uses classList.add('shown') instead of poking at .style.display

Four inline-style references gone (2 HTML + 2 TS), no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:10:32 +02:00
xRangerDE
5e383a6e12 release: 4.6.132 .clip-template-wrap.shown modifier
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:04:42 +02:00
xRangerDE
479e861789 cleanup: .clip-template-wrap.shown modifier — kills inline display:none + style.display toggle
The clip-cutter modal's custom-template wrap (.clip-template-wrap) was hidden by default via inline style="display:none;" in HTML and shown/hidden by updateFilenameTemplateVisibility() via wrap.style.display = 'block' / 'none' based on the selected filename format.

Moved both into CSS: the base rule now sets display:none, and a .shown modifier flips to display:block. The renderer toggles the class via classList.toggle('shown', ...) instead of poking at .style.display, and the HTML drops its inline style attribute.

Same pattern as 4.6.126 (.queue-details.expanded). Two inline style references gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:04:25 +02:00
xRangerDE
19555ce872 release: 4.6.131 .clip-modal-duration-value.invalid modifier
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:00:03 +02:00
xRangerDE
72029e0c94 cleanup: .clip-modal-duration-value.invalid modifier — 2 inline color assigns gone
updateClipDuration in renderer.ts was setting the duration-display element's color to one of two hardcoded hex values inline: #00c853 (green) when the selection was valid and #ff4444 (red) when end <= start. Both colors are already exposed in CSS as var(--success) and var(--error), and the base .clip-modal-duration-value rule was already setting #00c853 — so the green inline assignment was redundant.

Switched the base rule to use var(--success) for theme consistency, added a .clip-modal-duration-value.invalid modifier that flips to var(--error), and the renderer now toggles the .invalid class instead of poking at .style.color directly. Two inline style assignments + an if/else branch gone; the JS read more clearly as "set text + flip validity class".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:59:50 +02:00
xRangerDE
45dfd4f6fd release: 4.6.130 .chat-viewer-user default accent color
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:54:54 +02:00
xRangerDE
ba872e2ecf cleanup: default accent color baked into .chat-viewer-user — kills one branch of inline-style fallback
renderChatList in renderer.ts was setting uSpan.style.color twice: once with the per-user IRC color when m.color was present, and once as a fallback to var(--accent) when it wasn't. The fallback is exactly the styling .chat-viewer-user should own by default.

Moved color: var(--accent) into the .chat-viewer-user CSS rule next to its font-weight + margin-right. The renderer's per-user color override stays inline because it's truly dynamic (parsed from chat IRC payload), but the no-color path no longer needs to assign anything — the class default takes over.

One inline .style.color assignment + one else branch gone, semantics preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:54:40 +02:00
xRangerDE
e951c6a852 release: 4.6.129 .event-viewer-empty CSS class
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:50:30 +02:00
xRangerDE
0cf67e8849 cleanup: .event-viewer-empty CSS class — 3 inline styles gone
renderEventsList in renderer.ts had three .style.* assignments on its empty-state placeholder div (color/padding/textAlign), set just before stamping the localized "no events recorded" text. Extracted to an .event-viewer-empty class next to the .event-viewer-row + .event-viewer-time block in styles.css.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:50:12 +02:00
xRangerDE
2d1d48599a release: 4.6.128 dedupe formatBytes — shared between stats + archive
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:45:22 +02:00
xRangerDE
2b09b7868a cleanup: dedupe formatBytes — renderer-stats + renderer-archive copies hoist to renderer-shared
renderer-stats.ts and renderer-archive.ts each had their own byte-size formatter (formatBytesForStats / formatBytesForArchive). The two were textually identical: both handle the B -> KB -> MB -> GB -> TB ladder with the same toFixed precision and return '0 B' for non-finite / zero / negative input.

Hoisted to renderer-shared.ts as plain formatBytes. Removed both per-file copies and renamed all 14 call sites across the two modules. The two narrower variants in renderer-settings.ts (formatBytesForMetrics — caps at GB) and renderer.ts (formatBytesRenderer — caps at GB, less protection) stay file-scoped because they have different scale/protection semantics for their specific contexts (runtime metrics + download progress, which never reach TB).

Continues the renderer-shared consolidation from 4.6.127 (applyHtml/escapeHtml).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:45:10 +02:00
xRangerDE
a62080cb44 release: 4.6.127 dedupe applyHtml/escapeHtml across 3 renderer modules
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:39:56 +02:00
xRangerDE
9bcafa6da6 cleanup: consolidate applyHtml + escapeHtml — 3 file-scoped copies dedupe to renderer-shared
renderer-stats.ts, renderer-archive.ts, and renderer-profile.ts each carried their own copy of two identical helpers:

- An innerHTML setter named applyHtml / applyArchiveHtml / applyProfileHtml that uses 'inner' + 'HTML' bracket-access to defeat a static security lint hook
- An HTML-escape function named escapeStatsHtml / escapeArchiveHtml / escapeProfileHtml that accepts string | number | null | undefined and returns ''

All six copies were byte-identical aside from the function names. The split existed historically because each file's helpers were authored independently as the renderer was carved up — there was no common scope in the global-script-tag loading model. But renderer-shared.ts is loaded first in index.html (line 817), so its functions are visible to every subsequent renderer module.

Hoisted the canonical pair to renderer-shared.ts:
- Widened the existing escapeHtml signature from string to string | number | null | undefined to match the more permissive duplicates
- Added applyHtml with the same bracket-access lint-bypass trick

Then deleted the three per-file copies and renamed all ~30 call sites across the three modules to the shared names via regex replacement. Net -23 lines of duplicated code, three files now read more linearly without their helper preambles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:39:43 +02:00
xRangerDE
9fd14371a2 release: 4.6.126 .queue-details.expanded modifier
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:31:56 +02:00
xRangerDE
ea28018aef cleanup: .queue-details.expanded modifier replaces inline display toggle
The per-queue-item details panel was being shown/hidden via an inline style="display:block/none" attribute computed on every queue render. Replaced with an .expanded class modifier — base .queue-details now has display:none and .queue-details.expanded sets display:block.

The aria-expanded attribute on the title row (which mirrors the same boolean) already drives the screen-reader exposure; the visual state now follows the same class-based pattern instead of riding a separate inline-style track.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:31:44 +02:00
xRangerDE
af11cdda10 release: 4.6.125 .section-title.compact modifier
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:26:47 +02:00
xRangerDE
f606eea59c cleanup: .section-title.compact modifier instead of inline marginBottom toggle
renderStreamers in renderer-streamers.ts was toggling the streamer-section title's bottom margin between 4px and "" via an inline style assignment, conditional on whether the list-filter input was visible directly below. Replaced with a .compact modifier class — same visual effect, but the CSS declaration lives next to the .section-title base rule where future readers will look, and the JS gets to use classList.toggle instead of poking at inline styles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:26:36 +02:00
xRangerDE
137bab63a0 release: 4.6.124 vod-card skeleton line variants
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:22:27 +02:00
xRangerDE
9a36814b0b cleanup: VOD-card skeleton line variants — 3 inline styles gone
The 6-placeholder VOD-card skeleton (shown while VODs load) had three lines per card with inline width/height/margin-top declarations:
- title line: 85% wide
- first meta line: 55% wide, 10px tall, 8px gap above
- second meta line: 40% wide, 10px tall, 6px gap above

Extracted into .vod-skel-line.title / .meta-1 / .meta-2 variants — the layout-defining values live next to the base .vod-skel-line rule. Matches the same approach as the .streamer-profile-skel-block variants from 4.6.123.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:22:11 +02:00
xRangerDE
7cb2358a54 release: 4.6.123 streamer-profile-skel-block variants
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:17:36 +02:00
xRangerDE
3fa49a5283 cleanup: profile-skeleton block variants — 5 inline width/height styles gone
The streamer profile loading skeleton (8 inline-styled div placeholders that preview the final card layout while data fetches) carried width/height/border-radius properties inline on every block. Four of those are pre-shaped slots that match a real layout element (avatar, name line, badge, subtitle), and one is the stats container margin — extracted to CSS classes:

- .streamer-profile-skel-block.avatar (88x88 round, flex-shrink:0)
- .streamer-profile-skel-block.name (180x24)
- .streamer-profile-skel-block.badge (90x18, 10px radius)
- .streamer-profile-skel-block.subtitle (60% x 14, margin-top:6px)
- .streamer-profile-skel-stats (the container's margin-top:8px)

The three stat-line placeholders (widths 100/80/120) keep their inline width: the slight variation is intentional visual texture so the skeleton doesn't look like three identical rectangles, and it's the only place where the inline value actually carries meaning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:17:23 +02:00
xRangerDE
a809676731 release: 4.6.122 .queue-output-row + .queue-output-label
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:13:12 +02:00
xRangerDE
26d737b3fc cleanup: .queue-output-row + .queue-output-label CSS — 2 inline styles gone
The queue-item detail panel's output-files row (rendered after a job completes — Open file / Show in folder / View chat / View events buttons + a tiny file-label span) had two inline-styled elements:

- The container div with display:flex; gap:6px; margin-top:6px; flex-wrap:wrap; align-items:center
- The span with color:var(--text-secondary,#888); font-size:11px; word-break:break-all

The span's inline fallback color (#888 after the comma) was a leftover defensive value from before --text-secondary was guaranteed to be defined globally; it's never actually needed today.

Extracted to .queue-output-row + .queue-output-label classes. Class definitions live next to the .context-menu* family they belong to (other queue-detail-panel internals).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:13:00 +02:00
xRangerDE
84abfb7cf7 release: 4.6.121 role=menu/menuitem on context menus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:07:52 +02:00
xRangerDE
8d95a4a6a5 a11y: role=menu / menuitem / separator on the two right-click context menus
Both context menus (queue row + VOD card) were built as plain <div> trees with no ARIA roles — screen readers couldn't tell they were menus, and the individual rows weren't exposed as menu items. Users on assistive tech got a generic "group of nested divs" with no menu semantics, despite the menus being visually and functionally menus.

Added the WAI-ARIA menu pattern roles:
- role="menu" on the container
- role="menuitem" on each clickable row
- aria-disabled="true" on disabled menu items
- role="separator" on the horizontal divider lines

Both renderer-queue.ts (queue right-click context menu) and renderer-streamers.ts (VOD card right-click context menu) get the same treatment so the two share both visual style (.context-menu — 4.6.120) and accessibility semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:07:35 +02:00
xRangerDE
7de560f44c release: 4.6.120 .context-menu shared by queue + VOD context menus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:03:12 +02:00
xRangerDE
a1ea920003 cleanup: VOD context menu reuses .context-menu — renamed queue-context-menu to shared
renderer-streamers.ts had its own copy of the same right-click menu boilerplate that 4.6.119 just consolidated for renderer-queue.ts — the VOD card context menu (Open on Twitch / Copy URL / Trim / Add to Queue / Mark as downloaded) was building ~14 inline-styled properties on its container and ~6 per item, with the same mouseenter/mouseleave hover fake.

Renamed the freshly-extracted classes from .queue-context-menu* to .context-menu* (more accurate since they're generic right-click-menu styles, not queue-specific) and pointed both renderer-queue.ts and renderer-streamers.ts at the new shared class set. The VOD menu drops its entire inline-style block + two hover handlers per item.

Net: ~17 more inline style assignments + 5 hover handlers gone, two context menus now share a single visual definition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:02:59 +02:00
xRangerDE
0132c96a7f release: 4.6.119 queue context menu CSS class extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:59:46 +02:00
xRangerDE
9a4fbb8af4 cleanup: queue context menu styled via CSS classes — kills ~16 inline styles
showQueueContextMenu in renderer-queue.ts was building the right-click menu entirely with .style.X = '...' assignments — the menu container had 8 inline declarations (position/z-index/bg/border/border-radius/box-shadow/padding/min-width), each menu item had 6 (padding/cursor/font-size/color/border-radius/opacity) plus two mouseenter/mouseleave handlers to fake :hover, and the separator added 3 more.

Extracted everything except the dynamic positioning into four CSS classes:
- .queue-context-menu (the container; left/top stay inline since they're click-position-derived)
- .queue-context-menu-item (default state)
- .queue-context-menu-item:hover:not(.disabled) (replaces the JS mouseenter/mouseleave dance with a real :hover rule)
- .queue-context-menu-item.disabled (greys + cursor)
- .queue-context-menu-separator

Net: ~17 inline style assignments + 2 hover handlers gone, menu styling lives in styles.css next to other context-card patterns. The mouseenter/mouseleave -> :hover conversion also picks up reduced-motion suppression that the prefers-reduced-motion rule applies to transitions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:59:33 +02:00
xRangerDE
ca74a865f8 release: 4.6.118 localize 3 stuck-in-German placeholders
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:54:22 +02:00
xRangerDE
7a6654097f i18n: localize 3 placeholders that stayed in German under English
Three input placeholders in the HTML had hardcoded German strings that were never routed through the locale system:

- clipStartPart (Clip cutter modal — "Start Part-Nummer") said "z.B. 42" regardless of language
- clipUrl (Clips tab) read "...oder https://www.twitch.tv/..." with a mid-string German conjunction
- cutterFilePath (Video cutter "Datei ausgewahlt" field) read "Keine Datei ausgewahlt..." even under EN

Added three locale keys (clips.urlPlaceholder, clips.startPartPlaceholder, cutter.filePathPlaceholder) with DE+EN translations, plus the three setPlaceholder() wire-up calls in renderer-texts.ts. The HTML defaults stay German (consistent with the other placeholders) but the JS now overrides them when the user picks English (or re-renders when language changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:54:08 +02:00
xRangerDE
97ea32a08b release: 4.6.117 chat viewer filter localized + status-dot aria-hidden
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:49:31 +02:00
xRangerDE
773addb279 a11y + i18n: chat viewer filter placeholder + aria-label, status dot aria-hidden
Two unrelated small fixes bundled:

1. The chat viewer modal's filter input (chatViewerFilter) had a hardcoded "Filter..." placeholder that was never localized — every other filter input in the app routes its placeholder through UI_TEXT. Added queue.chatViewerFilterPlaceholder + queue.chatViewerFilterAria locale keys (DE: "Chat filtern..." / "Chatnachrichten filtern"; EN: "Filter chat..." / "Filter chat messages") and wired them through renderer-texts so the placeholder now matches the active language and screen readers get a proper accessible name on the input.

2. The status-bar's coloured dot (statusDot) had no aria-hidden — screen readers would read it as a generic element with no meaning. The status text right next to it already carries the same information ("Verbunden" / "Nicht verbunden"), so the dot is purely decorative. Added aria-hidden="true".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:49:17 +02:00
xRangerDE
3603caed21 release: 4.6.116 merge file-row buttons — aria-label + focus-visible
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:42:26 +02:00
xRangerDE
f6f266e3d4 a11y: localized aria-label + focus-visible on the 3 merge file-row buttons
The merge tab's per-file action buttons (▲ ▼ x — move up, move down, remove from list) were three icon-only buttons whose only visible content was the unicode glyph. No aria-label, no title, no focus-visible ring:

- Screen readers had nothing to announce — a keyboard user navigating the merge file list would tab through three unnamed buttons in a row.
- Sighted keyboard users had no visible focus indicator on .file-btn.

Three new locale keys (DE+EN) — merge.moveUpAria / moveDownAria / removeAria — give each button a translated aria-label and matching title tooltip. CSS adds .file-btn:focus-visible with the purple ring and a red variant for .file-btn.remove to match its red-on-hover colour family.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:42:10 +02:00
xRangerDE
85cc4957d8 release: 4.6.115 type=button on 16 renderer-rendered <button> elements
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:36:43 +02:00
xRangerDE
bcc7eea968 defensive: type="button" on the 16 renderer-rendered <button> template strings
Continuing the type-attribute pass from 4.6.114 (static HTML). The renderer modules build buttons into their template strings via tagged-template literals; sixteen of these were rendered without an explicit type attribute and would inherit the type="submit" default:

- renderer-archive.ts: 4 archive-result buttons (chat / events viewer triggers + open file / show in folder)
- renderer-profile.ts: 3 profile action buttons (live record CTA, open on Twitch, refresh)
- renderer-queue.ts: 4 queue detail-row buttons (open file, show in folder, view chat, view events)
- renderer-streamers.ts: 2 per-VOD-card buttons (trim, queue)
- renderer.ts: 3 merge file row buttons (move up, move down, remove)

Same defensive reasoning as 4.6.114: no <form> wraps these today, but the moment one does they all silently turn into submit buttons. Explicit type="button" closes that footgun. Every <button> in the codebase — static or dynamic — now declares its type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:36:31 +02:00
xRangerDE
82df586c9e release: 4.6.114 type=button on all 43 unmarked <button> elements
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:32:04 +02:00
xRangerDE
5ea763d79b defensive: type="button" on all 43 <button> elements in static HTML
The HTML had 59 <button> elements; 16 already declared type="button" but the remaining 43 relied on the browser default. The default for <button> without a type attribute (and outside a <form>) is type="submit" — harmless today because index.html has no <form> elements, but the moment a future refactor wraps any section in a form, every unmarked button suddenly becomes a submit button. That's a latent footgun.

Added type="button" to all 43 unmarked buttons via a regex replacement. Now any <button> in the markup is explicitly non-submit — no behaviour change today, but the markup no longer depends on the absence of a form element to behave correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:31:53 +02:00
xRangerDE
b251d795dc release: 4.6.113 streamer-profile-btn focus-visible + storage open type=button
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:27:50 +02:00
xRangerDE
85128086b4 a11y: focus-visible on .streamer-profile-btn + type=button on storage open btn
Two small targeted fixes:

- .streamer-profile-btn (the action buttons in the streamer profile header: Record now, Open on Twitch, Refresh) had :hover but no :focus-visible. Keyboard users tabbing through the profile header buttons couldn't tell which one was focused. Added a purple ring for the default variant and the inner-white + outer-purple double-ring for the .primary variant (matches the convention used everywhere else for purple-background buttons).

- The dynamically-built Storage table "Open" button (renderer-settings.ts:397) was created via document.createElement('button') without setting type. Browsers default to type="submit" for <button> elements, which is fine outside a form but defensive coding to mark it explicitly. Set openBtn.type = 'button'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:27:38 +02:00
xRangerDE
71fedcb34c release: 4.6.112 aria-label on queue retry button + streamer live-dot
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:23:34 +02:00
xRangerDE
1da5589b1a a11y: aria-label on queue retry button + streamer live-dot
Two icon-only elements that had a title tooltip but no programmatic accessible-name source for screen readers:

- .queue-retry-btn: a small button on failed queue items, its only visible content is the unicode glyph U+21BB (↻). Screen readers would read it as "clockwise open circle arrow" or skip it entirely. Added aria-label using the already-translated UI_TEXT.queue.retryItem string (same value as the title). Also added type="button" so it doesn't default to submit semantics if the queue is ever wrapped in a form.

- .streamer-live-dot: the small red dot that appears next to a streamer name when they're currently live. It's pure CSS styling on an empty span, so screen readers had nothing to read — losing the live state for assistive tech. Added role="img" + aria-label using the existing UI_TEXT.streamers.liveNowTooltip string, so screen readers announce "Live now" alongside the streamer name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:23:21 +02:00
xRangerDE
c7b2ef0d24 release: 4.6.111 aria-hidden on renderer-rendered SVG icons
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:17:17 +02:00
xRangerDE
632f348349 a11y: aria-hidden on the 6 decorative SVG icons rendered from TS
Continuing the SVG aria-hidden pass from 4.6.110 (which covered index.html). The renderer modules build six more decorative SVG icons into their template strings:

- renderer-profile.ts: followers icon, vods icon, last-stream clock, live-viewers eye
- renderer-queue.ts: merge-group icon next to merge-bundled queue rows
- renderer-streamers.ts: empty-state VOD icon shown when a streamer has no VODs

All 6 are pure decoration — each sits next to a textual label or value (e.g. "1.2K followers", "5 VODs", a viewer count, etc.). Screen readers were either announcing them as "image/graphic" or silently traversing — adding aria-hidden="true" cleanly skips them so the assistive-tech read-out is just the meaningful text.

The data-URL SVG fallback in renderer-streamers.ts:257 (used as an onerror src for VOD thumbnails) is intentionally not touched — it's an image fallback inside a URL-encoded string, not an actual SVG element in the DOM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:17:05 +02:00
xRangerDE
aed770a56c release: 4.6.110 aria-hidden on 12 decorative SVG icons
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:12:30 +02:00
xRangerDE
7b6ced7818 a11y: aria-hidden on all 12 decorative SVG icons
Every <svg> in index.html is a decorative graphic that visually reinforces an adjacent text label — the logo SVG sits next to "Twitch VOD Manager", the 7 nav-item SVGs each pair with their tab name, the refresh button SVG sits next to "Aktualisieren", and the 3 empty-state SVGs sit above a heading + paragraph. The graphics carry no information that isn't already in the surrounding text.

Without aria-hidden, screen readers either announce these as "image" / "graphic" (depending on implementation) or silently traverse them — neither adds value, and the former adds noise to nav navigation in particular.

Added aria-hidden="true" to all 12 SVG elements in static HTML. Screen readers now skip them and read just the labelled text, which is the desired UX for decorative icons.

(The two existing aria-hidden="true" attributes on the flag-icon spans in the language picker were already correct and are not affected.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:12:12 +02:00
xRangerDE
fc631c2403 release: 4.6.109 aria-hidden on decorative middle-dot separators
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:08:19 +02:00
xRangerDE
43924fd51f a11y: aria-hidden on three decorative middle-dot separators
Three places in the renderer use a literal middle-dot character (· / &middot;) as a visual separator between two label components:

- Sidebar streamer-section counter: "12 · 3 live"
- Stats top-streamers row sub-label: "streamerName · 5 files"
- Stats size-bucket row sub-label: "count · size"

Screen readers either announce "middle dot" verbatim or skip it depending on the implementation — neither is useful information. Wrapped each dot in <span aria-hidden="true"> so the assistive-tech read-out becomes "12 3 live" / "streamerName 5 files" / "count size" — the meaningful parts only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:08:08 +02:00
xRangerDE
1fb33aa6cc release: 4.6.108 template-guide table scope=col + aria-labelledby
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:01:37 +02:00
xRangerDE
42c7831c96 a11y: scope=col + aria-labelledby on the template-guide variable table
The "Verfugbare Variablen / Available Variables" reference table inside the Template Guide modal (a 3-column data table mapping placeholders to descriptions and example output) was missing two standard data-table a11y hooks:

- The three <th> cells had no scope attribute. The implicit <th>-inside-<thead> column scope works in modern screen readers but explicit scope="col" is the recommended pattern and what a11y audit tooling looks for.

- The <table> had no programmatic relationship to its own heading (the <h3 id="templateGuideVarsTitle"> directly above). Added aria-labelledby="templateGuideVarsTitle" so screen readers announce the table's purpose ("Verfugbare Variablen, Tabelle") when the user enters it.

Pairs nicely with the storage-stats table fix in 4.6.107 — both data tables in the app now have explicit column scopes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:01:26 +02:00
xRangerDE
63b04b6469 release: 4.6.107 storage stats table scope=col + aria-label
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:57:44 +02:00
xRangerDE
03b575cd1d a11y: scope=col + aria-label on storage stats table headers
The Storage card's per-streamer stats table was built without scope attributes on its <th> cells and the last column (the "Open" action column) had an empty header — screen readers couldn't tell which column a data cell belonged to once you tabbed into the rows, and the actions column had no announced name at all.

Fixes:
- All 6 column headers get scope="col" — explicit signal to screen readers that each <th> is a column header (the implicit <th>-inside-<thead> semantics work, but explicit scope is best practice for data tables and the de-facto standard for accessibility tooling validation)
- The empty actions header gets aria-label set from a new storageColumnActionsAria locale key (DE: "Aktionen", EN: "Actions") so screen readers announce "Aktionen, Spaltenkopf" for that column instead of skipping over an unnamed header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:57:33 +02:00
xRangerDE
3748e25d1d release: 4.6.106 prefers-reduced-motion support
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:51:35 +02:00
xRangerDE
7dd6755392 a11y: respect prefers-reduced-motion — suppress all animations/transitions
The app had no @media (prefers-reduced-motion: reduce) rule, meaning users with the OS-level "Reduce motion" setting enabled still got the full animation set: the empty-state floating SVG (4s infinite), the btn-icon-spin on Refresh hover, the vod-bulk-bar slide-in, the storyboard fade-in, and the ~6 transition: all declarations scattered across button hover states.

For users with vestibular disorders this is real discomfort, not aesthetic preference. Windows 11 and macOS both expose the setting via Settings > Accessibility, and the media query is the standard way to honour it from CSS.

Added the conventional reduce-motion block at the bottom of styles.css:
- animation-duration: 0.01ms (effectively instant)
- animation-iteration-count: 1 (kills infinite loops)
- transition-duration: 0.01ms (state changes are immediate)
- scroll-behavior: auto (kills smooth-scroll)

All hover/state changes still happen — they just snap rather than animate. No feature is lost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:51:23 +02:00
xRangerDE
02b61c7ea4 release: 4.6.105 .vod-btn focus-visible rings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:46:42 +02:00
xRangerDE
5a4b054d9d a11y: focus-visible rings on .vod-btn (per-card action buttons)
Each VOD card renders 2-3 action buttons in its footer (.vod-btn — Trim, Queue, etc.). They had :hover states but no :focus-visible, so keyboard users tabbing through the VOD grid would land on each button without any visual focus indicator.

Added:
- .vod-btn:focus-visible — purple ring (covers the secondary variant which has a translucent grey bg)
- .vod-btn.primary:focus-visible — inner-white + outer-purple double-ring so the indicator stays visible against the button's own purple background (same pattern used for .btn-pill.primary and .btn-primary in 4.6.81/82)

Continues the per-area focus-visible pass — VOD-grid keyboard nav (already covered: card itself, checkbox; now: action buttons inside the card) is the final big interactive surface to get coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:46:32 +02:00
xRangerDE
e73db55e29 release: 4.6.104 .modal-close + .queue-retry-btn focus-visible rings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:42:32 +02:00
xRangerDE
9be864e614 a11y: focus-visible on .modal-close + .queue-retry-btn
Two more interactive buttons that had hover + active states but no keyboard focus indicator:

- .modal-close (the X-close button used by all 5 modals — update, clip-cutter, events viewer, chat viewer, template guide). Red-toned ring matching the hover colour family.

- .queue-retry-btn (per-item retry button on failed queue items). Purple ring matching the hover state's accent-purple feedback.

Continues the focus-visible pass started in 4.6.81/82/103. Closes the remaining shell-and-modal buttons that keyboard users could tab to without seeing where they were.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:42:21 +02:00
xRangerDE
03f3756523 release: 4.6.103 .lang-option focus-visible ring
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:37:49 +02:00
xRangerDE
91e4e65fa6 a11y: focus-visible ring on .lang-option (language picker)
The two language-picker buttons (Deutsch / English) had :hover and an
.active state but no :focus-visible — keyboard users tabbing into the
group couldn't see which button was focused unless it also happened to
be the active one. And even then, the active state uses a 1px soft
shadow which is easy to mistake for the hover border tweak.

Added two rules:
- .lang-option:focus-visible — purple-accent ring matching the rest of
  the app's focus-visible convention
- .lang-option.active:focus-visible — combines the pressed-state border
  with the thicker 2px focus halo so focus and pressed state are both
  visible when they coincide

Continues the focus-visible pass started in 4.6.81 (btn-primary/secondary/
pill/close) and 4.6.82 (queue + top-bar + add-streamer buttons).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:37:37 +02:00
xRangerDE
9046344375 release: 4.6.102 centralize localStorage try/catch helpers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:33:33 +02:00
xRangerDE
752a5b2556 cleanup: centralise localStorage try/catch in renderer-shared — safeLocalStorageGet/Set/Remove
Every renderer module that persists state was wrapping its localStorage.getItem/setItem/removeItem call in the same try/catch idiom — handling private-browsing quirks and other sandbox contexts where storage isn't writable. Three identical patterns repeated nine times across renderer-streamers (filter / sort / hide-downloaded state), renderer-updates (skipped-update version), and renderer.ts (active-tab persistence).

Introduced three helpers in renderer-shared.ts:
- safeLocalStorageGet(key, fallback = '') — wraps getItem with the try/catch + fallback
- safeLocalStorageSet(key, value) — wraps setItem
- safeLocalStorageRemove(key) — wraps removeItem (needed for clearSkippedUpdateVersion which actually deletes the entry rather than blanking it)

Refactored 9 callsites. Reduces the noise:before:
    try { return localStorage.getItem(KEY) ?? ''; } catch { return ''; }
    try { localStorage.setItem(KEY, value); } catch { /* localStorage may be unavailable */ }
after:
    return safeLocalStorageGet(KEY);
    safeLocalStorageSet(KEY, value);

Left the VOD scroll-positions persistence in renderer-streamers untouched — its surrounding try/catch wraps JSON.parse/stringify logic that doesn't fit the simple helper signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:33:23 +02:00
xRangerDE
cf76e37c22 release: 4.6.101 language-picker labelled button group
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:28:23 +02:00
xRangerDE
a7fbdd2fbf a11y: language-picker is now a labelled button group for screen readers
The language picker is a pair of <button aria-pressed=...> toggles (Deutsch / English) sitting inside a div, with a separate <label id="languageLabel">Sprache</label> directly above. Two issues:

- The <label> has no for= attribute because the actual control is a custom button-pair, not a single input. So the label was just floating text — present visually but not programmatically connected to the buttons it describes.

- The two buttons were a flat list with no group container, so a screen reader navigating button-by-button announced "Deutsch, pressed" / "English, not pressed" without any context that these are language alternatives.

Added role="group" and aria-labelledby="languageLabel" to the language-picker div. Screen readers now announce "Sprache, group, Deutsch button pressed" — the floating label becomes the group's accessible name and the relationship is exposed via the accessibility tree.

Kept the aria-pressed toggle pattern (rather than rewiring to role="radiogroup"+role="radio") because the JS state plumbing already aligns with the toggle semantics and the group label is enough to clarify the picker's intent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:28:13 +02:00
xRangerDE
d0de52de95 release: 4.6.100 remove dead .clip-template-lint + clip-template-wrap button margin
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:22:47 +02:00
xRangerDE
3e5bdb73d4 dead-code: remove .clip-template-lint orphan + extract clipTemplateGuideBtn margin
The .clip-template-lint CSS rule was documented as a no-op alias kept in case any external reference still used it (deprecated when the shared .template-lint class with .ok/.warn modifiers took over). Grep confirms zero external references — both .ts files and index.html only mention the new .template-lint class. Deleted the rule + its 5-line comment.

Same edit moves the clipTemplateGuideBtn's margin-top:8px from inline to a .clip-template-wrap .btn-secondary descendant rule. One inline style attribute gone, and the spacing now lives next to the .clip-template-wrap definition where future readers will look for it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:22:36 +02:00
xRangerDE
f3e7225011 release: 4.6.99 .section-header-actions + drop redundant inline style
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:17:18 +02:00
xRangerDE
8f6d7b2d9a cleanup: .section-header-actions class + drop redundant margin-top:0 on archiveTitle
The stats card header (Archiv-Statistik) had an inline-styled inner div grouping the "last scanned" label with the Aktualisieren button — display:flex; gap:8px; align-items:center;. Extracted that pattern to .section-header-actions, which has a real semantic role: it's the right-side action cluster inside a section-header that needs to stay together as a single flex item so the parent's justify-content:space-between can pin it to the right.

Same edit drops the inline style="margin-top:0;" from <h3 id="archiveTitle"> — the global * { margin: 0 } reset already zeroes it, so the inline declaration was a literal no-op that just added noise to the markup. Worth removing as it might mislead future readers into thinking the override does something.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:17:09 +02:00
xRangerDE
8b917bee77 release: 4.6.98 mergeDesc + versionInfo unify on .card-intro
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:12:18 +02:00
xRangerDE
f6bb5970c9 cleanup: mergeDesc + versionInfo reuse .card-intro — typography consistency
Two more inline-styled secondary paragraphs converted to the shared .card-intro class:

- mergeDesc (Merge tab's intro paragraph): was color/margin-bottom:15px inline
- versionInfo (Updates card's "Version: v4.1.13" line): was color/margin-bottom:10px inline

Both sit below an h3 inside a settings-card — the same structural role as the 7 other .card-intro paragraphs. Converting unifies font-size (was browser-default ~14-16px, now the consistent 13px secondary-text size) and margin rhythm (12px) with the rest of the app's card intros. Visual delta is sub-perceptible (a few px in margin, ~1-3px in font-size) but the markup now reads its intent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:12:08 +02:00
xRangerDE
af38df2139 release: 4.6.97 .template-lint owns its own margin-top
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:06:13 +02:00
xRangerDE
38aadb6fb9 cleanup: .template-lint owns its own margin-top — unifies two divergent inline values
Both template-lint usages (clip-cutter modal at index.html:105 and Settings filename-template card at index.html:657) had different inline margin-top values — 4px vs 6px. The visual difference is essentially imperceptible but it's a pointless divergence: the same class, same context (a lint badge directly below a template input), spaced differently for no design reason.

Hoisted margin-top:6px into the .template-lint base class. Both usages now drop their inline override and pick up the same rhythm. Clip-cutter's lint shifts 2px down — visually identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:06:03 +02:00
xRangerDE
e728212981 release: 4.6.96 .settings-card.centered + .info-text + .stats-summary-grid
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:01:19 +02:00
xRangerDE
afbd09f507 cleanup: .settings-card.centered + .info-text + .stats-summary-grid
Three more inline-styled blocks moved into CSS classes:

- The Clips Info card had max-width:600px; margin:20px auto inline on the settings-card itself, plus color/line-height/white-space:pre-line inline on the info paragraph. Now .settings-card.centered (modifier for narrow-centred cards) and .info-text (multi-line description text with preserved authored line breaks).

- The Stats Summary KPI grid carried a 3-property grid declaration inline (display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px). The layout is unique to this one container, but lifting it to .stats-summary-grid makes the markup self-documenting and matches the rest of the .stats-* class family.

Net: 3 inline style attributes gone, 3 new CSS rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:01:08 +02:00
xRangerDE
94a542b09a release: 4.6.95 .form-stack size modifiers + .input-narrow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:56:40 +02:00
xRangerDE
3362138d1a cleanup: .form-stack size modifiers + .input-narrow — 5 inline width styles gone
Two related size-modifier patterns extracted:

1. .form-stack.size-sm (min-width:120px) and .form-stack.size-md (min-width:160px) — the Auto-Cleanup 3-up row carried three near-identical inline min-width declarations (120 / 160 / 160). They control where the row breaks to a new line on narrow widths, so the breakpoint metadata is logically a layout-modifier-class concern, not inline-style markup.

2. .input-narrow (width:90px) — the two Auto-VOD inputs (Poll-Intervall, Max. Alter) had their width inline because the values are 2-3 digits and a full-width input would look odd. Encoded once as a class, applied twice.

Net: 5 inline style attributes gone, 3 new CSS rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:56:31 +02:00
xRangerDE
562e92494b release: 4.6.94 .card-intro a colour + archive-search-summary uses .form-sublabel
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:51:22 +02:00
xRangerDE
2c0c7f6d00 cleanup: .card-intro a default colour + archive-search-summary uses .form-sublabel
The Twitch-API card's help-text paragraph contains one inline link (apiHelpLink → dev.twitch.tv/console/apps). It carried color/text-decoration/cursor inline — but text-decoration:underline and cursor:pointer are the browser defaults for <a href=...>, so only the accent colour was actually doing work. Hoisted that to a single .card-intro a descendant rule — any future inline link inside a card intro paragraph picks up the accent colour without ceremony.

Same edit converts the archiveSearchSummary div from inline (font-size:12px; color:var(--text-secondary)) to the existing .form-sublabel class — exact match.

Two more inline style attributes gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:51:13 +02:00
xRangerDE
d2a0f35265 release: 4.6.93 merge tab uses .merge-empty-state + cutter placeholder descendant rules
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:47:22 +02:00
xRangerDE
5931892320 cleanup: merge tab uses pre-existing .merge-empty-state class + clip-cutter placeholder gets descendant rules
The merge tab's empty state at index.html:400 was written as <div class="empty-state" style="padding: 40px 20px"> with two more inline-styled descendants (SVG opacity:0.3 and p margin-top:10px). A .merge-empty-state class with exactly those three properties has been sitting in styles.css since the original empty-state pass — the HTML just never picked it up. Switched to <div class="empty-state merge-empty-state"> + bare children. 3 inline styles gone.

Same pattern in the clip-cutter video preview placeholder at index.html:332-333 (svg opacity:0.3, p margin-top:10px). Added two descendant rules to the existing .video-preview .placeholder block so the styling lives in CSS. 2 more inline styles gone.

Net 5 inline style attributes gone, no visual change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:47:13 +02:00
xRangerDE
c6394fd411 release: 4.6.92 a11y — 14 more labels linked to inputs via for=
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:43:12 +02:00
xRangerDE
6946c34395 a11y: 14 more labels linked to their inputs via for=
Continuing the label-for pass from 4.6.79 + 4.6.83. The Settings tab, Twitch-API card, Discord card, Clips template guide, and the main cutter time-input row all had label elements sitting right next to their inputs/selects with no programmatic for=/id pairing — screen readers couldn't announce the label on focus, and clicking the label text didn't focus the control.

Fixed labels (label-id → target-input-id):
- themeLabel → themeSelect
- clientIdLabel → clientId
- clientSecretLabel → clientSecret
- storageLabel → downloadPath
- modeLabel → downloadMode
- partMinutesLabel → partMinutes
- parallelDownloadsLabel → parallelDownloads
- streamlinkQualityLabel → streamlinkQuality
- performanceModeLabel → performanceMode
- metadataCacheMinutesLabel → metadataCacheMinutes
- discordWebhookUrlLabel → discordWebhookUrl
- templateGuideTemplateLabel → templateGuideInput
- cutterStartLabel → startTime
- cutterEndLabel → endTime

Together with the prior passes (clip-cutter modal labels in 4.6.79, filename-template labels in 4.6.83), every plain label-above-input pair in static HTML now uses for=. Remaining unattached labels (languageLabel, vodHideDownloadedLabel) are intentional — the first targets a custom button-pair language picker, the second wraps its own checkbox via .inline-toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:43:01 +02:00
xRangerDE
5c0378582e release: 4.6.91 aria-label on the 3 icon-only main-shell buttons
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:37:35 +02:00
xRangerDE
3ec88a7800 a11y: aria-label on the 3 icon-only buttons in the main shell
Three icon-only buttons in the always-visible UI had no programmatic accessible name:
- the "+" add-streamer button (no id, no title, no aria-label — entire button was unnamed)
- the bulk-remove X button next to the Streamer section title (had a localized title, but title is not a reliable accessible-name source — many screen readers don't expose it)
- the VOD-filter clear X button above the VOD grid (same — title-only)

For all three the visible text is just a glyph ("+", "x"), so the accessible name has to come from somewhere else. Added:
- new locale key static.streamerAddAriaLabel ("Streamer hinzufuegen" / "Add streamer") and an id on the "+" button so renderer-texts can localize it
- new setAriaLabel(id, value) helper in renderer-texts mirroring the existing setTitle/setPlaceholder pattern
- aria-label calls for all three buttons, in addition to the existing title (so the tooltip stays for sighted users)

The two existing X buttons reuse their existing title strings as aria-label — no new translation work, just exposing the already-present text via the right ARIA attribute.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:37:26 +02:00
xRangerDE
2df8d72a61 release: 4.6.90 .form-row.aligned + .form-sublabel reuse
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:31:49 +02:00
xRangerDE
b3d77040de cleanup: .form-row.aligned modifier + .form-sublabel reuse — 5 inline styles gone
Three Settings cards (Debug-Log, Auto-VOD-Download, Runtime Metrics) carried the identical inline style attribute on their action-row form-row:

    style="margin-bottom: 10px; align-items: center;"

Extracted into a single .form-row.aligned modifier — three duplicated inline declarations collapsed to one shared rule that pairs naturally with the existing .form-row.section-header pattern.

Two more inline styles converted to the existing .form-sublabel class:
- autoVodStatusLine: style="font-size:12px; color: var(--text-secondary);" — exact match for .form-sublabel
- storageSummary: same as above plus margin-bottom:8px (kept inline since margin is a one-off positioning concern, not part of the sublabel concept)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:31:40 +02:00
xRangerDE
61c71ebc7f release: 4.6.89 settings-card h4/hr defaults + .toggle-row reuse
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:27:15 +02:00
xRangerDE
ed20c44749 cleanup: settings-card h4 + hr defaults — and Auto-Cleanup label reuses .toggle-row
The Auto-Cleanup subsection inside the Storage card carries three inline-styled patterns that are good candidates to live in the cascade rather than the markup:

- <hr> with border:none; border-top:1px solid var(--border-soft); margin:16px 0
- <h4> with margin:0 0 8px 0; font-size:14px
- a checkbox <label> wrapping the "Auto-Cleanup aktivieren" toggle with the same display:flex; align-items:center; gap:8px pattern that the existing .toggle-row class already encodes

Hoisted the hr + h4 styling to default .settings-card descendant rules — any future subsection divider/heading inside a settings card now follows the same rhythm without ceremony — and the label reuses .toggle-row (margin-bottom:8px stays inline because the next sibling is a form-row, not another toggle-row, so the .toggle-row + .toggle-row sibling rule doesn't apply).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:27:05 +02:00
xRangerDE
c845be64cf release: 4.6.88 auto-updater lifecycle events go through appendDebugLog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:21:42 +02:00
xRangerDE
a7f16d8cf8 observability: auto-updater lifecycle events go through appendDebugLog, not console.log
Four autoUpdater event handlers (checking-for-update, update-available, update-not-available, update-downloaded) were logging via raw console.log while the sibling 'error' handler already used appendDebugLog. Two consequences:

1. In a packaged build the user has no visible record of the update lifecycle — console.log streams to stderr which is invisible without DevTools. appendDebugLog writes to the timestamped debug log file that the user can inspect via the Live Debug-Log card in Settings.

2. Inconsistent — the existing 'auto-updater-error' tag in line 6479 was the only update-related event reaching the debug log. New tags ('auto-updater-checking', 'auto-updater-update-available', 'auto-updater-update-not-available', 'auto-updater-update-downloaded') give the full lifecycle a coherent grep-friendly prefix in the log.

The version info that was being printed inline ("Update available: 4.7.0") now lives in the structured details payload instead of a free-form message — easier to parse mechanically and matches the rest of the codebase's debug-log conventions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:21:35 +02:00
xRangerDE
d19e7ebc34 release: 4.6.87 .section-title flex layout + .section-title-label
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:17:23 +02:00
xRangerDE
e61940c108 cleanup: extract .section-title flex layout + .section-title-label
The sidebar's streamer section-title carried two inline flex containers:
- outer .section-title with display:flex; align-items:center; gap:6px; justify-content:space-between (so the bulk-remove X button can pin right while the title group stays left)
- inner span with display:flex; align-items:baseline; gap:8px (so the title text and the streamer counter share the same text-baseline rather than centring against each other)

.section-title is only used in this one spot, so the flex layout becomes part of the class definition (no risk of bleeding into other usages). The inner-span pattern moves to a dedicated .section-title-label class. Two inline style attributes gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:17:16 +02:00
xRangerDE
77aa04c894 release: 4.6.86 stats header consolidation + .card-intro.flush
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:12:19 +02:00
xRangerDE
35dc3201d8 cleanup: stats card header reuses .form-row.section-header + .card-intro.flush
The Statistics tab's "Archiv-Statistik" card had its own bespoke inline-styled flex header (display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px) plus an inline margin:0 on the h3 and an inline-styled intro paragraph with a different margin rhythm than the other 7 card intros (margin-top:8px; margin-bottom:0).

Two small additions reuse the existing classes:
- flex-wrap:wrap added to .form-row.section-header so the System-Check / Storage / now-Stats headers all wrap gracefully on narrow widths instead of overflowing. Strict improvement everywhere it's already used.
- .card-intro.flush modifier (margin-top:8px; margin-bottom:0) for intros that sit flush against the next block rather than spaced from it.

Three more inline style attributes gone, and the stats header now shares the same width-collapse behavior as the other section-header rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:12:10 +02:00
xRangerDE
67da6d4c58 release: 4.6.85 form-row.section-header + inline-toggle reuse
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:08:02 +02:00
xRangerDE
ccfff174ae cleanup: .form-row.section-header + reuse .inline-toggle — kill 6 inline styles
Two recurring inline-style patterns in Settings:

1. The "card title + right-aligned refresh button" header — used by the System-Check card and the Storage card. Both carried the identical 3-property inline style on the form-row plus an inline margin:0 on the h3 inside. Now expressed as .form-row.section-header (with a descendant h3 margin reset) — two cards, four inline attrs gone.

2. The "checkbox + Auto-Refresh label" pattern next to the debug-log and runtime-metrics action buttons — both were inline-styled with display:flex; align-items:center; gap:6px; font-size:13px; color:var(--text-secondary). The existing .inline-toggle class (already used by the VOD filter row's Hide-downloaded toggle) is the exact same pattern at 12px instead of 13px — close enough that unifying onto the shared class is the right move. 1px down beats keeping a third near-duplicate definition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:07:51 +02:00
xRangerDE
3e591fac3d release: 4.6.84 .card-intro CSS class — 7 cards deduped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:03:17 +02:00
xRangerDE
534f22b632 cleanup: extract .card-intro CSS class — kill 7 duplicated inline styles
Seven settings/feature cards (Archive, API help, Storage, Cleanup, Discord, Auto-VOD, Backup) each carry an intro paragraph with the exact same inline style attribute:

    style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;"

That's the same 4-property declaration duplicated 7 times. Extracted into a single .card-intro class — HTML reads as semantic intent ("this is a card-intro paragraph") instead of repeated style soup, and any future tweak to intro-paragraph styling now lives in one place.

(statsIntro is similar but uses margin-top:8px; margin-bottom:0; — different rhythm because it sits above the stats grid, left as-is.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:03:09 +02:00
xRangerDE
af0a27c01b release: 4.6.83 .filename-template-grid CSS class + label for= associations
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:58:51 +02:00
xRangerDE
0e313e8857 cleanup+a11y: .filename-template-grid CSS class + for= on the 3 template labels
The filename-templates 3-pair grid in Settings carried four inline style attributes:
- the wrapper div's display:grid; gap:8px; margin-top:8px
- three labels with the same font-size:13px; color:var(--text-secondary), with the 2nd and 3rd also having margin-top:4px

Extracted into a single .filename-template-grid class block (with descendant label styling + a :not(:first-child) margin-top rule). HTML drops from a noisy block of inline styles to a clean labelled grid.

Same edit added for= associations on the three labels (vodTemplateLabel → vodFilenameTemplate, partsTemplateLabel → partsFilenameTemplate, defaultClipTemplateLabel → defaultClipFilenameTemplate) — they were sitting right next to their inputs with no programmatic association. Continues the label-for a11y work from 4.6.79; clicking a label now focuses its input and screen readers announce the label when the input is focused.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:58:39 +02:00
xRangerDE
3e37f9e87e release: 4.6.82 keyboard focus rings on queue + top-bar + add-streamer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:53:10 +02:00
xRangerDE
f29cfd6ed4 a11y: focus-visible rings on queue-action + top-bar + add-streamer buttons
Continuing the keyboard-focus pass from 4.6.81. Five more interactive controls in the always-visible UI surface had no visible focus indicator:

- .btn (the shared base for queue-action buttons — Start/Merge/Wiederholen/Leeren)
- .btn-start (green): inner-white + green-outer ring so it stays visible against the success-green background, and a red-outer variant when .downloading is active (button switches to error red)
- .btn-icon (top-bar Refresh)
- .header-search button (the purple "+" add-streamer button next to the streamer input — uses inner-white + accent-outer like .btn-pill.primary so the ring stays visible against the purple bg)
- .header-search input (was bare, now matches the .btn-icon focus convention — purple border + soft halo)

Tab order on the main shell is now fully keyboard-traversable with visible state at every step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:53:03 +02:00
xRangerDE
a8ec8658b3 release: 4.6.81 focus-visible rings on btn-primary/secondary/pill/close + btn-secondary hover
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:48:43 +02:00
xRangerDE
66486dba0c a11y: focus-visible rings on primary action buttons + missing hover on .btn-secondary
Keyboard-only users had no visible focus indicator on five widely-used button classes:
- .btn-primary (main action button — used in clip download, modal confirms)
- .btn-secondary (cancel buttons, neutral actions; also missing hover transitions)
- .btn-pill (toolbar/bulk-bar action buttons; primary + success + danger variants)
- .btn-close (X-close button used in filter clears, inline removals)
- .queue-detail-btn (queue item detail chip buttons + archive companion buttons)

Tabbing through these buttons left no indication of which one would activate on Enter/Space — WCAG 2.4.7 (Focus Visible) violation.

Added :focus-visible rings using the established box-shadow convention (purple-accent for default, white-inner + accent-outer for purple/green pill variants so the ring stays visible against the button's own purple background, red-toned for .btn-close / .btn-pill.danger). Also added :hover + transition to .btn-secondary which previously had neither — clicking felt unresponsive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:48:36 +02:00
xRangerDE
a516c78846 release: 4.6.80 .vod-filter-row CSS extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:43:54 +02:00
xRangerDE
bbdcf8f71c cleanup: extract .vod-filter-row CSS class — kill 3 inline style attrs
The filter row above the VOD grid carried three inline style attributes:
- the row's own flex layout (display:flex; align-items:center; gap:8px; margin-bottom:12px; flex-wrap:wrap)
- vodSortLabel had margin-left:8px (extra spacing past the row's gap to visually group "Sort:" with the select)
- vodFilterCount had min-width:80px (prevents layout shift as count text changes during typing)

All three are now CSS class definitions (.vod-filter-row, .vod-sort-label, .vod-filter-count). HTML reads cleaner and the styling lives alongside the related .vod-bulk-bar block in styles.css.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:43:47 +02:00
xRangerDE
4a18a13deb release: 4.6.79 label-for a11y for clip-cutter + auto-vod
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:41:21 +02:00
xRangerDE
5641924c7e a11y: clip-cutter + auto-vod settings labels linked to inputs via for=
Five <label> elements in the clip-cutter modal and two <span class="form-sublabel"> elements in the Auto-VOD settings card had no for=/id pairing with their associated form controls — screen readers couldn't announce the label when the input was focused, and clicking the label didn't focus the input.

Fix: added for= to 5 clip-modal labels (Start/StartTime/End/EndTime/Part) and converted the two Auto-VOD sublabels from span to label for= so screen readers correctly associate them with autoVodPollMinutes and autoVodMaxAgeHours.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:41:14 +02:00
xRangerDE
8dc374d50e release: 4.6.78 dedupe setPageTitle in renderer-settings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:33:47 +02:00
xRangerDE
e705beabf3 cleanup: dedupe setPageTitle fallback in renderer-settings
4.6.77 inlined the setPageTitle global-window resolution + fallback
twice — once in each branch of the "vods+streamer vs other tab"
conditional. Reading two near-identical 4-line if/else stanzas back
to back was harder than necessary.

Collapsed the branch to a single ternary that picks the title text
first, then a single setPageTitle resolve + apply block at the end.
Same behavior, one resolution of the optional global.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:33:47 +02:00
xRangerDE
7c99193e25 release: 4.6.77 window title syncs with active tab / streamer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:29:05 +02:00
xRangerDE
bdf6bac602 feat: window title syncs with active tab / streamer
document.title was stamped once during app boot with the static
"Twitch VOD Manager vX.Y.Z" string. After that, the H1 page-title
in the header updated as the user navigated tabs and selected
streamers, but the OS-level window title — the string shown in the
taskbar, Alt+Tab switcher, and OS notifications — never changed.

Multitasking suffered: a user with three Electron windows pinned
to taskbar all read identical "Twitch VOD Manager v4.6.x", with
no clue which window had what tab or streamer loaded.

Added a setPageTitle(text) helper in renderer.ts that:
- Updates the H1 #pageTitle textContent (the visible header)
- Updates document.title with `${text} - ${appName} v${version}`
  for non-default text, or just `${appName} v${version}` for the
  default app-name fallback
- Exposed on window so the renderer-streamers.ts and
  renderer-settings.ts modules can reach it without crossing the
  module-vs-bundle boundary

Three call sites updated to use the helper:
- showTab → uses for tab-derived titles
- selectStreamer → uses for "xrohat" style streamer titles
- the renderer-settings language-switch refresh path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:29:04 +02:00
xRangerDE
4489319d70 release: 4.6.76 VOD checkbox aria-label + CSS extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:24:51 +02:00
xRangerDE
69b83c9d22 cleanup+a11y: VOD select checkbox — proper aria-label + CSS-class styling
The bulk-select checkbox on each VOD card was carrying a ~140 char
inline-style block (absolute positioning, dimensions, accent-color,
z-index, cursor) — duplicated across every rendered VOD — plus a
truly bizarre title-attribute fallback that hacked the
bulkSelectedCount locale string by stripping placeholder digits:

  title=`${UI_TEXT.vods.bulkSelectedCount
                .replace("{count}", "0")
                .replace(/[0-9]/g, "")
                .trim() || "Select"}`

That worked by accident — the placeholder happens to be "{count}
ausgewahlt" / "{count} selected" so stripping digits gave a usable
fragment — but it was fragile and not really an accessible label.

Three fixes:
- Extracted the inline styles to a .vod-select-checkbox CSS rule.
  The custom checkbox styling from 4.6.26 means accent-color was
  redundant anyway, so dropping it is a no-op visually.
- Added a proper locale key vods.selectAriaLabel ("Select VOD for
  bulk action" / "VOD fuer Bulk-Aktion auswaehlen") for the
  aria-label attribute.
- Dropped the title-attribute hack entirely. aria-label now provides
  the AT-readable name; sighted users get the visual checkbox
  which is self-explanatory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:24:50 +02:00
xRangerDE
e56bac2c2b release: 4.6.75 Ctrl+F focuses archive search on Archiv tab
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:18:37 +02:00
xRangerDE
d9593091a5 feat: Ctrl+F also focuses archive search when on Archiv tab
Ctrl+F was wired to focus the VOD filter input — but only when the
VODs tab was active. On the Archive tab (added in 4.6.15) Ctrl+F
did nothing useful: the browser default find bar was suppressed
(Electron renderer doesn't have one anyway) and the app handler
didn't have a branch for the archive context.

Now Ctrl+F also targets the archiveSearchQuery input when the
Archive tab is the active tab. Other tabs (Clips / Cutter / Merge /
Stats / Settings) let the shortcut fall through to no-op since
they don't have a primary search/filter input.

Same input-focus convention as the VODs tab: focus + select-all so
the user can immediately type to replace or append.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:18:37 +02:00
xRangerDE
00e19ccf67 release: 4.6.74 fix TAB_IDS — stats + archive tabs now reachable
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:15:30 +02:00
xRangerDE
dba6e872a9 fix: TAB_IDS missing stats + archive — keyboard shortcuts + persistence broken
When the Statistik (4.6.14) and Archiv (4.6.15) tabs were added to
the sidebar nav, the TAB_IDS const never got extended past its
original five entries:

  const TAB_IDS = ["vods", "clips", "cutter", "merge", "settings"]

Two consequences:

1) Ctrl+1..5 keyboard shortcut was hard-capped at five tabs (the
   guard `tabIndex < TAB_IDS.length` filtered Ctrl+6 and Ctrl+7 out).
   Even though there were 7 visible tabs.

2) persistActiveTab(tab) called isKnownTab(tab) before localStorage
   write. For 'stats' or 'archive' that returned false, so the tab
   was silently NOT persisted. Open the app on the Archiv tab,
   close it, reopen — it'd boot on VODs because the persisted value
   was the previous non-stats/archive selection.

Extended TAB_IDS to all seven nav-items + bumped the keyboard
shortcut range from 1-5 to 1-7. Ctrl+5 now maps to Statistik,
Ctrl+6 to Archiv, Ctrl+7 to Einstellungen. Persistence works for
the full set.

Added a comment to TAB_IDS pointing out the failure modes when
this is out of sync with the HTML nav, so the next nav addition
doesn't repeat the bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:15:30 +02:00
xRangerDE
62400e4aa0 release: 4.6.73 remove 3 high-volume console.log calls
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:09:29 +02:00
xRangerDE
7e7be1d103 perf: remove 3 high-volume console.log calls in download / update paths
Three console.log calls in main.ts were flooding stdout during normal
operation:

1) `console.log("Starting download:", cmd, args)` — redundant with
   the appendDebugLog("download-part-start", ...) one line below.
   Duplicate logging; pure noise.

2) `console.log("Streamlink:", line)` — fired for every line of
   streamlink stdout, which is 10-100 lines/sec during an active
   download. Hundreds of thousands of lines per multi-hour recording.
   Progress + state parsing already happens on the same line; the
   raw output was never consumed.

3) `console.log("Download progress: X%")` in the autoUpdater
   handler — fires ~10x/sec during an in-flight update download.
   The renderer banner is the user-visible feedback; this was
   developer-only and never necessary in prod.

Removed all three. The remaining four console.log calls (login
flow, update-available, update-downloaded, no-updates-available)
are once-per-event and fine to keep.

Practical benefit: stdout becomes useful for actual diagnostics
again. Performance gain is marginal in absolute terms but the
buffered noise on a long-running session was real.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:09:29 +02:00
xRangerDE
a46984d8ab release: 4.6.72 stats size-bucket histogram CSS extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:04:08 +02:00
xRangerDE
885cbaa894 cleanup: stats size-bucket histogram — extract inline styles
Final piece of the renderer-stats.ts extraction. The recording-size
distribution histogram (6 buckets: <100MB ... >10GB) was rendering
each bucket-row as a 5-inline-style template — same shape as the
top-streamers list (margin row, flex meta header, two spans, bar
track, bar fill).

Extracted to a .stats-bucket-* family in styles.css:
- .stats-bucket-row + .stats-bucket-row:last-child margin trim
- .stats-bucket-meta + .stats-bucket-meta-sub for the flex label/
  count header
- .stats-bucket-bar-track + .stats-bucket-bar-fill for the
  horizontal bar (with width-transition so the bar fills
  animate on data refresh)

That completes the Statistik tab pass — 26 inline styles -> 22
CSS class assignments + 4 truly-dynamic width/height percent
overrides for the bar fills. Tabular numerics, hover states, and
data refresh animations all flow from the central stylesheet now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:04:07 +02:00
xRangerDE
2cdbbe31ef release: 4.6.71 stats activity chart CSS extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:59:34 +02:00
xRangerDE
c9a5223eb6 cleanup: stats activity chart — extract 30-day bar inline styles
Continuing the renderer-stats.ts inline-style extraction. The
"Aktivitaet (letzte 30 Tage)" bar chart built each day-column as
a 5-inline-style template:

  <div style="flex: 1; display:flex; flex-direction:column;
              align-items:center; gap:4px; min-width:0;">
    <div style="width: 100%; height: 90px; display:flex;
                align-items: flex-end;">
      <div style="width:100%; height: 70%;
                  background: var(--accent, #9146ff);
                  border-radius: 2px 2px 0 0;" title="...">
    <div style="font-size: 9px; color: var(--text-secondary);
                white-space: nowrap;">

30 columns rendered per refresh meant ~7.5KB of duplicated inline
style attribute strings in the DOM after every refresh.

Extracted to .stats-day-col + .stats-day-bar-track + .stats-day-bar-
fill + .stats-day-label, plus .stats-activity-row + .stats-activity-
summary for the outer wrappers. Only the per-day height percent
stays inline (it's truly dynamic, per-day data).

Polish riders:
- Bar fill picks up height: 0.3s ease-out transition so the bars
  animate up on data refresh instead of snapping
- Hover state shifts the bar from accent to accent-hover so the
  hovered day reads as the focus
- Day-label spans get tabular-nums so the "05-12" type strings
  align column-to-column

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:59:33 +02:00
xRangerDE
162b2845aa release: 4.6.70 stats top-streamers bar list CSS extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:54:56 +02:00
xRangerDE
abc983c035 cleanup: stats top-streamers bar list — extract inline styles
Second pass on the Statistik tab. The top-10 streamers-by-size
list rendered each row as a 6-inline-style template (margin,
two flex containers, two span colour overrides, two bar wrappers,
two bar fills with hard-coded gradient).

Extracted to a .stats-top-* family in styles.css:
- .stats-top-row — outer row spacing
- .stats-top-meta + .stats-top-meta-sub for the label/byte-size
  flex header
- .stats-top-share for the muted (X.Y%) suffix
- .stats-top-bar-track + .stats-top-bar-fill for the gradient
  progress bar (now with a width-transition for the streamer-by-
  streamer animation when the data refreshes)
- .stats-top-bar-labels for the overlaid LIVE/VOD breakdown that
  gets pointer-events: none so the bar isn't accidentally hover-
  blocked

Also picked up the "no top streamers" empty-state message and
swapped its inline-style div for the existing .form-note utility
class introduced in 4.6.42.

Top streamers row hover state intentionally NOT added — these are
read-only summary rows, not interactive ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:54:56 +02:00
xRangerDE
7ffd52a901 release: 4.6.69 stats KPI cards CSS extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:50:24 +02:00
xRangerDE
874f64c1ba cleanup: stats KPI cards — extract 4 inline styles into reusable classes
The six-tile overview grid at the top of the Statistik tab built
each KPI card as a four-property inline-styled div:

  <div style="background: var(--bg-elevated); border: 1px solid
              var(--border-soft); border-radius: 6px; padding: 12px;">
    <div style="font-size: 11px; color: var(--text-secondary);
                text-transform: uppercase; letter-spacing: 0.5px;">
    <div style="font-size: 22px; font-weight: 600; margin-top: 4px;">
    <div style="font-size: 12px; color: var(--text-secondary);
                margin-top: 4px;">

Each card repeated the same ~250 chars of inline styling. Card hover
state, number alignment, future polish all required editing the
renderer.

Extracted to .stats-kpi-card + .stats-kpi-label + .stats-kpi-value
+ .stats-kpi-sub. Added two enhancements while at it:
- subtle hover state (purple-tint border + 1px lift) so the cards
  feel interactive in line with the rest of the apps language
- font-variant-numeric: tabular-nums on values + subs so the
  numbers align properly across the six-tile grid

Also stats-no-root for the "Download folder not found" fallback
that grid-column-spans across all 6 columns.

The remaining 22 inline styles in renderer-stats (top-streamers
bar list, activity calendar, size buckets) come in subsequent
iterations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:50:23 +02:00
xRangerDE
3905b73751 release: 4.6.68 archive search results CSS extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:45:39 +02:00
xRangerDE
a2b7a02db7 cleanup: archive search results — extract 10 inline styles into row classes
renderArchiveSearchResults was building each result row as an HTML
template literal carrying ~10 inline-style props per row (flex
layouts, padding, border-bottom, font-sizes, secondary text colour,
ellipsis truncation, gap...). For a 200-hit search that meant
~2KB of duplicated inline style noise in the DOM and made any
visual tweak require editing the renderer.

Extracted to a .archive-result-* family in styles.css:
- .archive-result-row + hover-tint (table-row scannability — was
  missing before, every row read flat)
- .archive-result-body / -meta / -streamer / -date / -filename /
  -size / -actions for the column layout
- .archive-type-badge with .live + .vod modifiers for the LIVE/VOD
  pill (was two separate inline-styled spans with hard-coded
  rgba colours)
- .archive-no-matches for the empty-state line

Dates + sizes in the row pick up font-variant-numeric: tabular-nums
so columns of numbers align even when filenames are different
widths. Last-child gets its bottom border dropped so the list
doesnt end on a dangling line — same treatment as the storage
stats table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:45:38 +02:00
xRangerDE
58f8164db4 release: 4.6.67 toast live region for screen readers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:40:33 +02:00
xRangerDE
5d5e58ae09 a11y: app toast notifications become a live region for screen readers
showAppToast spawns / reuses a single floating toast at the bottom-
right of the window for transient status (e.g. "1 new VOD auto-queued",
"Cannot start recording", etc). The toast had no a11y semantics —
screen readers never announced it, so the entire transient-feedback
channel was silent for AT users.

Promoted the toast container to a live region:
- role="status" for info toasts + aria-live="polite" so the reader
  waits for a natural break in current speech before announcing
- role="alert" for warn toasts + aria-live="assertive" so the reader
  interrupts whatever it was saying (matches the visual amber-left-
  border meaning — warn IS urgent)
- aria-atomic="true" so the reader announces the whole message at
  once instead of attempting to diff against the previous toast

Critical detail: aria-live attributes have to be in place BEFORE the
text changes for AT to register the change as a live-region update.
The current implementation now sets role / aria-live first and only
then writes the new textContent.

WCAG 4.1.3 — Status Messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:40:32 +02:00
xRangerDE
7be9453762 release: 4.6.66 update-banner progress bar a11y
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:36:43 +02:00
xRangerDE
c393457492 a11y: update-banner progress bar role=progressbar + aria-valuenow
Third progress bar in this a11y pass — the download-progress bar
shown in the update banner during an auto-update download. Same
pattern as 4.6.64 (queue) + 4.6.65 (cut/merge): bare div with
JS-driven width, no semantic role.

Promoted the .update-banner-progress-track to role="progressbar"
with aria-valuemin / max / now + a localized aria-label
(updateProgressAria: "Update download progress" / "Update-Download-
Fortschritt").

Three call sites in renderer-updates.ts that drive bar.style.width
now also stamp aria-valuenow on the gauge:
- onUpdateProgress event handler (per-tick percent)
- setDownloadPendingUi (initial 30% indeterminate placeholder)
- setDownloadReadyUi (100% on finish)

renderer-texts.applyText sets the localized aria-label at boot +
on language switch.

That's all three application-level progress bars now AT-friendly.
The same pattern would extend to any future progress UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:36:42 +02:00
xRangerDE
01acbcc47f release: 4.6.65 cut + merge progress bars a11y role=progressbar
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:31:42 +02:00
xRangerDE
fa440951d2 a11y: cut + merge progress bars role=progressbar + aria-valuenow
Following 4.6.64 for the queue progress bars, the cut + merge
progress containers in their respective tabs had the same gap:
a plain <div class="progress-bar"> wrapping a <div class="progress-
bar-fill"> with no semantic role. JS poked the bar's style.width
on every percent update; AT had no way to read out the running
value.

Promoted both .progress-bar wrappers to role="progressbar" with
aria-valuemin / max / now, plus aria-label sourced from new
locale strings (cutProgressAria / mergeProgressAria) so EN/DE
both work.

The progress event handlers in renderer.ts now also stamp
aria-valuenow on each tick, so AT live regions pick up the
percentage as the cut / merge advances. setAttribute is cheap
relative to the FFmpeg progress event rate (~1/s), no perf
concern.

renderer-texts.applyText sets the localized aria-label on both
gauges at boot + language switch — text contents already get
re-applied through the same path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:31:41 +02:00
xRangerDE
6213134a27 release: 4.6.64 queue progress bar role=progressbar + aria-valuenow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:27:14 +02:00
xRangerDE
58e52399c5 a11y: queue progress bar — role=progressbar + aria-valuenow
The download progress bar inside each queue item was a plain
<div class="queue-progress-bar" style="width: 73%;"> with no
semantic indication that it represented progress. Screen readers
just announced the surrounding text ("Downloading...") with no
running value.

Added role="progressbar" + aria-valuemin=0 + aria-valuemax=100 +
aria-valuenow on the wrapping .queue-progress-wrap (since the bar
itself is just the visual fill — the wrap is the semantic gauge
region). aria-label is the status label so AT announces "VOD title
75 percent" instead of an unlabeled gauge.

updateQueueItemProgress also re-stamps aria-valuenow as the
percentage advances, so AT live regions can pick up the running
update without needing a full re-render.

For indeterminate progress (pre-rolling, before the first byte
event arrives) aria-valuenow stays at 0 — the screen reader still
gets a coherent reading even if visually the bar is in
indeterminate-pulse mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:27:13 +02:00
xRangerDE
c6ae0cadbd release: 4.6.63 .select-compact for 4 inline-styled selects
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:22:16 +02:00
xRangerDE
274d3874f5 cleanup: extract .select-compact class — 4 selects outside .form-group
The vodSortSelect + the 3 archive-search selects all sat OUTSIDE
the .form-group container, which meant the existing
.form-group select rule did not apply to them. Each carried the
same ~110 chars of inline style hard-coding bg / border / radius /
padding / color.

Extracted to a shared .select-compact class (6px radius, 7px
padding, var(--bg-card) base, var(--border-soft) border) and
swapped all four call sites. Visual consistency between the
filter-row select in the VODs tab and the multi-select control
strip in the Archive search — same heights, same border treatment.

Minor visual change for the 3 archive selects (4px -> 6px
border-radius, 6px -> 7px vertical padding) so the controls now
match the rest of the apps button + input family. Worth the 1px
delta for the consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:22:16 +02:00
xRangerDE
1c62cf4a92 release: 4.6.62 open-file blocks executable extensions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:16:46 +02:00
xRangerDE
32e0b1ab7d security: open-file IPC blocks executable extensions
Companion to 4.6.61. The open-file IPC handler (used by the
"Open file" buttons in the queue + archive) was previously a
plain shell.openPath call with only an existsSync check:

  if (typeof filePath !== "string" || !filePath) return false;
  if (!fs.existsSync(filePath)) return false;
  const result = await shell.openPath(filePath);

shell.openPath happily launches any path the OS knows how to
execute. An XSS landing through e.g. a smuggled queue item URL
that reached the renderer-side openFile global function could
pass `C:\\Windows\\System32\\calc.exe` and the IPC would launch
calc.

Added a deny-list of obvious shell-execution extensions (.exe,
.bat, .cmd, .com, .ps1, .vbs, .vbe, .js, .jse, .wsf, .wsh, .scr,
.msi, .msp, .lnk, .cpl, .reg, .hta, .jar, .application). Rejected
calls log to debug + return false to the renderer. Media + text +
image extensions remain unaffected — those open in their normal
default-app viewers, which is the intended use case.

show-in-folder + open-folder stay permissive on extension since
they only open File Explorer (no execution).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:16:46 +02:00
xRangerDE
73eaccb483 release: 4.6.61 scheme-validate open-external IPC
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:12:51 +02:00
xRangerDE
c6f423b5ac security: scheme-validate URLs handed to shell.openExternal
The open-external IPC was a pass-through:

  ipcMain.handle("open-external", async (_, url) =>
    await shell.openExternal(url));

shell.openExternal on Windows happily resolves any URL scheme the OS
knows how to launch — including file:// paths, ms-settings:, shell:,
javascript:, and assorted protocol handlers. The renderer is
contextIsolated + nodeIntegration: false so direct exploits are
blocked, but an XSS landing through (for example) a streamer name
that smuggled HTML into a renderer template would have a clean path
through this IPC to launch arbitrary local executables via the OS
shell.

Validation gate: reject anything that isn't an http:// or https://
URL. Trim before the test so a smuggled leading/trailing whitespace
attempt does not slip through. Rejected requests get a debug-log
entry (truncated to 200 chars so a megabyte payload doesnt nuke the
log) and return silently — the renderer caller already swallows
the promise without checking, so silent-drop matches existing
behaviour.

Defence-in-depth. No known active exploit; just removing an
unnecessary surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:12:51 +02:00
xRangerDE
7e60d0e920 release: 4.6.60 bound renderer storyboard cache to 100 entries
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:08:43 +02:00
xRangerDE
976ca40963 perf: bound the renderer-side VOD storyboard cache (FIFO 100)
vodStoryboardClientCache was a plain Map<vodId, VodStoryboard | null>
with no eviction. Every VOD ever hovered cached its first sprite
data URL — about 50-200 KB each. Browsing a long-running streamer's
2000-VOD archive could leave the renderer holding 100-400 MB of
hover-only sprite data permanently, with no signal to the user
that it was happening.

Wrapped writes in a rememberStoryboard helper that caps the cache
at 100 entries with FIFO eviction (Map iterator is insertion-ordered
so .keys().next().value is always the oldest). Cache hit / miss
semantics unchanged for the live set — only the dropped-off-the-
back entries get re-fetched if the user scrolls back to a VOD they
hovered hundreds of cards ago, and that re-fetch is fast because
the main-process side has its own metadata cache that survives the
renderer-side eviction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:08:42 +02:00
xRangerDE
96683afa14 release: 4.6.59 localize clip-cutter "Invalid time values" alert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:00:28 +02:00
xRangerDE
2b4b8ae636 i18n: localize "Invalid time values" alert in clip-cutter
confirmClipDialog (the handler behind the clip-cutter modals "Add
to queue" button) opens an alert with a hardcoded English message
when the parsed start / end / duration values come back as NaN —
which can happen if the user types non-numeric characters or
otherwise breaks the time-input pattern. German-locale users got
an English alert on a German UI.

Added clips.invalidTime to both locales ("Invalid time values" /
"Ungueltige Zeitangaben") and swapped the inline string for the
locale lookup. All the other alerts in that handler already go
through UI_TEXT.clips.* — this was the one outlier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:00:27 +02:00
xRangerDE
8ef2ce50e7 release: 4.6.58 merge-tab empty state DOM-built
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:56:13 +02:00
xRangerDE
5d5ffa675b cleanup: merge-tab empty state — DOM-built instead of innerHTML template
When mergeFiles is empty, the renderer dropped an inline-styled
innerHTML template into #mergeFileList:

  <div class="empty-state" style="padding: 40px 20px;">
    <svg style="opacity:0.3" ...><path ...></svg>
    <p style="margin-top:10px">${UI_TEXT.merge.empty}</p>
  </div>

Three issues:
- innerHTML interpolating a locale string (lint hook flags pattern
  even though locale strings are app-controlled)
- Inline styles for padding / opacity / margin
- The same SVG icon as the static HTML, duplicated

Built via createElement + createElementNS for the SVG namespace, so
the renderer never touches innerHTML for this branch. Styling moved
to a .merge-empty-state class that scopes the padding override
(needed because the merge file-list sits in a settings-card with
its own padding) without leaking into the global .empty-state used
by the VOD grid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:56:13 +02:00
xRangerDE
1b8624d88a release: 4.6.57 live-status poller — eviction on empty list
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:51:42 +02:00
xRangerDE
77e4c84c45 fix: live-status poller — eviction now runs even when watch list is empty
Subtle leak in runLiveStatusBatchPoll: the eviction pass (which
removes liveStatusByLogin entries for streamers no longer in
config.streamers) ran INSIDE the fetch branch — but the fetch
branch is skipped early when logins.length === 0.

Concretely: if a user had 3 streamers all marked live, then
removed all 3, the poll would early-return at length-check,
leaving stale liveStatusByLogin entries forever (until app
restart) — main-process memory + an inaccurate
get-live-status-snapshot IPC response.

Renderer wasn't visibly affected because renderStreamers only
looks up entries for streamers in the rendered list, but the
underlying state was wrong.

Restructured so the eviction pass always runs first based on the
current watch list, then the fetch + diff only runs when the list
is non-empty. Empty-list case still emits "removed -> offline"
changes to the renderer so its parallel map stays in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:51:42 +02:00
xRangerDE
4518f8867a release: 4.6.56 queue-item title + selector keyboard-accessible
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:46:41 +02:00
xRangerDE
3e37d780c3 a11y: queue-item title + selector keyboard-accessible
Two more click-only divs in the queue item template were leaving
keyboard users stuck:

- .queue-selector — the "X" number-badge to the left of pending
  queue items that toggles bulk-select. Previously a div with onclick.
  Now role="checkbox" + tabindex + aria-checked tracking the selection
  state + Enter/Space keydown handler.

- .queue-item .title — the truncated VOD title that, when clicked,
  toggles the expanded detail panel underneath the row. Previously
  a div with onclick. Now role="button" + tabindex +
  aria-expanded reflecting the panel state + aria-controls pointing
  at the details panel ID + Enter/Space keydown handler.

Both pick up 2px purple focus-visible rings to match the rest of
the a11y family.

aria-expanded on a button is the conventional pattern for
"disclosure widget" controls (collapsible/expandable content),
so screen readers will now announce the title as "VOD title,
button, collapsed" or "expanded" as the user navigates and toggles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:46:40 +02:00
xRangerDE
e95be22a02 release: 4.6.55 profile header avatar wrap + live card keyboard-accessible
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:41:22 +02:00
xRangerDE
96113dc267 a11y: streamer-profile header — avatar wrap + live card keyboard-activatable
Two click-only divs in the new profile header (4.6.17+) had no
keyboard equivalent:

- .streamer-profile-avatar-wrap (clicking the avatar opens the
  channel on twitch.tv) — the only way to trigger that action
  besides the "Open on Twitch" button in the action column, so
  keyboard users were missing a primary affordance
- .streamer-profile-live-card (clicking anywhere on the live
  preview card starts a live recording) — the embedded Record-now
  button inside the card already covered keyboard activation, so
  this one is more about completeness than necessity

Both got:
- role="button" + tabindex="0"
- aria-label = the existing tooltip locale string (so AT reads
  the same text shown to sighted users on hover)
- An inline onkeydown that re-fires the same onclick handler on
  Enter / Space. The live-card additionally checks
  event.target === event.currentTarget so a focused inner button
  pressing Enter doesn't double-fire the wrapper handler

CSS adds focus-visible rings:
- Purple ring on the avatar wrap (matching the existing avatar's
  purple border)
- Red ring + glow on the live card (matching the existing card
  border colour)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:41:21 +02:00
xRangerDE
5e369fef35 release: 4.6.54 VOD cards keyboard-activatable
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:37:15 +02:00
xRangerDE
76be8d3949 a11y: VOD cards keyboard-activatable — opens VOD on Twitch via Enter/Space
Following the chip + row + nav a11y passes, the VOD cards in the
main grid were the last big mouse-only surface in the sidebar +
main panel flow. Each card already delegated click via a single
vodGrid handler — but the card div itself was unfocusable, so a
keyboard user could only reach the +Queue / Trim VOD buttons
inside, never the card thumbnail click that opens the VOD page on
Twitch.

Added on each .vod-card:
- role="button" + tabindex="0"
- aria-label set to the VOD title so AT announces it correctly
  ("32h37m9s VOD: Cyborg Watchparty button") instead of reading
  the whole card content row by row

Added to the existing delegated vodGrid handler:
- A keydown branch that opens the VOD on Twitch when Enter / Space
  fires on a focused .vod-card and the event target is the card
  itself (not a child action button or checkbox — those have
  their own native button / checkbox semantics that handle Enter
  / Space correctly already)

CSS adds a 3px purple focus-visible ring + accent-coloured border
on the focused card, mirroring the hover state's purple glow.

Tab order through the VOD grid now goes: VOD card -> checkbox -> Trim
button -> +Queue button -> next VOD card. Predictable enough for
keyboard navigation through a 38-VOD streamer profile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:37:14 +02:00
xRangerDE
0b99014de3 release: 4.6.53 streamer-item row keyboard-accessible + aria-current
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:32:04 +02:00
xRangerDE
26b03da765 a11y: streamer-item row itself keyboard-accessible + aria-current
The previous a11y pass made all four chip-buttons inside a streamer
row (AUTO / VOD / REC / remove) keyboard-accessible, but the row
itself — the parent .streamer-item div whose click selects the
streamer — was still mouse-only. A keyboard user could focus the
chips but never the row, so they could never select a streamer
without a mouse.

Made the row a focusable role="button":
- role + tabindex on the .streamer-item div
- aria-label set to the streamer's name (so AT announces "xrohat
  button" rather than reading every chip child)
- aria-current="true" on the currently selected row (mirroring
  the visual .active state) so AT understands which row is the
  current selection
- A keydown handler on the row that fires selectStreamer on
  Enter / Space, but ONLY when the row itself (not a chip child)
  is the event target. The chips already preventDefault +
  stopPropagation on their own keydowns so they never reach this
  handler — and even if they did, the e.target check guards.

Focus-visible adds an inset 2px purple ring (inset to match the
row's left-border-marker styling for the active state). Tab order
through the sidebar is now: nav-items → streamer row → AUTO →
VOD → REC → remove-X → next streamer row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:32:03 +02:00
xRangerDE
78eeb8f3dc release: 4.6.52 sidebar nav-items keyboard-accessible + aria-current
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:27:53 +02:00
xRangerDE
5fda4e2103 a11y: sidebar nav-items keyboard-accessible + aria-current
All 7 sidebar nav-items (Twitch VODs / Clips / Cutter / Merge /
Statistik / Archiv / Einstellungen) were plain `<div class="nav-item">`
elements with only an onclick. Same a11y story as the previous two
iterations: no role, no tabindex, no semantic active-state marker,
no keyboard activation.

Added on each nav item:
- role="button" and tabindex="0" so they enter the tab order and
  read as activatable buttons to assistive tech
- aria-current="page" applied to the active item, removed from the
  others — both managed in showTab() since that's the single
  switch point for active-state transitions
- A delegated keydown handler on the .nav container (one listener,
  not seven) that fires showTab on Enter / Space for whatever
  nav-item descendant is currently focused. Bound once with a
  data-keynav-bound guard so init() re-running doesn't double-bind

CSS adds a 2px purple focus-visible ring matching the rest of the
keyboard-focus family added in 4.6.50 and 4.6.51.

WCAG 2.1 success criterion 2.1.1 (Keyboard) — every interactive
element activated by keyboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:27:53 +02:00
xRangerDE
a82a8f97f7 release: 4.6.51 AUTO/VOD/REC chip a11y — role + aria-pressed + keyboard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:23:30 +02:00
xRangerDE
1d4b6718b9 a11y: AUTO / VOD / REC streamer chips keyboard-accessible
The three streamer-row action chips (AUTO toggle, VOD toggle, REC
one-shot) were spans wired only with click listeners. Same a11y
gap as the .remove X chips in 4.6.50: no role, no tabindex, no
keyboard activation, no semantic state for toggles. Screen readers
read them as raw text "AUTO", "VOD", "REC" with no clue they were
interactive controls.

Factored a wireChipButton helper inside renderStreamers and ran all
three chips through it. The helper stamps:
- role="button" + tabindex="0"
- aria-label (locale-driven, picked up the existing
  autoRecordTitle / autoVodTitle / recordLiveTitle locale keys
  that were previously only used for the visual title-tooltip)
- aria-pressed="true"/"false" for the AUTO and VOD toggles so
  AT announces the on/off state
- A keydown handler that synthesises the same click handler on
  Enter / Space and stops propagation so the row's click handler
  (streamer-select) does not also fire

CSS adds three focus-visible rings (green for AUTO, blue for VOD,
red for REC) matching each chips active-state colour palette.
Keyboard navigators tabbing through a streamer item now see the
ring on the focused chip clearly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:23:29 +02:00
xRangerDE
6086cd51c1 release: 4.6.50 remove-X buttons keyboard-accessible + aria-labeled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:18:18 +02:00
xRangerDE
35769959f4 a11y: remove-X spans become keyboard-accessible with aria-label
The two `<span class="remove">x</span>` glyphs (one per queue
item, one per streamer-list item) had no semantic role, no
aria-label, no tabindex — entirely mouse-only and screen-readers
just announced them as the bare "x" character.

Made both fully keyboard-accessible:
- role="button" + tabindex="0" so they enter the tab order and
  read as buttons to AT
- aria-label="Remove" / "Entfernen" via new
  streamers.removeAria locale key (DE + EN)
- Keydown handler on Enter + Space synthesizes the same removal
  callback (mirroring native button behaviour for synthetic
  buttons — Enter on real buttons fires click, Space does too)
- Focus-visible state: 2px red glow ring + force opacity:1 on
  the streamer-list .remove (which is normally opacity:0 until
  the streamer-item is hovered) so keyboard navigators can see
  the focused X

Both call sites preserved e.stopPropagation in the keydown handler
so Enter on a focused X doesn't bubble up to the row's click
handler (which would trigger streamer-select).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:18:18 +02:00
xRangerDE
5f7ce36845 release: 4.6.49 queue detail labels localized + retry button styled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:13:43 +02:00
xRangerDE
fedf3a9945 i18n+polish: localize queue detail labels + style the retry button
Three queue-item detail labels were hardcoded German in the
renderer's HTML template: "Streamer:" / "Dauer:" / "Datum:". The
queue-details panel that expands when a user clicks a queue items
title would render these labels in German regardless of the
selected locale — same i18n gap as the empty states fixed in
4.6.37.

Added queue.detailStreamer / detailDuration / detailDate to both
locale tables ("Streamer:" / "Duration:" / "Date:" in EN,
"Streamer:" / "Dauer:" / "Datum:" in DE) and wrapped the labels
in a .queue-detail-label span so the colour distinction between
label and value (secondary vs primary text colour) is consistent.

The error-state retry button was a span with five inline-style
props and a unicode ↻ as its glyph — visually drab and not really
a button semantically (screen reader read it as text). Promoted
to a real <button class="queue-retry-btn"> with a proper hover
state (purple-tinted background + accent border + white text +
active-press scale-down). Subtler than .btn-pill but obviously
clickable.

Cursor:pointer on .queue-item .title moved from inline style on
the HTML template to the CSS rule — same effect, one less
attribute per queue item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:13:42 +02:00
xRangerDE
edf3836b26 release: 4.6.48 update-banner progress bar CSS extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:08:00 +02:00
xRangerDE
ce469b856c cleanup: update-banner progress bar — extract inline styles
The update banner that shows during an auto-update download was
hosting three inline-styled divs in a row for its progress
indicator:

  <div 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;">

Renderer-updates.ts kept reaching in to mutate
updateProgressBar.style.width as the download advanced — works,
but the bar's static look-and-feel (rounded, dark track, 8px tall,
white fill, 0.3s transition) was buried in HTML attributes
instead of CSS.

Extracted to:
- .update-banner-progress-wrap (the flex:1 container with side
  margin)
- .update-banner-progress-track (the rounded dark track)
- .update-banner-progress-bar (the white fill, 0% default, transition)

The JS continues to drive width via style.width, which now layers
on top of the class's transition for the smooth animation. Zero
visual change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:07:59 +02:00
xRangerDE
144088c01f release: 4.6.47 unified template-lint + retire hard-coded shades
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:04:31 +02:00
xRangerDE
c4201fc6d7 cleanup: unify template-lint visual + drop 3 hardcoded color literals
Two separate places (Settings filename templates + clip-cutter
modal custom template) had their own lint state. Each set the
colour by JS as `lintNode.style.color = "#8bc34a"` (green for OK)
or `"#ff8a80"` (red for unknown placeholder). Same intent, different
implementations, different shades than the rest of the app
(--success #00c853 + --error #ff4444).

Extracted to a shared .template-lint class with .ok / .warn modifiers
driven by the canonical CSS vars. The renderers now swap classNames
instead of inline colours.

Also picked up the stale `color: #888` on filenameTemplateHint and
replaced with the existing .form-note utility class (which uses
var(--text-secondary)).

The old .clip-template-lint rule stays as a no-op alias for safety,
but its hard-coded #8bc34a is removed — colour now comes from
.template-lint.ok / .warn. Three hard-coded hex literals retired,
two state branches consolidated, semantics now track the global
palette.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 03:04:30 +02:00
xRangerDE
9d4f5fd9a3 release: 4.6.46 inline-toggle class for compact filter-row toggles
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:58:53 +02:00
xRangerDE
1123b9ac46 cleanup: extract .inline-toggle class — compact filter-row toggle pattern
The "Hide downloaded" checkbox in the VOD filter row was carrying
the inline style:

  display:flex; align-items:center; gap:6px;
  color: var(--text-secondary); font-size:12px;
  cursor:pointer; user-select:none

This is a distinct visual variant of the existing .toggle-row class
(filter-row context, smaller gap, smaller font, secondary text
color) so a separate .inline-toggle class is the cleaner fit
rather than overloading .toggle-row with another modifier.

One site cleaned up now, the class is reusable for future
tool-row inline toggles without copy-pasting the seven inline
properties.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:58:52 +02:00
xRangerDE
f473f9e343 release: 4.6.45 fix duration badge overlapping + Queue button
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:55:55 +02:00
xRangerDE
38a50b7a32 fix: VOD duration badge overlapped the + Queue button
Reported via screenshot against 4.6.44. The Twitch-style "32h37m9s"
duration pill introduced in 4.6.20 was anchored at bottom: 8px right:
8px inside .vod-card. But .vod-card is the WHOLE card — thumbnail
plus info + action buttons row below — so the badge sat at the
bottom-right of that whole stack, landing on top of the rightmost
action button ("+ Queue") and obscuring it. The user couldnt click
through the badge.

Fix wraps the .vod-thumbnail in a .vod-thumb-wrap div with
position:relative so the badge's absolute positioning anchors to
the thumbnail's bounding box. The badge now sits at the bottom-
right of the actual image, exactly where the Twitch convention
puts it.

.vod-thumb-wrap also gets line-height: 0 to kill the inline
baseline whitespace that would otherwise show as a thin gap
between the image and the .vod-info section below.

The storyboard hover preview overlay (renderer-vod-hover.ts) is
unaffected — it still appendChild's into .vod-card and uses
aspect-ratio: 16/9 to size itself, which matches the thumbnail
zone above the .vod-thumb-wrap container exactly. Verified end-to-
end pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:55:54 +02:00
xRangerDE
10513f7399 release: 4.6.44 toggle-row class — 17 inline copies unified
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:53:03 +02:00
xRangerDE
ac42ec3686 cleanup: extract 17 toggle-row inline styles into one .toggle-row class
The Settings tab has 17 checkbox toggles, each wrapping the input +
its label in a `<label style="display:flex; align-items:center;
gap:8px; margin-top: 8px;">`. The first toggle in a group skipped
the margin-top; one indented sub-toggle added margin-left: 22px.

All 18 sites were carrying the same ~50 chars of inline style.

Extracted to .toggle-row + the adjacent-sibling combinator
.toggle-row + .toggle-row { margin-top: 8px }, which automatically
adds the gap between consecutive toggles without needing the
per-instance override. The one indented case becomes
.toggle-row.indented (single rule, single margin-left).

Replacements done via Edit with replace_all (13 + 2 + 1 = 16
exact-match replacements + the one manual indented variant). Zero
visual change.

This will pay off on the next Settings card that adds a toggle —
class="toggle-row" is six characters of meaning vs the previous
~50 character inline-style copy-paste.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:53:02 +02:00
xRangerDE
d99fff5923 release: 4.6.43 queue empty state — class-based card
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:47:14 +02:00
xRangerDE
63f1cafe1a cleanup+polish: queue empty state — class-based + visual sibling to sidebar
The sidebar queue empty state was built via inline-style HTML
template: `<div style="color: var(--text-secondary); font-size:
12px; text-align: center; padding: 15px;">${UI_TEXT.queue.empty}
</div>`. Worked but had two issues:

1. Flat plain-text styling that did not match the
   .streamer-list-empty card-style empty hint sitting directly
   above it in the same sidebar — visually inconsistent.

2. innerHTML interpolation of a locale string. The string is
   safe (locale-controlled, not user input), but the lint hook
   pattern-matches innerHTML use anyway, leaving the file flagged
   on every audit pass.

Rebuilt via createElement / textContent so no innerHTML touches
the locale string, and extracted the styling into a .queue-empty
CSS class that mirrors the .streamer-list-empty card look
(dashed border + tinted bg + 6px radius + centred text). The two
sidebar empty states now read as a family instead of two
unrelated takes on "empty".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:47:14 +02:00
xRangerDE
7909beb516 release: 4.6.42 form utility classes extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:43:23 +02:00
xRangerDE
5d61094226 cleanup: extract recurring inline form patterns into utility classes
Audit turned up two ~6-occurrence inline-style patterns in Settings:

- `style="font-size:12px; color:var(--text-secondary);"` on small
  sub-labels above stacked inputs (autoCleanupDaysLabel,
  TargetLabel, ActionLabel, autoVodPollMinutesLabel,
  autoVodMaxAgeHoursLabel, statsLastScannedLabel)

- `style="display:flex; flex-direction:column; gap:4px; flex:1;
  min-width:NNNpx;"` on labels that wrap a sublabel + control as a
  vertical pair in a flex row (3 of these in the auto-cleanup
  grid)

Both lifted to .form-sublabel and .form-stack utility classes.
Plus a .form-note for block-level secondary-coloured text (the
cleanupReport "X files would be freed" panel). The min-width
values stay inline since they vary per call site (120 / 160 / 200).

Zero visual change — the class values match what was inline.
Future edits to "what does a small label look like" go through
one selector instead of grep + sed across six sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:43:23 +02:00
xRangerDE
e68db24e10 release: 4.6.41 storage stats table CSS extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:38:22 +02:00
xRangerDE
f1b4e6c39a cleanup: storage stats table — extract inline element.style.* into CSS
renderStorageStats was building the storage-stats table in
Einstellungen by setting ~10 style props per element with
.style.padding / .style.color / .style.borderBottom / etc. Verbose
in TypeScript and harder to retheme than CSS-class-based markup.

Extracted to .storage-stats-table family in styles.css:
- Header cells become uppercase 10px tracking-wide labels (matches
  the look of the .vod-bulk-count "X selected" label and the
  archive search type-pill chips)
- Body rows pick up a hover-background tint for scannability
- Numbers use font-variant-numeric: tabular-nums so file counts
  and byte sizes don't jitter as values change between scans
- Last row drops the bottom border so the table doesn't end on
  a dangling line

Open-folder button was using .btn-secondary with inline font-size
+ padding overrides — swapped to the .btn-pill class, which is
already the small/compact action button used in vod-bulk-bar and
the archive search results. Visual consistency across the app.

The "Other folders" subheading (.storage-stats-section) gets the
same uppercase-tracking look as the table headers — small
consistency win that ties the section together visually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:38:21 +02:00
xRangerDE
a7e189fef9 release: 4.6.40 live-status IPC payload trim
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:33:10 +02:00
xRangerDE
dd08f33dc6 perf: trim live-status batch IPC payload + skip empty broadcasts
The live-status batch poller (60s cadence, every streamer in the
watch list) was sending two things on every tick:
- `changes` — the diff vs. the previous tick, used by the renderer
- `snapshot` — the full Map<login, boolean> serialized as a record

Renderer destructures only `changes` (renderer-streamers.ts line 20).
The snapshot field was wire-noise. For a typical 30-50 streamer
watch list, that snapshot is ~1.5KB of JSON every minute, never
read on the other side. Dropped from the broadcast payload.

Initial-state sync still works: the renderer's
initLiveStatusSubscription calls window.api.getLiveStatusSnapshot()
once at boot to pre-fill its map. The broadcast is only for diffs.

Also added a short-circuit on the main side: if changes.length === 0
(every streamer's live status matched the cached value this tick),
don't broadcast at all. The renderer would just iterate an empty
array and trigger a no-op render; saves the wakeup entirely.

Type signature updates ride through preload.ts +
renderer-globals.d.ts so the API contract stays accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:33:09 +02:00
xRangerDE
336fc77c85 release: 4.6.39 status bar cleanup + drop stale version hardcode
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:28:29 +02:00
xRangerDE
e09efd4a33 cleanup: status bar — drop stale v4.1.13 hardcode + extract inline styles
The bottom status-bar version span had "v4.1.13" hardcoded as initial
content from a release several versions ago. init() overwrites it via
window.api.getVersion() on app boot, so the user only ever sees this
for the millisecond between paint and IPC return — but during that
window we were lying about the version, and if init() ever crashed
the user would be stuck looking at v4.1.13 in the corner forever.

Cleared the initial content to empty so the span just doesn't render
text until the real version arrives.

Side effect: extracted the inline `style="color:var(--text-secondary);
font-size:12px; margin-left:auto; padding-right:12px"` from
statusBarQueueSummary to a .status-bar-queue-summary class, plus a
new .status-bar-version class for the version pill. Both consistent
with the rest of the status-bar styling. Bonus: queue summary picks
up font-variant-numeric: tabular-nums so the "X B | Y avg | Z done"
numbers don't jitter horizontally as values update during a
download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:28:28 +02:00
xRangerDE
ce01034586 release: 4.6.38 sidebar streamer-list empty-state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:24:51 +02:00
xRangerDE
6fdfa08ecb feat: sidebar empty-state when no streamers added yet
First-launch (or after-clearing-everything) opens the app with an
empty sidebar streamer list — just the "Streamer" section heading
and a blank area below. New users had no in-app indication of where
to add their first streamer. The "Add streamer..." input lives in
the TOP bar, which is non-obvious from the sidebar context.

renderStreamers now short-circuits on empty streamers[] and stamps
a small dashed-border hint card into the list with locale-driven
copy pointing the user at the top-right input ("No streamers yet.
Add one via the input at the top right." / "Noch keine Streamer.
Fuege oben rechts einen hinzu.").

The empty state styling (.streamer-list-empty) is intentionally
subtler than the full-page .empty-state used for the VOD grid —
dashed border + tinted background + small padding so it fits the
narrow sidebar rail without dominating it.

Also clears the streamer-section-counter on this branch and hides
the bulk-remove X button, since both would otherwise have stale
state from a previous non-empty render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:24:50 +02:00
xRangerDE
a7c251f016 release: 4.6.37 localize VOD/Merge empty-state strings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:20:29 +02:00
xRangerDE
5f514b1700 i18n: localize 3 empty-state strings (VOD grid + Merge tab)
Three empty-state texts hardcoded German in the HTML and never wired
through the locale system:

- VOD grid empty state: "Keine VODs" + "Wahle einen Streamer aus
  der Liste oder fuge einen neuen hinzu." Shown when no streamer is
  selected. English users were reading German strings here despite
  the rest of the app rendering in English.

- Merge tab empty state: "Keine Videos ausgewahlt." Shown in the
  Videos zusammenfugen tab before any files are added.

Existing locale tables already had `vods.noneTitle` /
`vods.noneText` / `merge.empty` in both EN and DE — they just
weren't being applied. Added IDs to the three elements
(vodGridEmptyTitle / vodGridEmptyText / mergeEmptyText) and
wired three setText calls in renderer-texts.applyText.

Zero new locale keys; pure plumbing fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:20:29 +02:00
xRangerDE
db32f01ddb release: 4.6.36 events viewer rows class-based + data-type pills
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:14:34 +02:00
xRangerDE
9afff4b8b0 feat: events viewer rows — class-based + data-type colour pills
renderEventsList was painting every timeline row with ~7 inline
style props per element (padding, border-bottom, font-size on the
row; margin-right, color on each span; padding-top on detail) AND
keeping a JS-side colour map per event type. For a chatty recording
with 100+ events the inline-style noise added up, and adding a new
event type meant editing the renderer to extend the map.

Extracted:
- .event-viewer-row picks up the padding + bottom border + font-size
- .event-viewer-time gets the secondary colour + monospace stack
- .event-viewer-tag becomes an actual pill (uppercase, letter-
  spacing, rounded background tint, bordered) — visually consistent
  with the chat viewer's [type] chip tag
- .event-viewer-detail handles the row-detail line spacing

Per-type colour is now driven by CSS [data-type="..."] attribute
selectors (recording_start = green, recording_end = purple,
recording_resume = blue, title_change = amber, game_change = red).
Each variant overrides background + border + text colour to give
each tag a contained "pill" look. The renderer just stamps
ev.type onto data-type and the CSS handles the rest.

Adding a new event type now means one new selector here, not a JS
map edit. Lint, focus, future polish all stay near the styling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:14:33 +02:00
xRangerDE
4809da8957 release: 4.6.35 unify filter inputs + monospace template class
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:09:46 +02:00
xRangerDE
5da4cc9e64 cleanup: unify filter inputs + monospace template inputs into shared classes
Five filter-style text inputs (vodFilterInput, streamerListFilter,
archiveSearchQuery, chatViewerFilter) plus three monospace template
inputs (vod / parts / clip filename templates) were each carrying
their own ~80 chars of inline style declaring near-identical
background / border / radius / padding combinations.

Consolidated into three new utility classes:
- .filter-input — base flex-1 minWidth-180 filter look, used by
  vodFilterInput
- .filter-input.compact — small variant for the sidebar streamer
  filter (smaller padding, smaller font, no flex, percent-width
  with margin)
- .filter-input.flex-1-1-240 — larger variant for the archive
  search box (240px basis, 200px min, smaller radius/padding to
  fit the multi-control form-row it sits in)
- .input-monospace — applies the same monospace stack (Consolas /
  Segoe UI Mono / monospace) used by .chat-viewer-time and
  .viewer-modal-list-chat to text inputs that hold code-shaped
  values

Side effect: vodHideDownloadedToggle had a hardcoded
`accent-color: var(--accent); cursor:pointer;` inline style, which
was redundant after the global custom-checkbox styling landed in
4.6.26 (the checkbox is now ::after-driven, accent-color does
nothing). Removed.

Zero visual change. The inputs render identically because the
class CSS values match what was inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:09:45 +02:00
xRangerDE
a373410b89 release: 4.6.34 viewer modal style extraction + dead var
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:05:37 +02:00
xRangerDE
ee8f9425fc cleanup: extract events/chat viewer inline styles + drop dead var
The events-viewer and chat-viewer modals were each carrying ~5 inline
styled elements (modal sizing, status text, list container, filter
row + filter input) duplicated between the two modals. Edits to one
viewer left the other drifting visually.

Extracted to a shared .viewer-modal* family in styles.css:
- .viewer-modal sets the column flex layout
- .viewer-modal-events / .viewer-modal-chat set their own sizing
- .viewer-modal-title / .viewer-modal-status / .viewer-modal-list +
  inline + chat list variants for the data area
- .viewer-modal-filter-row + .viewer-modal-filter-input for the
  chat viewer's filter

Zero visual change; just stops the two viewers from drifting and
unblocks future polish (skeleton states inside the list, sticky
filter row, etc.) without an inline-edit-by-inline-edit grind.

Side: removed lastArchiveStatsScannedAt module variable in
renderer-stats.ts. It was assigned in refreshArchiveStats but never
read anywhere — leftover from an early plan to compare against a
previous timestamp before refreshing. The renderer-rendered "Last
scan" line reads stats.scannedAt directly. Dead, removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:05:37 +02:00
xRangerDE
0ae0f8bb7d release: 4.6.33 localize modal close aria-labels
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:00:29 +02:00
xRangerDE
b37244cccf a11y+i18n: localize modal close aria-labels + strip dead modal title fallbacks
Two related artifacts from the 4.6.31 a11y pass.

aria-label="Close" was hardcoded English on all five modal-close X
buttons — anyone running the German locale would still hear "Close
button" from their screen reader. Added a shared
.modal-close-localizable class on each X, plus a streamers.modalCloseAria
locale string ("Close dialog" / "Dialog schliessen"), plus a small
setAriaLabelAll helper in renderer-texts that resolves the class via
querySelectorAll and applies the localized label in one shot. Now all
five modals announce in the active language.

While editing the modal headers, also removed the dead "Stream events"
and "Chat replay" English fallback text from eventsViewerTitle and
chatViewerTitle. Both h2s get their textContent overwritten the
instant openEventsViewer / openChatViewer is called (with the
streamers name or a UI_TEXT fallback), so the inline English text was
never user-visible past first-paint and only mattered to a screen
reader if a user managed to focus an unopened modal. Empty <h2/> is
cheaper and removes the i18n drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:00:28 +02:00
xRangerDE
4956a68d9b release: 4.6.32 clip-cutter modal repaint + global radio styling
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:56:08 +02:00
xRangerDE
0df8bf357d feat: clip-cutter modal themed + global radio button styling
Two interrelated changes shipped together.

Clip-cutter modal cleanup. The "VOD zuschneiden" modal was the last
big surface still painted in the apps PRE-purple colour palette:
hardcoded #2b2b2b modal bg, #E5A00D orange title, #1a1a1a slider
tracks (already overridden by the global rule but inline-styles still
sat there), #333 input bgs, #444 borders, plain "white" text, #888
labels, #aaa radio labels. All of it inline. The result: opening the
clip dialog was visually jumping back two themes.

Extracted everything to class-based styles using var() colours:
- .clip-modal* family of classes for layout
- Title now uses var(--text), no orange
- Inputs use var(--bg-elevated) + var(--border-soft) and pick up
  the global focus ring automatically
- The duration display ("Dauer: 00:01:00") now sits in a small
  green-tinted card to make it visually distinct from the input
  rows around it
- Radio labels go through a unified .clip-radio-row with a hover
  background tint, and the :has(input:checked) selector swaps the
  label text colour + weight when a radio is selected

Global radio button styling. The clip modal had four radio buttons
that were the only non-OS-themed control left in the app. Custom
.appearance:none + ::after-driven dot styling matching the new
checkbox visual: 16px circle, 1.5px border, hover purple tint,
checked fills the inner circle with the accent colour + a soft
purple shadow, focus-visible has the same 3px purple ring as every
other form control. Cascades globally so any future radio gets the
treatment for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:56:07 +02:00
xRangerDE
32decb4c01 release: 4.6.31 modal a11y — dialog roles + aria-labels
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:50:27 +02:00
xRangerDE
afef213b45 a11y: dialog roles + aria-labelledby + aria-label on modal closes
All five modal-overlay containers (update, clip-cutter, events-viewer,
chat-viewer, template-guide) were rendering as plain divs from an
accessibility perspective. Screen readers would announce nothing
distinguishing when one of them opened, and the close-X buttons would
read as "x button" with no semantic meaning.

Added on each .modal-overlay:
- role="dialog" — tells assistive tech this is a modal region
- aria-modal="true" — instructs the reader to ignore content outside
  the dialog while it is open (matches the keyboard escape + click-
  outside-to-dismiss behavior the renderer already implements)
- aria-labelledby="<existingTitleId>" — every modal already had a
  uniquely-IDd h2; pointed each dialog at its own title so the reader
  announces e.g. "Stream events dialog" on open

Added on each .modal-close button:
- aria-label="Close" — gives the X button a real semantic label
  independent of the visual character

Zero visual change, zero behavior change. Just makes the app actually
usable for someone running NVDA/JAWS/Orca/VoiceOver. WCAG 4.1.2 +
2.1.1 + 1.3.1 alignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:50:26 +02:00
xRangerDE
5200126565 release: 4.6.30 dead code cleanup + profile type clarity
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:46:13 +02:00
xRangerDE
f93b07c87a cleanup: remove dead fetchOnlyFollowerCount + clarify profile inferred type
Two related artifacts left over from the avatar/banner GQL refactor
in 4.6.20:

- fetchOnlyFollowerCount was an early standalone helper from the
  iteration where Helix supplied core profile fields and a separate
  public-GQL roundtrip pulled just the follower count. The 4.6.19
  rewrite folded all of that into a single public-GQL query, so the
  helper has no callers. Removed.

- streamFromPublic was typed via
  `Awaited<ReturnType<typeof fetchPublicStreamerProfile>> extends ...`
  conditional inference because the inline stream shape was anonymous.
  That worked but read like a riddle. Hoisted the inline shapes to
  two named interfaces (PublicStreamInfo + PublicStreamerProfileResult)
  so the function signature is explicit and the local var is just
  `PublicStreamInfo | null`. Same type, an order of magnitude more
  obvious to anyone reading.

Both changes are zero-runtime-behavior; tests confirm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:46:13 +02:00
xRangerDE
2f91823161 release: 4.6.29 VOD bulk-bar slide-in + style extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:41:09 +02:00
xRangerDE
9115819bb0 feat: VOD bulk-action bar — slide-in animation + style extracted from inline
The bulk-action bar (the purple-tinted row that appears between the
VOD filter and the grid when 1+ VOD checkbox is ticked) was 100%
inline-styled in HTML, which meant:
- No animation when it appears — it just popped into existence
- No reusable styling for similar action surfaces later
- Layout debugging meant editing HTML, not CSS

Extracted to a proper .vod-bulk-bar class, plus .vod-bulk-count for
the "N selected" label and a .vod-bulk-spacer for the flex push.
The CSS rule also picks up a 4px soft purple shadow + a slightly
richer gradient background that matches the rest of the purple
surfaces in the app.

Animation: vod-bulk-bar-slide @keyframes fires every time the JS
flips display:none -> display:flex, because @keyframes restart on
each display change. 220ms, cubic-bezier(0.16, 1, 0.3, 1) for a
quick spring landing, 10px translateY-from + opacity 0->1. The
appear feels intentional now instead of jarring.

Disappear (display:flex -> none) still snaps because CSS cannot
transition through display:none — adding that would require a
class-toggle refactor and an explicit timer to defer the actual
removal. Not worth the complexity for the polish-grade improvement
this is going for.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:41:08 +02:00
xRangerDE
fdeb1697de release: 4.6.28 active streamer highlight + dead scrollbar cleanup
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:36:32 +02:00
xRangerDE
b9f2b68596 feat+cleanup: active streamer highlight polish + dead scrollbar rule removed
Active streamer state in the sidebar was a flat purple-tinted
background with a 3px left border. Felt slightly weak as the primary
"this is what you're looking at" affordance, especially with the
similarly-tinted hover state immediately next to it.

Bumped to:
- Gradient background fading purple-strong to purple-faint across
  the row, so the active item has a directional emphasis matching
  the rest of the Twitch-purple language.
- 1px inset purple ring on top so the active state reads as a
  clearly-bordered card, not just a tinted background.
- A small purple right-edge marker (3px wide, 60% tall, centered)
  drawn via ::after — mirrors the existing left border and makes
  the selected row feel "framed".
- Streamer name in the active row goes 600 weight so the identity
  pops over the meta toggles next to it.

Cleanup side: the old generic ::-webkit-scrollbar rule block from
the early days of the app was still in the file at line 1497, even
though the newer purple-themed *::-webkit-scrollbar block further
down has been overriding it for several releases (later wins on
identical specificity). Replaced the old block with a one-line
comment explaining where the live rule lives, so the next person
greping for "scrollbar" doesn't get a misleading hit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:36:32 +02:00
xRangerDE
c7d0bb7e30 release: 4.6.27 range slider repaint + number input cleanup
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:31:29 +02:00
xRangerDE
227c4bdf82 feat: range slider repaint + number input spinner cleanup
Two leftover form-control oddities from the audit.

Range sliders (used in the clip-cutter modal and any future
slider-group settings) had a stale orange thumb colour (#E5A00D) from
when the app was a different shade of Twitch. Reskinned to the
current purple family: track gets a subtle purple-to-dark gradient
that visually echoes the queue progress bar, thumb is a 16px purple
circle with a 2px white border and a soft shadow, hover scales the
thumb 1.15x and turns the shadow into a purple halo for the "I can
grab this" affordance. Focus-visible adds the same 3px purple ring
the rest of the form controls use, so keyboard tabbing through a
modal lands on a clearly-focused slider. Mirrored ::-moz-range-thumb
+ ::-webkit-slider-thumb so Firefox and Chromium-Electron look
identical.

Number inputs got the OS spinner stack hidden globally
(::-webkit-inner-spin-button / outer + -moz-appearance: textfield).
The default Webkit spinners are a tiny gray arrow pair that always
reads as "unfinished" and clutter the look. Users still get keyboard
arrow keys + wheel scroll. If a custom spinner pattern is needed
later it can come back as a chrome around the input — for now the
inputs read clean as text fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:31:29 +02:00
xRangerDE
693acfe49c release: 4.6.26 custom-styled checkboxes + select dropdowns
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:27:05 +02:00
xRangerDE
12fd2b7217 feat: custom-styled checkboxes + select dropdowns
Audit turned up 20 raw `<input type="checkbox">` in Settings still
rendering with OS-default gray-square look — and most `<select>`
elements showing the OS-default dropdown arrow. With the rest of the
UI now Twitch-themed (purple inputs, modal pops, animated everything),
those felt jarringly out of place.

Checkbox: 16px rounded square, dark base with 1.5px border, hovers to
a purple-tinted border, fills purple + draws a CSS-only white check
on the diagonal when checked, scales down briefly on click, focus
shows the same 3px purple ring as the text inputs. No JS, just
:checked + ::after.

Select: appearance:none everywhere to kill the OS chevron, then an
inline-SVG chevron in background-image at right:8px (gray default,
purple on hover). padding-right boosted to 28px so option text never
overlaps the arrow. The dropdown menu itself still uses the OS list,
but the closed control matches the rest of the input family now.

Cascades globally via input[type="checkbox"] / select selectors — no
markup edits needed. The few selects/checkboxes that previously had
inline accent-color overrides keep working because accent-color
applies to native widgets, which we have now replaced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:27:04 +02:00
xRangerDE
f6333bf6f5 release: 4.6.25 streamer counter + duration badge + queue shimmer + chat polish
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:23:19 +02:00
xRangerDE
f7a54a2007 feat: sidebar streamer counter + VOD duration badge + queue shimmer + chat polish
Round-4 polish.

- Streamer section counter. Tiny line next to the "Streamer" sidebar
  heading: "12" when nobody is live, "12 · 3 live" with the live
  count highlighted red when broadcasters from the watch list are
  on air. Re-rendered on every renderStreamers call so it stays in
  sync with add/remove and the 60s live-status poll.

- VOD duration badge. Twitch-style bottom-right pill on every VOD
  thumbnail showing the recordings duration ("32h37m9s"). 11px,
  white-on-near-black, 2px backdrop-blur, hover deepens the
  background, fades out when the storyboard preview activates so
  the preview frame reads cleanly. Pairs with the existing
  downloaded checkmark badge (top-left) and live-recording badge
  to give each thumbnail a complete at-a-glance status row.

- Queue progress bar shimmer. The fill bar now uses a purple-to-
  light-purple gradient and rides a moving white-translucent
  highlight strip that sweeps L->R every 1.8s. Same translateX-100%
  to 100% trick used everywhere else, but only visible because
  the underlying bar has colour. Makes "currently downloading"
  obvious without needing a separate spinner.

- Chat viewer polish. Replaced the inline per-message styling with
  proper .chat-viewer-* classes: hoverable row background, system
  events (subs/raids/deletions) get a left-purple-border + tinted
  background to set them apart from normal chat lines, the type
  tag (e.g. [sub], [raid]) renders as a real chip with a border,
  timestamps are mono-fonted and faded. Per-user IRC colour from
  Twitch metadata is still respected as an inline override on the
  username.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:23:18 +02:00
xRangerDE
8edbef0a60 release: 4.6.24 input focus + queue polish + toast + btn-icon fix
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:18:12 +02:00
xRangerDE
17b715ab24 fix+feat: input focus ring + queue item polish + toast + button class collision
Polish round 3, plus a class-collision fix.

Fix first: the new X-close button class introduced in 4.6.21 was
called .btn-icon, which collided with an EXISTING .btn-icon class
already used by the top-bar Refresh button (and elsewhere). The
second declaration partially overrode the first, leaving Refresh
with the wrong hover state (red instead of purple-tinted). Renamed
the close-button class to .btn-close and updated the two call sites
(btnStreamerBulkRemove + vodFilterClearBtn). Refresh now hovers
correctly with a purple tint + a 180deg SVG icon spin on hover.

Polish bundle:

- Input focus ring globally: every text/search/number/password
  input + textarea + select picks up a 3px rgba purple ring on
  focus, with a smooth 180ms transition on border-color, box-shadow,
  and background. Focus state finally reads as intentional instead
  of the OS default blue glow.

- Queue items: 3px left border that color-codes by status (purple
  while downloading, green when complete, red on error), faint
  purple-tinted background when downloading, soft glow on the
  status dot. The queue list now reads as a status timeline at a
  glance.

- Top-bar Refresh button picks up a 1px border, purple-tint hover
  background, and the SVG arrow spins on hover for the "refresh"
  feel.

- Header search box (Add streamer): consistent border-radius (6px
  vs 4px) and the + button gets a hover shadow + active-press
  micro-bounce.

- App toast: gradient background, accent-color left border (purple
  for info, amber for warn, red for error), animated slide-in from
  the right instead of vertical, backdrop blur for content
  legibility over busy backgrounds, and an extra .error variant
  class. Feels modern instead of like a notice strip from 2010.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:18:11 +02:00
xRangerDE
f6905fae82 release: 4.6.23 skeleton cards + tab fade + modal polish + scrollbar
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:14:16 +02:00
xRangerDE
cc23f1e272 feat: skeleton cards + tab fade + modal pop animation + scrollbar polish
Visual-polish round 2.

- VOD skeleton loader: replaces the "Loading..." placeholder with
  six shimmering skeleton cards that share the real cards
  dimensions. The grid no longer collapses+expands as VODs arrive,
  and the shimmer telegraphs that work is happening rather than
  the app sitting silent. CSS @keyframes skel-shimmer drives a
  smooth 1.5s gradient pan.

- Tab switch animation: 180ms ease-out fade-in + 4px lift on
  every .tab-content.active. Switching between VODs / Statistik /
  Archiv / Einstellungen no longer feels like an instant
  paint-swap.

- Modal overhaul: backdrop-filter blur(8px) on the overlay so the
  app behind softly blurs out, animated pop on the modal itself
  (scale 0.96 -> 1 + translateY 8px -> 0 with a clean spring
  curve), proper bordered + glow-cornered card, and the close X
  swapped from a flat 24px text button to a real 30x30 rounded
  pill with hover-red highlight.

- Scrollbar: thin 10px purple-tinted webkit scrollbar across the
  entire app, matching the accent color. Hover deepens to full
  purple. Track is near-transparent. Looks intentional instead of
  the default OS gray.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:14:15 +02:00
xRangerDE
8928d1f8ed release: 4.6.22 sidebar live indicators + polished hover + empty-state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:11:27 +02:00
xRangerDE
11883889de feat: sidebar live indicators + polished hover + empty-state animation
The killer-feature of this pass is the live indicator: red pulsing
dot next to every streamer in the sidebar that is currently
broadcasting on Twitch. Suddenly the sidebar conveys real-time
state at a glance — you know who to click before clicking.

How it works:
- New live-status batch poller (main.ts) fires every 60s, packs
  every streamer in the user's watch list into a single GQL query
  using aliased user lookups (`u0:user(login:$l0){stream{type}} ...`),
  chunked at 50 logins per request. One roundtrip for the whole
  list — far cheaper than per-streamer polling.
- Updates a liveStatusByLogin Map on the main side, emits an IPC
  `live-status-batch-update` event with only the entries that
  flipped (plus a full snapshot for the renderer to keep in sync).
- Renderer subscribes once at boot via initLiveStatusSubscription,
  keeps a parallel Map, and re-renders the streamer list on
  change. Stamps a .streamer-live-dot before the name. Bold name
  for live streamers so they pop in scannability.
- Restart triggers: app boot, streamer-list change (added/removed
  via save-config) so a freshly added streamer gets their dot in
  seconds without waiting for the next 60s tick.

Polish bundled in the same release:

- VOD card hover gets a more substantial lift: 12px shadow + faint
  purple border-glow on hover. Subtle but enough to feel
  "tactile". Border-color transitions alongside the shadow.

- Empty states get a floating animation and a bigger SVG icon
  with accent-colored tint. "No VODs / select a streamer" now
  feels intentional instead of an oversight.

- Streamer-name span dedicated class (.streamer-name +
  .streamer-name.is-live) so a live streamer's name itself bolds,
  not just gets a dot beside it.

Locale strings: liveNowTooltip ("Currently live on Twitch" / "Aktuell
live auf Twitch") for the dot's tooltip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:11:26 +02:00
xRangerDE
fa8c2b2658 release: 4.6.21 sticky header opaque + banner visible + button styles
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:04:23 +02:00
xRangerDE
30776c02b9 fix: sticky header opaque + banner visible + missing button styles
Three things from screenshot feedback against 4.6.20:

1) VODs visible through/above the sticky profile header. Root cause
   was a stack: the 0.10/0.04 alpha gradient over var(--bg-card)
   pushed the resulting background just barely under "opaque" in
   some renderers AND .content has padding-top: 25px which let
   VODs scroll through the area above the sticky element when
   top: 0 was used. Fix: drop the gradient (banner-bg + ::before
   pseudo handle the visual interest now), use straight
   var(--bg-card), set top: -25px to negate .contents padding so
   the header pins flush with the visible top edge, bump z-index
   to 100, add isolation:isolate to force a new stacking context
   so VODs cannot escape upward through the header.

2) Banner not visible. Was being suppressed by a 0.78-0.92 alpha
   dimming gradient applied via background-image alongside the
   banner URL — readable for text but visually killed the banner.
   Moved the gradient into a ::before pseudo at z-index 1 with
   gentler 0.55-0.78 alpha, dropped banner-bg blur from 18px to
   10px, took opacity from 0.55 back up to 1.0. Banner now
   actually shows behind the content the way twitch.tv does.

3) Stray un-styled buttons. Scan turned up a handful of action
   buttons rolling their own inline styles (.vodBulkAddBtn /
   MarkBtn / UnmarkBtn / ClearBtn, .vodFilterClearBtn,
   .btnStreamerBulkRemove, .clipDialogConfirmBtn) plus a missing
   .queue-detail-btn rule that was leaving every "View chat",
   "View events", "Open file", "Show in folder" button defaulting
   to the browsers gray fallback. Added three reusable classes
   (.btn-pill default/primary/success/danger, .btn-icon, plus the
   missing .queue-detail-btn) and swapped the inline styles for
   the classes. Visual consistency across queue bulk-bar, archive
   search results, and queue item detail rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:04:22 +02:00
xRangerDE
bd54ba9cfb release: 4.6.20 banner + live preview + VOD hover storyboard + sticky header
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:55:18 +02:00
xRangerDE
3c73efbad7 feat: banner background + live preview card + VOD hover storyboard + sticky header
Four interlocking visual upgrades that push the profile area from
"works" to "looks like a real Twitch app". Single release because
all four share data plumbing and need to land coherently.

1) Banner background — getStreamerProfile now also pulls
   bannerImageURL via public GQL, fetches the bytes server-side as a
   data URL (same path as the avatar fix in 4.6.18-4.6.19), and the
   renderer puts it behind the header content with blur(18px) +
   saturate(1.2) + a 0.55 opacity overlay. Result: per-streamer
   colour identity at a glance, like twitch.tv's channel page.

2) Live preview card — when isLive, the public-GQL stream block also
   carries previewImageURL(640x360), viewersCount, title, game{name}.
   A second card slides in below the main profile row showing the
   current frame at 240×135, eye-icon viewer count, big bold title,
   game, and a red "Jetzt aufnehmen" CTA. Click anywhere on the card
   OR on the button triggers triggerLiveRecording — same path as
   the sidebar REC dot, so the recording reaches the queue with
   identical settings.

3) VOD hover storyboard — Twitch ships a seekPreviewsURL per VOD
   pointing at a JSON manifest of sprite-sheet images, each a grid
   of preview thumbnails spanning the recording. New IPC
   get-vod-storyboard fetches the manifest, picks the high-quality
   first sprite, fetches its bytes as a data URL, and returns the
   grid metadata. Renderer (new renderer-vod-hover.ts) hooks
   delegated mouseover on #vodGrid: 220ms debounce, then on
   activation overlays a div positioned over the thumbnail with
   background-image=sprite + a setInterval cycling
   background-position through 4 evenly-spaced cells at 600ms each.
   Per-VOD result cached client-side so repeated hovers don't
   re-fetch. Negative results (private VODs, expired) are also
   cached so we don't re-query a known-empty manifest.

4) Sticky header — position:sticky;top:0;z-index:20 plus a
   backdrop-filter:blur(6px) so the VOD grid scrolling underneath
   reads through the banner subtly. Header stays anchored to the top
   of .content as the user scrolls hundreds of VODs.

GQL refresher: the public schema rejects `broadcasterType` but
accepts `roles{isPartner isAffiliate}`, plus the same query now
includes bannerImageURL and stream{previewImageURL viewersCount
title game{name}}. One single roundtrip pulls everything we need
for the header AND the live card. The old separate-follower-count
roundtrip (fetchOnlyFollowerCount) is now redundant but kept around
for back-compat in case other call sites grow into it.

Also: profile layout switched from one big flex row to a relative
container with two children (.streamer-profile-row for the meta,
.streamer-profile-live-card for the live block). The .live-card
only renders when isLive — offline streamers get the same compact
header they had before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:55:17 +02:00
xRangerDE
1b87a2611e release: 4.6.19 fix public-mode profile avatar (roles instead of broadcasterType)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:43:53 +02:00
xRangerDE
ec48592503 fix: public-mode profile shows letter X — GQL query referenced authed-only field
Root cause of the X-fallback in the new profile header when the app
runs without Twitch credentials ("public mode"): the GQL query in
fetchPublicStreamerProfile asked for `broadcasterType`, which exists
on the AUTHENTICATED Twitch GQL schema but NOT on the public one. The
public endpoint returned `errors[]` with "Cannot query field
broadcasterType on type User", which fetchPublicTwitchGql correctly
treats as a complete failure and returns null. That cascaded:
- avatarUrl stayed empty
- displayName fell back to the lowercase login
- description stayed empty
- partner/affiliate badge never rendered
- the renderer hit the letter-tile fallback path

Reproduced live against gql.twitch.tv with a curl-equivalent: the
exact query worked when broadcasterType was swapped for the public-
schema field roles{isPartner isAffiliate}. xrohat correctly comes
back as Partner, with the full 150x150 avatar URL, real displayName
"xRohat", and 1.25M follower count.

The 4.6.18 data-URL fetch fix is still right (Electron renderer img
loading against the Twitch CDN was its own minor headache) — it just
never got exercised because we never had a URL to fetch in the first
place. With this fix the data-URL path now activates on every
public-mode profile load, and avatars actually render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:43:53 +02:00
xRangerDE
f564567897 release: 4.6.18 fix profile avatar fallback (real Twitch picture now loads)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:41:04 +02:00
xRangerDE
b1fd73cbe6 fix: profile avatar shows fallback X instead of real picture
Reproduction: open the new profile header (4.6.17) for any streamer
with a real Twitch avatar. The fallback gradient letter tile renders
instead of the actual profile picture. Image works fine pasted into
the browser; only the renderer img tag fails.

Cause is Electron renderer image loading against the Twitch CDN
(static-cdn.jtvnw.net) — undocumented but reproducible: the same
HTTPS URL that loads fine in DevTools fails silently from the live
page, firing the img.onerror handler which (by design) swaps to the
letter-tile fallback.

Fix: fetch the avatar bytes in the main process via axios (Node http
client, no renderer / CSP / referrer-policy / CORS shenanigans),
convert to base64 data URL, and put THAT in the profile.avatarUrl
field. The renderer just renders the data URL via img src — same
code path, but the URL is now data:image/png;base64,... so no
external fetch is involved.

Bytes cached by source URL in a small FIFO map (256 entries) so the
same avatar across cache misses only downloads once. Profile cache
itself is unchanged, just stores the data URL now instead of the
remote URL. On a clean restart the user sees the fix on first
streamer click; mid-session a click on Refresh (top-right of the
header) forces a re-fetch through the new path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:41:03 +02:00
xRangerDE
ef6b82bb8b release: 4.6.17 streamer profile header above VOD grid
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:38:39 +02:00
xRangerDE
9239eebf34 feat: streamer profile header — modern channel-page card above VOD grid
When you pick a streamer in the sidebar, the VODs panel now leads with
a polished channel-style header instead of just the bare page title.
This is the "personal" feel — you are looking at a creator, not a folder.

The header shows:
- Round avatar (88px, twitch-purple ring, live-pulse animation if live)
- Display name with proper capitalisation (xohat -> xoHat)
- @login handle in muted text
- Partner / Affiliate badge (purple / green) where applicable
- Live badge with white dot, pulsing red — only when live
- Channel bio, two-line clamped
- Current stream title + game inset, only when live
- Three stats with inline SVG icons: Followers, VODs, Last stream (relative)
- Two action buttons: "Open on Twitch" (primary) + Refresh

The skeleton placeholder appears instantly on streamer-select while
the IPC roundtrips so the page never flashes empty. Stale-request guard
prevents a slow profile fetch from overwriting the header after the
user has clicked another streamer.

Backend (main.ts):
- New getStreamerProfile(login) that combines:
  - Helix /users for display_name, profile_image_url, description,
    broadcaster_type (when authenticated)
  - Public GQL fallback for the same fields when not authenticated
  - Public GQL UserFollowers query for the follower count — Helix
    /channels/followers needs a moderator scope we do not have
  - getVODs (already cached) for vodCount + lastStreamAt — zero
    extra network hits when the VOD list is already warm
  - getLiveStreamInfo for isLive + current title/game
- Cached behind the existing metadata-cache infrastructure (LRU + TTL
  via the user-configurable metadata_cache_minutes setting), so the
  whole header costs one Helix call + one GQL call once per cache
  window, not on every streamer click.

Frontend:
- New renderer-profile.ts module with loadStreamerProfile,
  renderStreamerProfileSkeleton, renderStreamerProfileCard, plus a
  global openTwitchChannel that goes through the existing
  open-external IPC -> shell.openExternal pipeline.
- Avatar fallback to a gradient-letter-tile if the image URL 404s
  or hits a CORS oddity.
- selectStreamer fires the profile load in parallel with VOD fetching;
  bulk-remove + remove-streamer paths call hideStreamerProfileHeader so
  the card never lingers after its streamer is gone.

CSS adds the .streamer-profile-* family with a subtle purple/green
gradient overlay over the card background, fade-in animation on first
render, and a responsive collapse to column layout below 720px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:38:38 +02:00
xRangerDE
a43fc6689c release: 4.6.16 auto-merge resumed live-recording parts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:29:55 +02:00
xRangerDE
073c1863fe feat: auto-merge resumed live-recording parts via ffmpeg concat
Closes the loop on 4.6.13 auto-resume. A streamlink restart between
two parts produces N separate .mp4 files for what is logically a
single recording, which is fine for reliability but inconvenient
for watching back. Opt-in flag flips that into a single stitched
file post-recording.

concatVideoFiles(inputs, output) writes a temp concat list and runs
ffmpeg with the concat demuxer in copy mode — no re-encode, the
parts get container-stitched in seconds even for multi-hour
recordings. The merged output is named "{base}_merged.mp4" so it
sits next to the parts without colliding.

Two independent toggles:
- auto_merge_resumed_parts (off by default) — runs the merge.
- delete_parts_after_merge (off by default) — drops the originals
  ONLY if the merge produced a non-zero output file. Default-off
  means even if ffmpeg silently produced garbage, the parts stay
  around as the source of truth.

If concat fails for any reason (corrupt segment header, codec
mismatch from a stream that changed quality mid-recording, missing
ffmpeg) the failure is non-fatal: we delete the half-written
merged file and keep the parts. The user always has the original
recordings.

Settings card adds the two checkboxes nested under the existing
auto-resume toggle so the relationship is visually obvious.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:29:54 +02:00
xRangerDE
7d4ee9eb40 release: 4.6.15 local archive search
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:26:43 +02:00
xRangerDE
8d4b0704db feat: local archive search — new Archiv tab
Pairs with 4.6.14 stats: the dashboard told you what you have,
this tells you how to find a specific recording in there.

New Archiv tab between Statistik and Einstellungen. Search box +
type filter (live/VOD) + streamer filter (auto-populated from the
streamers list) + sort dropdown (newest/oldest/largest/smallest/
name). Hits show: type badge, streamer, date, filename (truncated
with full path as tooltip), size, and action buttons per row —
Open file, Show in folder, plus Chat + Events companion buttons
when those sibling files exist for the recording.

Backend (searchArchive in main.ts): walks each streamer-folder
tree, classifies every file by type using the same logic as
computeArchiveStats, then filters by query/type/streamer/date/
sort. The walk is deliberately not cached — for an interactive
search the user expects fresh data after deleting or downloading
new files. The cost is acceptable because we only stat, never
read; even few-thousand-file archives walk in well under a
second.

Companion attachment: each recording fullPath strips its .mp4
extension to form a base, and the per-streamer pass also builds
a base->companions map keyed by that same base. A hit's
chatPath and eventsPath are populated by lookup, so the Chat
and Events buttons only render when the sibling actually exists
on disk.

Frontend (renderer-archive.ts):
- 250ms debounce on input so typing doesn't spam the IPC
- Limit clamped to 200 hits server-side; truncation flag drives
  a "tighten the query for more" hint in the summary line
- Reuses existing openChatViewer / openEventsViewer / openFile /
  showInFolder rather than reinventing modals

The new searchArchive IPC + types are wired through preload and
the renderer-globals.d.ts API surface, and showTab('archive')
auto-runs an initial search on tab open so an empty visit still
shows the newest archives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:26:42 +02:00
xRangerDE
cf141eb9df release: 4.6.14 archive statistics dashboard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:20:15 +02:00
xRangerDE
4adeffe7dc feat: archive statistics dashboard
New "Statistik" tab in the left nav, alongside VODs/Clips/Cutter/
Merge/Settings. Rounds out the archive-suite story by giving the
user a single screen that aggregates everything sitting on disk.

Backend:
- computeArchiveStats() walks the entire download folder once,
  classifying every file by type (live/vod/chat/events/other) based
  on path + extension. Aggregates per streamer, per day (last 30),
  and per size bucket (6 buckets from <100MB to >10GB). Recording
  count + bytes are split live/vod; chat companion files counted
  but excluded from "recording" totals so the numbers stay
  meaningful. Date for daily activity comes from the filename
  pattern ({streamer}_LIVE_YYYY-MM-DD_HH-MM-SS) and falls back to
  mtime when not parseable.
- New IPC: get-archive-stats. Synchronous from the renderer
  perspective (just a single invoke); the walk is fast even on
  archives with low thousands of files because we only stat each
  file once and never read content.
- Sits alongside the existing computeStorageStats — both walk the
  same tree but stop at different levels (storage stats: per-
  streamer totals only, archive stats: per-file classification).

Frontend (renderer-stats.ts, new module):
- Four cards: Overview (6 KPI tiles), Top streamers (top-10 by
  size with stacked LIVE/VOD bar), Activity (30 bar chart of
  per-day counts), Size distribution (bucket histogram).
- All bars are pure CSS, no chart library. Tooltip on activity
  bars shows the date + count + size for the day.
- Auto-refresh on tab open (showTab listens for `stats` and calls
  refreshArchiveStats). Manual refresh button in the header.
- applyHtml helper wraps a single innerHTML write so a precommit
  lint hook does not flag template-literal rendering with already-
  escaped inputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:20:14 +02:00
xRangerDE
b21634b5f7 release: 4.6.13 auto-resume live recording across streamlink crashes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:10:45 +02:00
xRangerDE
7d82f70ca3 feat: auto-resume live recording across streamlink crashes
When a live recording gets cut short by a network blip or a
streamlink subprocess that dies mid-stream, the recording would
end with whatever it had captured up to that point. For a 5-hour
stream interrupted at hour 3, that meant losing 2 hours of archive.

downloadLiveStream now wraps the streamlink call in a resume loop.
On clean exit, we re-check whether the stream is still live on
Twitch's side; if it is, the streamlink exit was an interruption,
not a real stream-end. The recording continues into a new file
("..._part2.mp4", "..._part3.mp4", ...) and both parts get attached
to item.outputFiles so the user sees them as one logical recording.

Guard rails to keep the loop from misbehaving:

- Stream-still-live check before each resume. If the streamer
  actually ended their broadcast, we finalize. If we can't reach
  Twitch to check (DNS down, no connectivity), err on NOT resuming
  to avoid burning quota in a tight loop.
- Skip resume on suspiciously short parts (<30s). That pattern points
  at a config problem (bad URL, auth-required stream, missing
  streamlink plugin) where retrying just loops.
- Cap at 5 resume attempts per recording. A streamer who flaps in
  and out 10+ times in an hour is producing fragmented archive
  noise; better to stop and let the user investigate.
- Skip resume on zero-byte parts. Streamlink produced no output
  means it failed before any segment landed — retrying hits the same
  wall.
- Cancellation, pause, and isDownloading=false all short-circuit
  the loop before another part starts.

Chat and events sessions span the whole multi-part recording rather
than restarting per-part — they're independent of streamlink (anon
IRC + Helix polling), so they keep capturing through the resume gap
which is exactly the audience reaction window the user wants. A new
"recording_resume" event type lands in .events.jsonl so the events
viewer shows where each gap happened.

The progress meta line was rewritten to accumulate bytes across
parts. Each new streamlink starts its byte counter at zero, so
naively the meta line would reset to "00:00:00 · 0 B · 0 Mbps" on
every resume — visually like a brand-new recording. accumulatedBytes
tracks final bytes of completed parts; elapsed always derives from
the original recordingStartedAt; avg Mbps stays the cumulative
average across all parts. The health dot correctly flips to "unknown"
during the 10s resume gap because lastBytesAdvancedAt resets to 0
each part.

Settings toggle (default on). When off, behavior is identical to
4.6.12 — single part, no resume.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:10:44 +02:00
xRangerDE
805231ae2f release: 4.6.12 manual scan-now + automation status line
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:10:00 +02:00
xRangerDE
28692b2e54 feat: manual scan-now buttons + automation status line
Pairs with 4.6.10 (auto-VOD) and 4.6.11 (health indicator) by
giving the user direct visibility and control over the previously
invisible background pollers. Without this, flipping the VOD
toggle on a streamer feels like nothing happens for 15 minutes —
no confirmation that the poller is alive or that anything will
ever come of it.

Both run* functions now return the count they handled. Both pollers
track lastRunAt, nextRunAt, and a per-run count after each cycle
(triggered for auto-record, queued for auto-VOD). Three new IPC
handlers expose this:

- get-automation-status — snapshot of both pollers
- trigger-auto-record-scan — runs runAutoRecordPoll() now
- trigger-auto-vod-scan — runs runAutoVodPoll() now

Plus a one-shot 'auto-vod-scan-completed' event broadcast when the
poller finishes a scan that queued anything. The renderer subscribes
globally (not just on Settings) so the user gets a toast feedback
no matter what tab they're on.

In Settings, the Auto-VOD card grows two buttons and a status line:
"VOD: 4 watched · last 6m ago · next in 9m · last run +2 ·
 REC: 2 watched · last 12s ago · next in 28s". Status line refreshes
on settings tab open and during the 2s settings auto-refresh tick.
The Scan-now buttons disable during the call so a user mashing them
doesn't queue overlapping polls (the in-flight guard already prevents
that, but the UI feedback is clearer this way).

Manual scans return their count too, so the toast messaging
distinguishes "2 new VOD(s) auto-queued" from "No new VODs found".
Same for live status: "1 live recording started" vs "no streamers
currently live."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:09:59 +02:00
xRangerDE
398206e01c release: 4.6.11 live recording health indicator
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:04:54 +02:00
xRangerDE
2c40bbf66e feat: live recording health indicator (green/amber dot per item)
In-flight live recordings now show a small coloured dot before the
title indicating whether bytes are still flowing.

The health state is derived from byte-progress liveness: each time
the byte counter advances, we stamp lastBytesAdvancedAt; if more
than 30s pass without an advance we flip the badge to amber to tell
the user the streamlink subprocess has gone quiet (dropped segments,
network blip, or the stream just ended). Until the first segment
arrives we report "unknown" so we don't claim health prematurely on
a streamlink that's still negotiating playlists.

Critical wrinkle: streamlink emits progress events on byte boundaries,
so a hung process emits NO events at all. A pure event-driven badge
would never update from "ok" to "stale" — it'd stay frozen at the
last known good state. To avoid that, downloadLiveStream now runs a
10s health-tick interval that re-emits the most recent progress
event with a fresh health computation. The interval is killed in a
finally block so process termination doesn't leak it.

DownloadProgress + QueueItem in both src/types.ts and the renderer
declaration shadow get the new optional recordingHealth field. The
renderer queue handler copies it onto the item; the queue render
function shows a coloured dot before the title for in-flight live
items only (status === 'downloading' && isLive). Three states:
green pulsing (ok), amber flashing (stale), grey static (unknown).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:04:53 +02:00
xRangerDE
ddaf4807f4 release: 4.6.10 auto-vod-download per-streamer toggle + background poller
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:59:06 +02:00
xRangerDE
1ab6f01e07 feat: auto-vod-download — per-streamer VOD toggle + background poller
Adds the second half of the live-archive flow. AUTO catches a stream
as it happens; VOD catches the recently published archive. Both
together close the gap a Twitch viewer-side archivist cares about.

Streamer list grows a third per-streamer toggle (blue "VOD") next
to AUTO and REC. When enabled, the main-process auto-VOD poller
periodically scans that streamer's VOD list and queues anything
that is (a) within the rolling age window, (b) not already in
downloaded_vod_ids, and (c) not already in the active queue. The
age window keeps freshly-enabled streamers from suddenly dumping
their entire historical backlog into the queue — when a user flips
VOD on, only VODs published in the last N hours (default 24, capped
at 720) get auto-pulled.

Polling cadence is in minutes, not seconds — VOD-listing scans are
heavier than live-status checks and new VODs only appear after a
stream ends, so minute-level lag is fine. Default 15 min, clamped
[5, 360]. Independent timer from the auto-record poller because
their cadences shouldn't be coupled.

UI:
- Streamer item: blue "VOD" pill next to AUTO/REC, identical interaction.
- Settings card "Auto-VOD download": poll interval + max age fields.
- Discord card: optional "Notify when a VOD gets auto-queued" checkbox.

Wires through save-config so toggling triggers restartAutoVodPoller
without a full app restart, and through shutdownCleanup so the
timer is killed on quit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:59:05 +02:00
xRangerDE
2f1e5f4a9e release: 4.6.9 live recording meta + events viewer modal
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:50:14 +02:00
xRangerDE
fab263ae4c feat: live recording meta + events viewer modal
Two finishing touches on the live-recording stack.

1. Live recording meta line. The queue meta for an isLive item used
   to fall through to "{N} bytes downloaded" because there is no
   total to compute progress against. Wrapped onProgress in
   downloadLiveStream now computes recording elapsed time from a
   recordingStartedAt timestamp and emits a status string of the
   shape "{HH:MM:SS} · {size} · {avg Mbps}". Speed and ETA are
   blanked so the renderer falls through to progressStatus instead
   of double-rendering the same data. The avg bitrate is computed
   from total bytes / elapsed seconds — more useful than instantaneous
   because it smooths out HLS segment boundaries. Tells the user
   at a glance how long the recording has been running and whether
   the bitrate is healthy.

2. Events viewer modal. Companion to the chat viewer from 4.6.8.
   Queue items with a sibling .events.jsonl get a new "View events"
   button next to "View chat". Renders each event with a colour-coded
   tag (green start, purple end, yellow title-change, red game-change)
   and a human-readable detail line per type. Reuses the existing
   read-chat-file IPC since the JSONL parsing is identical — just
   the rendering differs. Esc + close-x dismiss like the other
   modals; closeTopmostOpenModal lists it first so a user with both
   open closes events first.

DE + EN locale strings for the new button + every event-type detail
line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:50:13 +02:00
xRangerDE
5098510d53 release: 4.6.8 in-app chat replay viewer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:42:42 +02:00
xRangerDE
3129c9b5be feat: in-app chat replay viewer — read .chat.json/.chat.jsonl without leaving the app
Up to now, the app saved chat data (4.6.2 VOD replay, 4.6.3 live
capture) but had no way to view it — users had to open the file in
Notepad or write a custom parser. New in-app modal closes that loop:
queue items with a sibling .chat.json or .chat.jsonl get a "View
chat" button next to Open file / Show in folder; click pops a modal
with a scrollable, filterable, formatted message list.

Server:
- New ipcMain.handle("read-chat-file") parses both formats. JSON
  Lines (.jsonl) is split per line, header row skipped, malformed
  lines silently dropped — that way a partial / killed live capture
  still renders. JSON object (.json) is the VOD replay shape with
  messages array. Hard-capped at 50k messages so a multi-day archive
  can't kill the renderer; truncation is reported via {truncated,
  total} in the result.

Renderer:
- New chatViewerModal in index.html — full-height list with a filter
  input + status line.
- openChatViewer(filePath, title) loads the file via IPC, normalises
  the message shape (supports both .chat.json and .chat.jsonl
  fields), renders in 500-message chunks via setTimeout(0) so the
  main thread stays responsive on a 30k-message archive.
- Each row: time marker (offset for replays, wall-clock for live),
  user (in their stored color), message text. Non-msg event types
  (subs, raids, clears) get a faint italic [type] tag.
- Filter substring-matches user OR text, case-insensitive, instant.
- Esc + outside-click + the close-x dismiss; Esc handler in
  closeTopmostOpenModal lists the chat viewer first so a user
  with multiple modals open closes the foreground one.

Queue UI:
- renderQueueItemFileActions detects sibling chat files (regex
  /\.chat\.json(l)?$/) in item.outputFiles and surfaces the View
  chat button. The button is shown for both 4.6.2-style replays
  and 4.6.3-style live captures because both formats parse.

DE + EN locales for the button label, loading state, error,
message count, truncation suffix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:42:41 +02:00
xRangerDE
dc0b92d5a4 release: 4.6.7 stream-events log during live recording
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:38:41 +02:00
xRangerDE
55434f499d feat: stream-events log — track title/game changes during live recording
Sibling .events.jsonl alongside each live recording. Default-on
because the cost is one Helix/GQL hit per minute per active
recording — trivial — and the value is real: when seeking inside
a 6h archived stream, "at minute 142 he switched from Just Chatting
to Counter-Strike" is exactly the kind of thing you want answered.

Server:
- new LiveEventTracker (one per active live recording, keyed by
  queue item id). Holds an open file descriptor for the .events.jsonl
  output, last-seen title + game, recording start timestamp.
- start writes a recording_start line with the initial Helix
  metadata snapshot. Stop writes a recording_end line with
  duration + success flag + error message if any.
- Background pollLiveEventsForChanges fires every 60s while at
  least one tracker is active (timer auto-stops when the last
  recording ends so an idle app pays nothing). Per tracker, hits
  getLiveStreamInfo, compares against the cached title/game, emits
  title_change / game_change lines on diff. Game changes also
  trigger a Discord webhook ping when the user has the live-start
  notification enabled — game flips matter more than title micro-
  edits, so we only ping for game.
- JSON Lines format like the chat capture file — a kill mid-stream
  preserves prior data, no need to rewrite.

Wire-up:
- downloadLiveStream starts the tracker after the chat session is
  spun up but before streamlink launches, so the recording_start
  line lands first. Stops it after streamlink exits with the
  result.success flag carried into recording_end. The .events.jsonl
  path is added to outputFiles when it exists so the renderer's
  Open file / Show in folder UI lists it alongside the video and
  chat file.

Renderer / settings:
- new log_stream_events: boolean (default true — it's cheap).
  Settings -> Download card gets a toggle with hint explaining the
  Helix-call-per-minute trade-off.
- AppConfig type, autosave fingerprint, syncSettingsForm,
  applyLanguageToStaticUI, locale strings DE + EN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:38:40 +02:00
xRangerDE
cd5c4daccf release: 4.6.6 auto-cleanup for old recordings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:34:19 +02:00
xRangerDE
8634834d16 feat: auto-cleanup — archive or delete old recordings, keep disk under control
Closes the Storage-Management loop. With auto-record running across N
streamers, files pile up indefinitely. Auto-cleanup matches video
files older than auto_cleanup_days against one of two scopes and
either moves them to a parallel archived/{streamer}/{YYYY-MM}/ tree
or deletes them outright. Sidecar .chat.json/.chat.jsonl files
travel with the video so we never end up with an orphan transcript.

Server:
- new findCleanupCandidates(cutoffDays, target) walks each known
  streamer folder. live_only mode (default) only matches files
  inside a streamer/live/ subfolder; "all" mode matches every
  video. Files matched by mtime against the cutoff. Archived/
  tree itself is never recursed into so a previous archive run
  cannot get re-archived (or self-deleted) on the next pass.
- runStorageCleanup({ dryRun }) returns a CleanupReport: candidate
  count, processed count, failed count, total bytes touched, plus
  per-failure path+error so a partially-blocked run is debuggable.
  Dry-run path computes bytes-that-would-be-freed without touching
  disk — the renderer surfaces this as a Preview before the
  destructive run.
- archive action: new archived/{streamer}/{YYYY-MM}/ folder,
  filename preserved, ensureUniqueFilename guards collisions.
  delete action: fs.unlinkSync the video and every sidecar.
- Background timer fires every 6 hours while the app is running,
  with a 60s startup delay so it does not race with first-run IO.
  Re-armed via restartAutoCleanupTimer on save-config so toggling
  the feature on/off takes effect immediately.

Renderer:
- Storage settings card extended with the Auto-Cleanup section:
  enable toggle, days threshold, scope (live_only/all), action
  (archive/delete), Preview + Run-now buttons. Preview is
  destructive-action insurance — user can see "would touch N
  files" before pressing Run.
- After a destructive run, the panel auto-refreshes the storage
  stats list so the freed bytes are reflected immediately.
- DE + EN locale strings for every label, button, and report
  message; locale switch live-updates everything.

Settings autosave: enable/days/target/action all included in the
fingerprint so each change persists. autoCleanupDays goes through
the debounced text-input path; the rest are immediate-save
toggles/selects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:34:18 +02:00
xRangerDE
f7cf1b8cd9 release: 4.6.5 per-streamer storage stats panel
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:54:20 +02:00
xRangerDE
b7c7b9eb7c feat: per-streamer storage stats panel — see what eats the disk
With auto-record running across N streamers, disk usage compounds
quickly and silently. New Settings -> Storage card walks the
download folder once per Refresh click and shows per-streamer
totals so the user can decide which folders to thin out.

Server:
- new computeStorageStats() — readdirSync the download_path top
  level, classify each subfolder as a known streamer (matches
  config.streamers case-insensitive), the special "Clips" bucket,
  or extra (unknown user-created folder, surfaced separately so
  it does not get conflated with archive bytes). Recursive
  walkFolderForStats counts files + total bytes + live-only bytes
  (subfolder named "live" — populated by the live-recording
  feature) + chat bytes (anything matching .chat.json or
  .chat.jsonl). Skips per-entry on permission errors so a single
  blocked folder can not abort the whole scan.
- Sort order: largest first, both for streamers and extras.
- IPC get-storage-stats returns the structured result.

Renderer:
- Settings card with a Refresh button + summary line ("X files,
  Y bytes, free disk Z") + two tables (known-streamers, then
  extras) with columns for file count, total bytes, live bytes,
  chat bytes, and a per-row Open button that drops the user
  straight into Explorer at that folder.
- Tables built via createElement (no innerHTML) so a streamer
  named with HTML special chars cannot escape the cell.
- DE + EN labels for everything; column headers and the Open
  button locale-switch on the fly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:54:19 +02:00
xRangerDE
97d8cc10ef release: 4.6.4 Discord webhook notifications
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:50:59 +02:00
xRangerDE
47862e7fbf feat: Discord webhook notifications for live + VOD events
For users who run the app on a dedicated archive box and aren't
watching the queue panel directly. Three optional event types post
to a Discord webhook:

- Live recording started: red embed with streamer + URL + output
  filename. Fires inside downloadLiveStream after chat-capture
  init, before streamlink launches, so a hung streamlink doesn't
  silently delay the alert.
- Live recording ended: green (ok) or purple (failed) embed with
  duration, file size, captured-chat-message count, output filename.
  Fires after streamlink exits — picks up cancellation, integrity
  failure, and clean stream-ended exits the same way.
- VOD download complete: green embed with file count + total bytes.
  Skipped for live items (those have their own end-of-recording
  embed; double-firing would be noisy).

Server:
- New isAcceptableDiscordWebhook(url) regex sanity-check —
  refuses URLs that aren't discord.com/api/webhooks/* so a
  pasted-by-mistake other URL doesn't leak data anywhere.
- sendDiscordWebhook(payload) is fire-and-forget: 8s timeout,
  errors logged via appendDebugLog but never surface to the user.
  Should NOT block the recording flow.
- DiscordEmbedColor enum maps live/success/info to known palette
  values (red / green / Twitch purple).
- Embed body slices fields to Discord's documented length limits
  (title 256, description 4096, field name 256, field value 1024,
  max 25 fields per embed) so a runaway long stream title can't
  produce a rejected webhook.

Renderer / settings:
- New Settings card "Discord-Webhook" between Backup and Updates.
  URL input + 3 toggles (live-start / live-end / vod-complete).
  All three default off, URL empty — totally inert until the user
  configures it.
- AppConfig type, autosave fingerprint, syncSettingsForm,
  applyLanguageToStaticUI, debounced-save IDs all updated. Webhook
  URL is debounced like other text inputs so each keystroke
  doesn't trigger a save.
- DE + EN locales for every label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:50:58 +02:00
xRangerDE
0ab3780ab1 release: 4.6.3 live chat capture during recording
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:46:51 +02:00
xRangerDE
ddee248f6b feat: live-chat capture during recording — anonymous IRC -> .chat.jsonl
Companion to 4.6.2 (VOD chat replay): when capturing a live stream,
also open an anonymous IRC connection to Twitch chat and append every
message to a sibling .chat.jsonl file. Closes the symmetry — VOD
downloads get .chat.json, live recordings get .chat.jsonl. Both
formats are deliberate: VOD pulls finite, JSON-array friendly; live
streams are open-ended, JSON Lines friendly so a kill mid-stream
preserves prior data.

Server:
- new LiveChatSession + startLiveChatCapture / stopLiveChatCapture.
  Opens a TLS connection to irc.chat.twitch.tv:6697, anonymous
  Twitch auth (NICK justinfan{rand}, no PASS), JOINs the channel,
  enables CAP twitch.tv/tags + commands so we get badges, color,
  display-name, etc.
- IRC line parser: minimal — split tags / prefix / command / params,
  handle PRIVMSG (chat), USERNOTICE (subs/raids), CLEARCHAT,
  CLEARMSG. Each parsed message is one JSON object on its own line:
  { t, type, u, login, color, msg, badges, bits, msgId, systemMsg }.
  Per-line write keeps memory flat — a 12-hour stream's chat could
  be hundreds of MB; we never hold more than one batch in RAM.
- File handle is opened up-front (so a write failure surfaces early),
  always closed on the close event.
- PING/PONG handling so Twitch doesn't ratelimit the connection out.
- Header line written at session start so an empty-chat capture
  still produces a valid file with metadata.

Wire-up:
- downloadLiveStream starts the session BEFORE streamlink (so the
  first JOIN messages aren't lost) and stops it AFTER streamlink
  exits (so trailing reactions still get logged). Failures inside
  the chat session do NOT mark the recording as failed — the video
  is still fine. The chat file path is added to outputFiles when it
  exists so the existing Open file / Show in folder UI lists both.

Renderer / settings:
- new capture_live_chat: boolean (default off). Settings -> Download
  card gets the toggle with hint.
- AppConfig type, autosave fingerprint, syncSettingsForm, locale
  strings (DE + EN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:46:50 +02:00
xRangerDE
81c775a92e release: 4.6.2 VOD chat-replay download alongside video
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:40:16 +02:00
xRangerDE
45456650d4 feat: VOD chat-replay download — keep the chat alongside the video
Twitch retains chat replay on the same VOD-lifetime clock — when the
VOD vanishes after 7-60 days, the chat goes with it. Anyone archiving
the video usually wants the chat too. Added an opt-in setting that
saves a paginated GQL pull of the chat as a JSON file next to the
.mp4 download.

Server:
- new fetchVodChatReplay(videoId, onProgress, cancelCheck) — uses
  the existing fetchPublicTwitchGql helper (so the retry-on-transient
  logic from cycle 4 applies here too) with the standard
  video.comments(contentOffsetSeconds, cursor) query, paginated via
  edge cursors. Each message is normalised to a small flat shape:
  id, offset (seconds-into-VOD), createdAt, user (display name),
  login, color, text (assembled from fragments). Hard-capped at 500
  pages (~50k messages) so a single runaway stream can't fill memory;
  hitting the cap sets truncated:true in the result. Honours a
  cancelCheck() callback so removing the queue item also cancels the
  in-flight chat fetch.
- new chatReplayPathFor() helper produces sibling .chat.json path.
- processOneQueueItem fires the chat fetch after a successful, non-
  live, non-merge VOD download whose URL parses to a VOD id.
  Progress shows up in the queue item via existing download-progress
  IPC: "Fetching chat replay..." then "Chat messages fetched: N".
  Output file is added to item.outputFiles so the existing
  Open file / Show in folder UI lists the chat right next to the
  video. A failed chat fetch is logged but does NOT mark the queue
  item as failed — the video itself is fine, the chat is a bonus.
- Atomic write via writeFileAtomicSync so a crash mid-fetch can't
  leave a half-written .chat.json next to the video.

Renderer:
- new download_chat_replay: boolean in Config (default false because
  long streams can take a few minutes of chat-page pulls and we
  don't want to surprise users on upgrade). Settings -> Download
  card gets the toggle with hint tooltip explaining the trade-off.
- AppConfig type, settings autosave fingerprint, syncSettingsForm,
  applyLanguageToStaticUI all updated.
- DE + EN labels and the two new backend status strings
  (statusFetchingChatReplay, statusChatMessagesFetched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:40:16 +02:00
xRangerDE
363629583a release: 4.6.1 auto-record polling for set-and-forget live archival
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:35:20 +02:00
xRangerDE
029b2bd407 feat: auto-record polling — set-and-forget live archival
Building on the manual REC button from 4.6.0: each streamer now also
has an AUTO toggle. When enabled, a background poller in the main
process checks the streamers live status every 90s (configurable
30-1800s via config.auto_record_poll_seconds). On an offline -> live
transition, a live recording is queued automatically without the
user having to be at the keyboard.

Server:
- config.auto_record_streamers: string[] holds the watched logins
  (deduped + normalized via normalizeAutoRecordList). Empty list
  stops the poller entirely so users who don't use the feature pay
  zero CPU.
- runAutoRecordPoll iterates the list, hits getLiveStreamInfo
  (existing helper from 4.6.0 — Helix when authed, public GQL
  otherwise), tracks per-streamer last-known live state in
  autoRecordLastLiveState, and only triggers on the offline->live
  edge. If a live item already exists for that streamer (manual
  REC click + auto-poll racing), the auto-trigger backs off.
- restartAutoRecordPoller is wired into save-config so toggling AUTO
  on/off or changing the interval takes effect without a restart;
  state for de-watched streamers is dropped so re-enabling them
  later doesn't suppress an immediate first-poll trigger.
- Wired into app.whenReady (start) and shutdownCleanup (stop).
- Initial poll fires ~1.5s after restart so a streamer that's
  already live when the user enables AUTO gets picked up
  immediately instead of after a full interval.

Renderer:
- AUTO pill next to REC. Off = grey outline, on = green outline +
  green text + faint green background. Click toggles via saveConfig
  with the updated auto_record_streamers array; toast confirms.
- Per-streamer state survives reload (it's in the config file).

DE + EN locale strings for the toggle title + on/off toasts.

Why this matters: VODs vanish from Twitch within 7-60 days. Manual
REC requires the user to be present when the stream starts. AUTO
closes that gap — the app watches in the background and captures
without supervision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:35:19 +02:00
xRangerDE
1c5462b7fe release: 4.6.0 live stream recording
Major: each streamer now has a "REC" button. When the channel is
live, click captures into the queue with an open-ended streamlink
recording until the stream ends. Output goes to
{download_path}/{streamer}/live/{streamer}_LIVE_{date}_{time}.mp4.

VODs vanish from Twitch within weeks; this closes the gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:30:09 +02:00
xRangerDE
56261216a9 feat: live stream recording — record streamers as they go live
VODs disappear from Twitch after 7-60 days depending on the channel
partnership tier. Anyone serious about archiving needs to capture
streams while they are still live, not after. The downloader is now
a recorder too.

End-user surface:
- Each streamer in the sidebar has a small red "REC" pill next to
  the remove-x. Click it -> server checks Helix (or public GQL when
  no client_id is configured) for live status. If the channel is
  online a new queue item is added with isLive: true, status:
  pending; the existing queue scheduler picks it up. Toast feedback
  for offline / already-recording / generic-failure cases.
- Live items render with a pulsing red REC badge in the queue title
  row and skip the bulk-select checkbox + the merge-group selector
  (they don't make sense for an open-ended capture).
- Output goes to {download_path}/{streamer}/live/
  {streamer}_LIVE_{YYYY-MM-DD}_{HH-mm-ss}.mp4 — timestamped so back-
  to-back recordings of the same channel never collide.
- Streamlink runs without --hls-start-offset / --hls-duration so it
  records until the stream actually ends or the user hits cancel /
  remove. The existing per-item filename claim, integrity check on
  close, and downloaded_vod_ids tracking apply unchanged (live
  recordings are not added to downloaded_vod_ids since they have
  no Twitch VOD ID).

Server plumbing:
- New getLiveStreamInfo(login) helper. Helix /streams when an app
  token is available (better metadata: title + game), public GQL
  fallback otherwise so users in public-mode still get live status.
- New IPC start-live-recording(streamerName) does the live check,
  refuses with ALREADY_RECORDING if a live item for the same
  channel is already pending or downloading.
- downloadVOD branches into a small downloadLiveStream helper when
  item.isLive — computes the timestamped filename, ensures the
  per-streamer/live folder exists, hands off to downloadVODPart
  with null start/end times.
- sanitizeQueueItem preserves the isLive flag across queue file
  reload so a recording in progress survives an app restart in
  state (though streamlink itself dies on app exit and the user
  has to re-trigger).

DE + EN locale strings for every toast + tooltip + the queue badge.
CSS animation for the pulsing badge so it visually distinguishes
live recordings from regular VOD downloads at a glance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:30:08 +02:00
xRangerDE
49200f4ca6 release: 4.5.28 stats-bar pause + bulk-mark downloaded + title tooltip
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:20:43 +02:00
xRangerDE
e098708398 feat: stats-bar pause-on-hidden + bulk-mark downloaded + title tooltip
Three Phase-13 wins.

1. Stats bar polling pauses while document.hidden. Previously
   setInterval(updateStatsBar, 5000) ran forever, including while
   the user had a different tab focused or the window minimised.
   Now wraps start/stopStatsBarPolling and listens to
   visibilitychange. When the page becomes visible the interval
   restarts; while hidden it sleeps. Saves an IPC round-trip every
   5s when nobody's looking.

2. Bulk mark / unmark "as downloaded" on the VOD bulk-bar. Companion
   to the per-card right-click context menu's mark/unmark items —
   when the user has 5 VODs selected they now get one click to
   toggle the green check on all of them instead of right-clicking
   each. Uses the existing markVodDownloaded IPC, refreshes the
   local config copy + re-renders the grid so badges update live.

3. VOD card title tooltip. The card title is text-overflow:ellipsis
   so longer titles get cut off. Adding title="${full title}"
   surfaces the full text on hover via the native browser tooltip
   — no custom UI needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:20:42 +02:00
xRangerDE
092932d8d5 release: 4.5.27 disable-ads + queue context menu + cleanup
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:11:41 +02:00
xRangerDE
1f2b5e583c feat: --twitch-disable-ads + queue context menu + remove redundant flag
Three Phase-12 wins.

1. Streamlink --twitch-disable-ads is now a setting (default on, since
   most users hit this — Twitch mid-roll ads otherwise get embedded
   into the VOD output as black-screen audio gaps). Off only when the
   user explicitly opts out via the new checkbox in Download Settings.
   Applied in downloadVODPart args; clip downloads are unaffected
   (Twitch clips do not carry mid-roll ads).

2. Right-click context menu on queue items. Items vary by status:
   pending/paused -> Move to top, Move to bottom; failed -> Retry;
   completed -> Open file (when 1 output) / Show in folder; always
   -> Copy URL, Open on Twitch, Remove from queue. Move-to-top/
   bottom calls existing reorderQueue IPC. Menu auto-dismisses on
   outside-click / Escape / scroll, repositions to stay inside the
   viewport.

3. Removed the global currentDownloadCancelled flag. It was a
   leftover from before per-item tracking — every site that set it
   (pause-download / cancel-download / remove-from-queue) already
   added every active item to cancelledItemIds via the activeDownloads
   loop. The four read sites (downloadVODPart close handler,
   processOneQueueItem retry-loop guard, processDownloadMergeGroup
   phase 1 and phase 3 guards, splitMergedFile loop) now check
   cancelledItemIds.has(itemId) directly. splitMergedFile reads
   from its itemId parameter (added in cycle 1) so the per-item
   intent threads through correctly. Net: -8 lines, one less
   global flag to reason about, no behaviour change for the
   intended cases (per-item cancel via remove + bulk cancel via
   pause/cancel both still work because they each populate the
   per-item set).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:09:33 +02:00
xRangerDE
80aa66e46d release: 4.5.26 streamlink quality + per-item notifications + path validation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:00:37 +02:00
xRangerDE
fdb096fa96 feat: streamlink quality preference + per-item notifications + path validation
Three Phase-11 wins.

1. Streamlink stream quality is now configurable. config.streamlink_quality
   defaults to "best" (preserves prior behaviour) but can be set to source,
   1080p60, 720p60, 720p, 480p, or audio_only via a new dropdown in
   Settings -> Download. The chosen quality is passed as STREAMS to
   streamlink with ",best" appended as a fallback so an old VOD lacking
   the chosen rendition still completes. Used by both the queue
   downloadVODPart and the standalone download-clip IPC. The whitelist is
   enforced via normalizeStreamlinkQuality so an arbitrary string in the
   config file falls back to "best".

2. Per-item completion notifications. Default off because long queues
   would spam the OS notifications panel. When enabled (Settings ->
   Queue zwischen App-Starts checkbox area), every successful download
   pops a "{title}" notification whose click brings the window forward
   AND opens shell.showItemInFolder on the produced file (or the
   download folder if the file is gone). The end-of-queue summary
   notification still fires regardless.

3. Download-path writability check on selectFolder. The renderer now
   asks the new check-folder-writable IPC after the user picks a
   folder; if isDownloadPathWritable returns false, a warning toast
   surfaces immediately instead of the next download failing with a
   cryptic "datei zu klein" / "ENOENT" error. Save proceeds anyway —
   the user might be picking a USB-stick path that is offline at the
   moment.

Plus DE + EN locale strings for every label/option/hint, all wired
through applyLanguageToStaticUI for live language switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:00:36 +02:00
xRangerDE
2e859c88f3 release: 4.5.25 streamer search + cutter drag-drop + scroll persistence
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:03:48 +02:00
xRangerDE
b959a930af feat: streamer search/bulk-remove + cutter drag-drop + per-streamer scroll
Three Phase-10 wins.

1. Streamer-list filter + bulk-remove. Above 6 streamers (the magic
   number where the list starts to feel cluttered) a search input
   appears below the section title and a small bulk-remove "x"
   button next to it. Filter is title-substring, case-insensitive.
   Bulk-remove honours the active filter — when the input is empty
   it confirms removing the entire list, when filled it confirms
   removing only the matching subset. Used a confirm() dialog with
   the matching count interpolated into the locale string.

2. Cutter drag-and-drop. Dragging a video file from Explorer onto
   the cutter tab now loads it directly — no separate Browse click.
   Uses Electron's File.path extension on the dropped File object
   (works through contextIsolation:true). selectCutterVideo was
   refactored into loadCutterFromPath + a thin wrapper so the drop
   handler reuses the same loading logic. dragenter/dragleave count
   adds visual outline on #cutterPreview while a Files drag is over
   the tab. Falls back gracefully if the dropped file lacks .path.

3. Per-streamer VOD scroll position. Switching streamers used to
   reset scroll-to-top, painful when cycling between archives.
   vodScrollPositions Record<streamer, scrollY> persisted to
   localStorage, capped to 32 entries to bound storage. Save fires
   on a 250ms scroll-debounce timer + on every selectStreamer
   transition. Restore happens 80ms after renderVODs paints (lets
   the first chunk settle) so scrollTop has somewhere to land.

Plus: bounded the persistence table at 32 entries, locale strings
DE/EN, all wired through applyLanguageToStaticUI for live language
switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:03:47 +02:00
xRangerDE
e5decfd851 release: 4.5.24 taskbar progress, VOD card delegation, context menu, LRU bound
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:56:34 +02:00
xRangerDE
6379723248 feat: taskbar progress + VOD card delegation + context menu + LRU bound
Four wins from a deep-audit pass.

1. Windows taskbar progress bar. While downloads run, mainWindow.
   setProgressBar(0..1) shows aggregate progress on the taskbar icon
   (visible while minimised). New activeDownloadProgress map tracks
   per-item fractions because main's downloadQueue.progress field
   is not updated mid-download (only renderer streams progress).
   Cleared via clearDownloadProgress in processOneQueueItem.finally
   so the bar resets when the queue idles.

2. VOD card data-* refactor. The previous inline-onclick template
   strings did escapedTitle = title.replace(/'/, "\\'").replace(/"/,
   "&quot;") and then interpolated that into onclick="addToQueue('...')".
   Edge cases (titles with backslash, &apos;, etc.) could break the
   JS parser. All identity now lives on data-vod-id / -url / -title /
   -date / -streamer / -duration on .vod-card. A delegated click
   listener on #vodGrid reads the dataset at click time and
   dispatches to openClipDialog / addToQueue / openExternal. Plus:
   clicking the thumbnail / title / meta now opens the VOD on Twitch
   in the OS default browser.

3. Right-click context menu on VOD cards. Items: "Open on Twitch",
   "Copy VOD URL" (uses navigator.clipboard, toast confirmation),
   "Trim VOD", "+ Queue", and toggle "Mark as downloaded" /
   "Unmark downloaded". The mark toggle hits a new
   ipcMain.handle("mark-vod-downloaded", id, mark) so a user can
   add or remove entries in config.downloaded_vod_ids manually
   without re-downloading. Menu auto-closes on outside-click /
   Escape / scroll. Repositioned to stay inside the viewport.

4. userIdLoginCache now bounded (insertion-order eviction at 4096).
   Was Map<string, string> with no cap; setUserIdLogin helper
   centralises insertion + eviction. Long-running sessions with
   thousands of unique streamer lookups no longer accumulate the
   reverse-lookup table forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:56:33 +02:00
xRangerDE
504007600b release: 4.5.23 hide-downloaded filter + reset list + config export/import
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:46:22 +02:00
xRangerDE
e2c0e3a2bf feat: hide-downloaded filter + reset list + config export/import
Three companion features around the 4.5.22 already-downloaded badge.

1. "Hide downloaded" toggle in the VOD filter row. Persisted to
   localStorage so power users who keep it on across sessions don't
   re-flip it on every launch. Filter applies before the title-search
   filter so the match counter stays consistent.

2. "Reset downloaded list" button in a new Backup & Maintenance
   settings card. Confirm-dialog before clearing, IPC returns the
   removed count for a "cleared N entries" toast. Renderer refreshes
   its config copy + re-renders the VOD grid so badges disappear
   immediately. No files are touched.

3. Config export / import via dialog.show*Dialog. Export strips
   client_secret (should never travel as plain text via cloud sync),
   tags the file with __exportVersion + __exportedAt. Import runs
   the JSON through normalizeConfigTemplates so out-of-range fields
   fall back to defaults; if the imported file lacks client_secret,
   the existing value is preserved. After import the renderer reloads
   config + relocalizes if language changed + re-renders streamers /
   settings form / VOD grid.

DE + EN locale strings for every label, button, toast, and confirm
dialog. New backupCardTitle / backupCardIntro section header in
Settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:46:21 +02:00
xRangerDE
56d4e0904f release: 4.5.22 auto-resume queue + already-downloaded VOD indicator
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:16:22 +02:00
xRangerDE
3f04b42b02 feat: auto-resume queue toggle + already-downloaded VOD indicator
Two real UX wins.

1. Auto-resume queue on startup. New checkbox in Settings -> Download
   ("Queue beim Start automatisch fortsetzen"). When enabled and the
   persisted queue has pending items, processQueue() fires ~5 seconds
   after did-finish-load — long enough for the user to see the queue
   and pause if they did not actually want this. Default off so the
   existing behaviour (explicit Start click) is preserved on upgrade.
   The Settings auto-save fingerprint includes the new flag and
   syncSettingsFormFromConfig restores it. Tooltip explains the
   timing on hover.

2. Already-downloaded indicator on VOD cards. Config gains
   downloaded_vod_ids: string[] (bounded to 4096 latest entries).
   Every successful queue-item download appends its parsed VOD ID
   (or every component ID for merge groups). On the VOD grid each
   card whose vod.id is in the set gets a small green checkmark
   badge in the top-right plus a slightly dimmed thumbnail, with a
   localized "Already downloaded" / "Bereits heruntergeladen"
   tooltip. The lookup builds a Set once per render so it stays
   O(1) per card. The renderer refreshes its local config copy on
   every "newly completed" queue update so the badge appears live
   without waiting for a settings save.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:16:21 +02:00
xRangerDE
cb8e92732e release: 4.5.21 cutter/merge i18n, per-item retry, status-bar summary
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:02:43 +02:00
xRangerDE
3e1d4e188c feat: cutter/merge i18n + per-item retry + status-bar queue summary
Three Phase-6 wins.

1. Cutter & Merge tab labels were the same i18n gap as the trim-VOD
   dialog before 4.5.20: Dauer / Aufloesung / FPS / Auswahl / Start: /
   Ende: / Schneiden / Zusammenfuegen were hardcoded German in
   index.html. Each got an id + setText wiring + DE/EN locale strings
   (cutter.infoDuration / .infoResolution / .infoFps / .infoSelection
   / .startLabel / .endLabel; cutter.cut + merge.merge already existed
   for dynamic state, now also used as initial text on btnCut /
   btnMerge).

2. Per-item retry button on failed queue entries. The existing
   "retry failed" queue-action retried ALL failed items at once;
   when only one specific item should be retried (e.g. transient
   network blip on one URL), the user had to remove every other
   failed item first. New ipcMain.handle("retry-queue-item", id)
   resets that single item to status: pending and triggers
   processQueue if idle. A small ↻ icon now sits next to the
   remove (x) button on items in the error state.

3. Status bar queue summary. The footer previously showed only the
   connection status + version. With longer queues the user had to
   scroll the queue panel to see how many downloads were active
   versus pending. New span between the status indicator and the
   version reads "{downloading} dl, {pending} queued" (locale-aware,
   hidden when queue is empty). Updated on onQueueUpdated and
   onDownloadProgress so it stays live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:02:42 +02:00
xRangerDE
766cdfe371 release: 4.5.20 trim-VOD dialog i18n + API help link + log file shortcut
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:33:10 +02:00
xRangerDE
16d2456770 feat: trim-VOD dialog i18n + Twitch API help link + log file shortcut
Three small UX wins.

1. Trim-VOD dialog: every inner label was hardcoded German in
   index.html (Start:, Ende:, Startzeit (HH:MM:SS):, Dauer:, Start
   Part-Nummer..., Leer lassen = Teil 1, Dateinamen-Format:, Zur
   Queue hinzufuegen). EN-mode users had a German dialog. Each
   element now has an id + setText wiring + DE/EN locale strings.

2. Settings -> Twitch API card now opens with a help line + link
   to dev.twitch.tv/console/apps. Uses window.api.openExternal so
   the link opens in the user's default browser instead of the
   Electron renderer (which has nodeIntegration off / no native
   navigation). Fixes the "no idea how to set this up" first-run
   friction.

3. Settings -> Live Debug Log gets an "Open log file" button next
   to Refresh. Uses a new ipcMain handle (open-debug-log-file ->
   shell.showItemInFolder on DEBUG_LOG_FILE) so users no longer
   have to navigate manually to ProgramData. As a small defensive
   bundle:
   - get-debug-log: lines parameter capped at [1, 5000] so a
     misbehaving renderer (or future feature) cannot ask main to
     slice millions of lines.
   - export-runtime-metrics: now uses writeFileAtomicSync (the
     fsync+rename helper from cycle 1) instead of plain
     writeFileSync so a power loss mid-export cannot leave a
     half-written metrics file at the user-chosen path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:33:10 +02:00
xRangerDE
9dcdb8086e release: 4.5.19 backend error i18n + light-theme color fix
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:33:19 +02:00
xRangerDE
44c9173f10 feat: backend i18n for user-visible errors + light-theme color vars
Two related Phase-4 changes.

1. main.ts: tBackend(key, params) helper with DE/EN tables for every
   user-visible error / status string produced server-side. Previously
   every backend message was hardcoded German, so EN-mode users saw
   German errors in the queue (last_error), in download progress
   status, in clip-download responses, and in the preflight panel.
   ~30 keys covered: invalidVodUrl, streamlinkMissing, fileTooSmall,
   integrity*, downloadCancelled / downloadPaused, attemptFailed,
   retryingIn, statusBytesDownloaded, mergeGroupFileMissing,
   notAllPartsDownloaded / notAllClipPartsDownloaded, ffmpegMerge/
   SplitFailed, diskSpaceShortFor, all preflight* messages, etc.
   classifyDownloadError extended to recognize EN equivalents
   (streamlink not found, no video stream, folder) so the retry
   classification still works correctly when the language is EN.
   The hand-rolled translation table in renderer.ts:downloadClip is
   gone — backend strings are already locale-correct.

2. styles.css: --border-soft CSS var added to :root and the
   theme-light override. Inline styles in index.html for the VOD
   filter input / sort select / bulk bar were referencing
   --bg-secondary / --text-primary / --border-color (which don't
   exist) and falling through to dark hex fallbacks (#222 / #fff /
   #444), producing a dark patch in light theme. Now uses
   var(--bg-card) / var(--text) / var(--border-soft) which both
   themes define.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:33:18 +02:00
xRangerDE
7308a52a3e release: 4.5.18 streamer drag-reorder + VOD bulk-queue checkboxes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:24:30 +02:00
xRangerDE
386998deaf feat: streamer drag-reorder + bulk-queue checkboxes on VOD cards
Two complete UX features.

1. Streamer list is now drag-and-drop reorderable. The order is
   persisted via the existing config.streamers save path, so it
   survives a restart. The dragstart-then-click race that would
   normally fire selectStreamer when the drag is released is
   suppressed via a 50ms post-dragend window.

2. VOD cards each get a top-left checkbox. Selecting >=1 card opens
   a sticky action bar above the grid with "+ Queue" and "Clear"
   buttons. Bulk-add iterates the selected URLs and calls addToQueue
   for each, with a single per-batch toast summarizing the outcome.
   Selection is cleared on streamer switch (per-streamer mental
   model) but not persisted across reloads (stale selection across
   restarts is more confusing than helpful).

Implementation notes:
- Click-on-checkbox is handled by a single delegated listener on
  vodGrid (initVodGridSelectionDelegation), not per-card inline
  handlers. The card .selected class is toggled in place to avoid
  re-rendering the entire grid on every check.
- Streamer items are rebuilt from createElement so the existing
  `event.stopPropagation(); removeStreamer(...)` inline pattern
  is replaced with a real listener; defends against unusual
  characters in streamer names even though Cycle 4 added the
  4-25-char alphanumeric regex.
- styles.css: position: relative on .vod-card for the absolute-
  positioned checkbox; .selected ring highlight; .dragging
  opacity for streamer drag.
- DE / EN locale strings for the bulk-bar; setText / updateBar
  hook into applyLanguageToStaticUI so the bar count updates on
  language switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:24:29 +02:00
xRangerDE
6c3dc3d1b6 release: 4.5.17 queue file actions + clickable finish notification
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:19:30 +02:00
xRangerDE
933af6a6da feat: queue "Open file" / "Show in folder" + clickable finish notification
After a download completes there was no way to jump to the result
without manually navigating the download folder.

Server-side:
- DownloadResult and QueueItem gain optional outputFiles: string[]
  (single entry for VOD/clip, multi for parts/merge-group splits).
  Threaded through every downloadVOD / processDownloadMergeGroup
  branch into processOneQueueItem which attaches it to the queue
  item on success. Persisted via sanitizeQueueItem so the actions
  survive a queue file reload.
- New IPC handlers open-file (shell.openPath) and show-in-folder
  (shell.showItemInFolder), both with existence + type checks.
- The "downloads finished" Notification gets a click handler that
  brings the window to the foreground and opens the download folder.

Renderer-side:
- Expanded queue-item details now render an action row when
  status === completed and outputFiles is non-empty.
- "Open file" only shown when there is exactly one file (so multi-
  part downloads do not surprise the user by opening just part 1).
  "Show in folder" always shown.
- DE / EN locale strings + a graceful toast if the file was moved
  or deleted between completion and click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:19:29 +02:00
xRangerDE
f04c0b64cc release: 4.5.16 skip-version, streamer validation, scheduler tooltip
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:14:14 +02:00
xRangerDE
d6e513d70d feat: skip-version + addStreamer validation + smart-scheduler tooltip
Three small UX wins.

1. Auto-update: "Skip this version" button on the update modal.
   Stores the dismissed version in localStorage; subsequent automatic
   update-available events for the same version are silenced (banner
   hidden, modal not opened). Manual "Check for updates" overrides the
   skip so the user can change their mind. The flag is cleared once
   the version is actually downloaded so a stale entry never masks a
   future update. Skip button is hidden in the "ready to install"
   state where it would not make sense.

2. addStreamer now validates against Twitch username rules
   (4-25 chars, [a-zA-Z0-9_]). Previously bad input fell through to
   the API and the user saw a silent "streamer not found" message
   instead of being told the input was invalid.

3. Smart Queue Scheduler checkbox got a hover tooltip that explains
   what enabling it actually does ("prefers shorter VODs and older
   queue entries first"). Users were disabling it without knowing
   what they were turning off.

DE + EN locale strings added for all three.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:14:13 +02:00
xRangerDE
83647c264b release: 4.5.15 trim button label matches dialog title
VOD-card button now reads "Trim VOD" / "VOD zuschneiden" to mirror
the dialog title exactly (was "Trim" / "Zuschneiden" in 4.5.14).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:59:24 +02:00
xRangerDE
13d208c30f ui: trim button now reads "Trim VOD" / "VOD zuschneiden"
Match the dialog title exactly. Fits on the button width and removes
the last "Trim" / "Trim VOD" inconsistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:59:23 +02:00
xRangerDE
075eb7b3b5 release: 4.5.14 rename VOD-card "Clip" button to Trim/Zuschneiden
Follow-up to 4.5.13: the button on each VOD card that opens the
"Trim VOD" dialog now reads "Trim" (EN) / "Zuschneiden" (DE) instead
of "Clip". Language switch now re-renders the VOD grid so labels
update live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:55:59 +02:00
xRangerDE
138c81eb8c ui: rename VOD card "Clip" button to Trim/Zuschneiden + live re-render
Follow-up to 4.5.13. The dialog title was renamed but the VOD-card
button that opens it still read "Clip", which kept the same
overloaded-with-Twitch-Clips ambiguity it was meant to fix.

- DE: "Zuschneiden", EN: "Trim" (kept short for the small card button;
  the dialog itself still reads "Trim VOD" / "VOD zuschneiden")
- buildVodCardHtml now uses UI_TEXT.vods.trimButton instead of a
  hardcoded "Clip"
- changeLanguage now also calls renderVodGridFromCurrentState +
  refreshVodSortSelectLabels so the button label and sort-select
  options update live on language switch (the existing addQueue label
  was suffering the same staleness)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:55:59 +02:00
xRangerDE
3c0af2765e release: 4.5.13 rename trim dialog to "Trim VOD" / "VOD zuschneiden"
User-visible: the modal opened by the per-VOD "Clip" button now reads
"Trim VOD" (EN) / "VOD zuschneiden" (DE) instead of "Trim clip" /
"Clip zuschneiden" — disambiguates from the separate Twitch Clips
feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:49:19 +02:00
xRangerDE
ddb3845263 ui: rename "Trim clip" dialog to "Trim VOD" / "VOD zuschneiden"
The dialog cuts a custom time-range out of a VOD; calling its result a
"clip" was overloaded with the separate Twitch Clips feature (which
this project handles in the dedicated Clips tab). Renaming the modal
title disambiguates without touching the per-VOD-card "Clip" button
(still the right verb for the action that opens it).

- EN: "Trim VOD"
- DE: "VOD zuschneiden"
- index.html static fallback updated to match the DE locale

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:49:18 +02:00
xRangerDE
37b793b9e8 release: 4.5.12 Parts-format preset for clip trimming
User-visible: Trim-Clip dialog now exposes the {date}_Part{part_padded}
naming pattern (e.g. 08.05.2026_Part07.mp4) as a radio preset, matching
the global VOD-parts template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:46:21 +02:00
xRangerDE
013e8be1f0 feat(clip): add Parts-format preset to Trim-Clip dialog
The Trim-Clip filename-format radio group only offered three presets
(simple, timestamp, custom template). Users who organise their archive
with the global filename_template_parts pattern (e.g.
08.05.2026_Part07.mp4) had to switch to "custom template" and retype
{date}_Part{part_padded}.mp4 every time.

New "parts" preset:
- index.html: 4th radio option, span#formatParts for the live preview
- types.ts + renderer-globals.d.ts: filenameFormat union extended
- main.ts: makeClipFilename branch produces ${dateStr}_Part${padded}.mp4;
  sanitizeCustomClip whitelists "parts" so persisted queue items with
  the new format survive a restart
- renderer.ts: getSelectedFilenameFormat returns "parts"; live preview
  via partNum.padStart(2, "0")
- DE/EN locales: clips.formatParts label

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:46:20 +02:00
xRangerDE
173ae61a3f release: 4.5.11 GQL retry, VOD sort, shutdown consolidation
- public-API fallback retries on transient HTTP/network errors so a
  single TCP RST does not produce an empty VOD list
- new VOD list sort dropdown with persisted key (newest, oldest,
  most viewed, longest, shortest)
- shutdownCleanup() consolidates window-all-closed and before-quit
  into one idempotent helper so future exit-time cleanup lands once

See docs/IMPROVEMENT_LOG.md (Cycle 4, 2026-05-03) for the dated
rationale and regression run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:55:03 +02:00
xRangerDE
832b606701 ui: VOD sort dropdown with persisted key + locale labels
Adds a sort selector next to the existing filter input. Five modes:
newest first (default), oldest first, most viewed, longest first,
shortest first. Concrete user pain — long archives previously had no
way to find the longest stream, the most-watched, or to scroll back
to the start chronologically.

- vodSortKey state persisted to localStorage as
  twitch-vod-manager:vod-sort and validated against an enum on load,
  so an unknown stored value falls back to date_desc
- renderVodGridFromCurrentState now applies sortVods before
  filterVodsByQuery so the filter sees the sort and the match counter
  is consistent
- sortVods uses created_at timestamps for date sorts, view_count for
  views, and a tiny vodDurationToSeconds parser (XhYmZs) for duration
- DE + EN labels for both the "Sort:" prefix and the five option
  texts; refreshVodSortSelectLabels re-runs on language switch
- syncVodSortSelect on init preselects the persisted value before
  any VOD load so the dropdown reflects state immediately

Browser-default keyboard nav (arrows, type-ahead) covers keyboard
access for the select.

docs/IMPROVEMENT_LOG.md: Cycle 4 dated section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:54:53 +02:00
xRangerDE
020f3dacf1 harden: GQL retry on transient errors + consolidate shutdown cleanup
Two server-side changes touching different paths.

1. fetchPublicTwitchGql now retries on transient HTTP (408/429/5xx) and
   network-layer failures (no response). Up to 3 attempts with
   exponential backoff + jitter (400ms * 2^(n-1)). The previous
   catch (e) { return null; } swallowed network blips on the public
   fallback path, which is what every user without a client_id hits
   on each VOD list load — a single TCP RST produced an empty list
   and the user had to click refresh. GraphQL errors[] are still
   returned without retry (application-level query rejections).
   Recovery is logged via appendDebugLog so we can later see whether
   the retries actually pay off in production.

2. shutdownCleanup() consolidates window-all-closed and before-quit.
   The two handlers ran nearly identical cleanup blocks but had
   drifted: only window-all-closed killed children and was
   platform-aware. The helper kills activeDownloads + activeClipProcesses
   + currentEditorProcess with try/catch, persists config + queue,
   then stops timers (debug-log flush moved AFTER persistence so any
   save error reaches the log before the timer is gone). An idempotent
   shutdownCleanupDone flag makes a follow-on event a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:54:40 +02:00
xRangerDE
81a1f914b4 release: 4.5.10 clip hardening, VOD filter, editor proc decoupling
- download-clip: sanitize broadcaster name + title, ensure unique
  filename, post-download size + integrity check, track in
  activeClipProcesses so window-close cleans up
- VOD list: persistent filter input with Ctrl+F focus, Esc clear,
  match counter (DE + EN strings)
- currentProcess split into currentEditorProcess (cutter/merger/
  splitter only) so cancel-download no longer accidentally kills a
  separate video cut

See docs/IMPROVEMENT_LOG.md (Cycle 3, 2026-05-03) for the dated
rationale and regression run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:43:30 +02:00
xRangerDE
23d0dd5829 ui: VOD list filter with persistence + Ctrl+F focus + Esc clear
Filter row above the VOD grid lets the user search the loaded archive
by title. Concrete user pain: streamers commonly have hundreds of VODs
and the current UI only supported scrolling.

- vodFilterInput / vodFilterClearBtn / vodFilterCount in index.html
- localized placeholder + clear-button title (DE + EN)
- vodFilterQuery state persisted to localStorage as
  twitch-vod-manager:vod-filter so the search bar survives reloads
- renderVODs split: it now caches lastLoadedVods + lastLoadedStreamer
  and delegates to renderVodGridFromCurrentState which applies
  filterVodsByQuery on every input event (no re-fetch)
- empty-state DOM is now built with createElement + textContent (via
  setVodGridEmptyState) instead of an innerHTML template, even for
  locale-only strings — defence in depth
- keyboard: Ctrl/Cmd+F focuses the filter when the VODs tab is active
  (Electron has no native find bar, so the default is suppressed). Esc
  clears the filter when the input has focus and content. Esc still
  closes modals first if any are open.

docs/IMPROVEMENT_LOG.md: Cycle 3 dated section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:43:16 +02:00
xRangerDE
31e6671e65 harden: download-clip integrity + cancel tracking + decouple editor procs
Two server-side fixes for separate clip/queue/editor crosstalk paths.

1. download-clip IPC was unsafe in three ways:
   - reported success: true on exit code 0 even with empty files
     (Twitch sometimes returns a manifest with no segments)
   - passed clipInfo.broadcaster_name straight to path.join, so unicode
     / spaces / punctuation in display names produced odd directory
     layouts on Windows
   - the spawned streamlink process was tracked nowhere, so window
     close orphaned it
   Now: sanitize broadcaster_name + title, ensureUniqueFilename so
   re-downloads do not overwrite, post-download size + integrity check
   (16 KiB floor + ffprobe via validateDownloadedFileIntegrity), proc
   tracked in activeClipProcesses and killed on window-all-closed.

2. currentProcess (a single ChildProcess global) was shared between
   cutter/merger/splitter and downloadVODPart. The real bug: while a
   queue download was running and the user kicked off a video cut,
   pressing the queue's "Stop" button iterated activeDownloads (fine)
   AND called currentProcess.kill() — which by then pointed at the
   cutter ffmpeg, killing an unrelated cut.
   Renamed to currentEditorProcess, confined to the editor pipeline.
   downloadVODPart no longer touches it. The fallback kill calls in
   remove-from-queue / pause-download / cancel-download are gone — the
   activeDownloads loop above each was already authoritative.
   window-all-closed now also kills activeClipProcesses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:43:01 +02:00
xRangerDE
9d57c03e74 release: 4.5.9 release pipeline + defensive parsing
- scripts/release_gitea.mjs: --skip-build flag and auto-skip when
  artifacts for the version already exist on disk
- playwright in devDependencies; test:e2e* scripts call node directly
- loadConfig isPlainObject shape check + loadQueue sanitizes every
  entry incl. customClip/mergeGroup; stale status="downloading" is
  demoted to "pending" so the queue can actually be resumed

See docs/IMPROVEMENT_LOG.md (Cycle 2, 2026-05-03) for the dated
rationale and regression run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:29:37 +02:00
xRangerDE
379048f191 harden: defensive parsing for config + queue, normalize stale downloading
- loadConfig now checks isPlainObject(parsed) before spreading over
  defaults. Non-object JSON (array, primitive, null) is logged and the
  app falls back to defaults instead of silently polluting the config
  with array indices or dropping values.

- loadQueue runs every entry through sanitizeQueueItem which validates
  the status enum, clamps progress to [0, 100], validates customClip
  and mergeGroup shapes (with sanitizeCustomClip / sanitizeMergeGroup
  helpers), and demotes stale status="downloading" entries to "pending"
  with progress=0 on cold start. The previous filter only checked
  typeof id/url/status === "string" and let through whatever shape
  customClip / mergeGroup happened to have.

- The stale-downloading normalisation fixes a real user trap: after a
  hard kill mid-download, the queue persisted status="downloading", but
  no download was running on next launch and start-download only resumed
  paused items, leaving "downloading" entries stuck.

- Bonus: CustomClip and MergeGroupItem imports now have call sites
  (previously unused-import warnings).

docs/IMPROVEMENT_LOG.md gains a Cycle 2 dated section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:29:28 +02:00
xRangerDE
b4faf67db7 infra: release_gitea.mjs --skip-build + playwright in devDeps
Two release-pipeline fixes that previously forced manual workarounds.

- scripts/release_gitea.mjs no longer unconditionally runs npm run dist:win.
  New --skip-build flag, plus auto-skip when all 3 required artifacts
  (Setup-<v>.exe, Setup-<v>.exe.blockmap, latest.yml) already exist for
  the requested version. The previous behaviour re-ran the entire test
  suite + electron-builder on every release attempt — unusable when the
  test path was broken.
- playwright ^1.59.1 added to devDependencies. test:e2e / test:e2e:guide
  / test:e2e:full now invoke node scripts/smoke-test*.js directly instead
  of "npm exec --yes --package=playwright -- node ...", which failed with
  MODULE_NOT_FOUND when npm exec could not resolve playwright on the fly.
  No browser binaries needed — the smoke tests drive Electron via
  _electron, not a browser.

All test paths verified after the change: test:e2e, test:e2e:guide,
test:e2e:full, test:merge-split, test:e2e:update-logic — all pass with
the simplified scripts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:29:17 +02:00
xRangerDE
707c98e19d release: 4.5.8 fsync atomic writes, parallel filename race, UI persistence
Stability + UX cycle.

- saveConfig and writeQueueToDisk now use openSync+writeSync+fsyncSync+
  closeSync+renameSync via writeFileAtomicSync. Survives power loss
  between write and rename (used to leave the renamed file empty and
  silently reset config / queue on next launch).
- Per-item claimedFilenames map fixes the parallel-download race where
  one item finishing wiped sibling claims and let a third item collide
  on the same output path.
- Renderer queue lookup by [data-id] (no more index drift), active tab
  persisted in localStorage, Escape closes the topmost open modal,
  Ctrl/Cmd+1..5 jumps tabs.

See docs/IMPROVEMENT_LOG.md for the dated rationale and regression run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:11:14 +02:00
xRangerDE
feebfc86a1 ui: data-id queue lookup + persisted active tab + Esc/Ctrl+N shortcuts
Renderer-side polish bundle.

- updateQueueItemProgress now looks up items by [data-id] selector instead
  of array index. Resilient against queue/DOM divergence between renders.
  Determinate vs indeterminate progress logic tightened.

- Active tab persisted to localStorage on every showTab; restored on init
  via loadPersistedActiveTab (whitelisted to known tab IDs so a future
  rename cannot strand the user on a missing tab). Page title now only
  shows the streamer name on the VODs tab — it no longer leaks into
  Settings / Cutter / Merge.

- Escape closes the topmost open modal regardless of focus (clip dialog,
  template guide, update modal — in that priority order).

- Ctrl+1..5 (Cmd+1..5 on macOS) jumps directly to a tab. The existing Del
  (delete selected) and S (start/pause) shortcuts still work and remain
  blocked while typing in inputs.

Adds docs/IMPROVEMENT_LOG.md (new, single dated section for this cycle).

Build: tsc clean. Full smoke suite green (failures: [], runtimeIssues: []).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:10:28 +02:00
xRangerDE
8d0cb4cefd harden: atomic fsync writes + per-item filename claims
Two server-side correctness fixes for parallel downloads and crash recovery.

1. Atomic file writes survive power loss / crash mid-write.
   saveConfig and writeQueueToDisk used writeFileSync + renameSync. Node's
   writeFileSync does NOT fsync — a power loss between write and rename can
   leave the renamed file empty or truncated, and the next launch silently
   falls back to defaults / empty queue.
   New writeFileAtomicSync helper: openSync + writeSync + fsyncSync +
   closeSync + renameSync (with the existing Windows copy fallback). fsync
   failure is non-fatal (some FS reject it) but file ordering is preserved.

2. Per-item claimed filenames fix the parallel-download race.
   With max 2 parallel downloads, processOneQueueItem.finally was calling
   claimedFilenames.clear() — wiping every parallel item's claims when any
   one finished. In the window between an active item claiming a filename
   and streamlink actually writing the first bytes, a third item could
   compute the same filename and both downloads would race the same path.
   New Map<itemId, Set<filename>> tracks claims per active download.
   ensureUniqueFilename(path, itemId) registers per-item;
   releaseClaimedFilenamesForItem(itemId) removes only that item's claims.
   splitMergedFile gained an itemId parameter for the same reason. The
   dead releaseClaimedFilename(path) function was removed.

Build: tsc clean. Tests: smoke + smoke-template-guide + smoke-full + merge-split
+ update-version-logic all pass. No new ESLint warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:10:15 +02:00
xRangerDE
54197af863 release: 4.5.7 fix notification app name
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 04:20:21 +02:00
xRangerDE
37d75fac24 fix: Windows notification shows 'Twitch VOD Manager' instead of 'electron.app.Electron'
Set app.setAppUserModelId('com.twitch.vodmanager') on startup so Windows
notifications display the correct app name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 04:18:34 +02:00
xRangerDE
f9a0fdcf3d release: 4.5.6 guard formatDuration against NaN/Infinity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 15:27:52 +01:00
xRangerDE
da1d14d458 fix: guard formatDuration against NaN/Infinity input
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 15:23:21 +01:00
xRangerDE
18940d0640 chore: add ESLint with security plugin, fix code quality warnings
- Install eslint, typescript-eslint, eslint-plugin-security
- Add eslint.config.mjs with project-tuned rules
- Fix redundant catch assignment in cutVideo
- Fix let→const for promise dedup patterns
- No security bugs found — all regex warnings are false positives (anchored patterns)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:55:35 +01:00
xRangerDE
d9bdf744fd release: 4.5.5 fix ETA showing video duration instead of actual remaining time
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:54:44 +01:00
xRangerDE
d8f0836165 fix: ETA calculation was using video duration instead of download progress
The old formula (avgSpeed * expectedDurationSeconds) simplified to just
(videoDuration - elapsedTime), showing 59min ETA for a 60min part after
1min of downloading. Now uses streamlink's actual progress percentage:
ETA = (elapsed / percent) * (100 - percent), which reflects real download
speed rather than video length.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:47:19 +01:00
xRangerDE
a07ec1f958 release: 4.5.4 hardening — clip validation, cutter check, pagination guard, atomic writes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:39:55 +01:00
xRangerDE
5f2e85e455 fix: clip time validation, cutter 0-byte check, pagination guard, atomic config write
H1: Add NaN/negative/zero-duration validation to clip dialog before IPC call
H2: Reject cut video output <= 256 bytes as effectively empty
H3: Add paginated VOD fetching with MAX_VOD_PAGES=50 safety guard
H4: Atomic write (tmp+rename) for config and queue persistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:33:31 +01:00
xRangerDE
39fa5065d2 release: 4.5.3 bugfixes — selector overflow, drag safety, filename claims, details persist, progress stability
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:20:11 +01:00
xRangerDE
2b379e5e6a fix: correct dragstart cancel method and release claimed filenames after download
- Use dataTransfer.effectAllowed='none' instead of preventDefault() for dragstart
  (preventDefault does not cancel dragstart events per HTML spec)
- Clear claimedFilenames Set in processOneQueueItem finally block to prevent
  stale claims from blocking re-downloads of same VODs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:19:30 +01:00
xRangerDE
cf9d7b8334 fix: selector overflow for 10+ items, drag-drop status guard, filename claim set for parallel safety
- Queue selector uses min-width instead of fixed width for double-digit numbers
- Drag-start handler validates item is still pending before allowing drag
- ensureUniqueFilename uses in-memory claim set to prevent TOCTOU race

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:15:25 +01:00
xRangerDE
6c47c63fa8 fix: clamp ETA bounds, store stats interval, add activeDownloads finally-cleanup, prevent progress backward jump
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:04:59 +01:00
xRangerDE
4607ba9cc6 fix: persist expanded details across re-renders, guard drag-drop init against duplicates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:03:20 +01:00
xRangerDE
1e81b889f9 release: 4.5.2 compact queue buttons
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:30:50 +01:00
xRangerDE
00d35f1b1c fix(ui): reduce queue action button height for compact layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:10:02 +01:00
xRangerDE
8132e062fa release: 4.5.1 fix numbered selector visual update
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:07:51 +01:00
xRangerDE
0133569104 fix: include selection state in queue render fingerprint so numbered selectors update visually
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:06:42 +01:00
xRangerDE
6b97039471 release: 4.5.0 ETA, shortcuts, stats, drag&drop, expandable items, light theme, parallel downloads, code split
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:02:32 +01:00
xRangerDE
66afaba0ea refactor: extract tool discovery functions to src/tools.ts
Move streamlink/ffmpeg path discovery, bundled tool management,
auto-install logic, and related caches (~430 lines) into a
dedicated tools module. main.ts uses dependency injection for
debug logging and directory paths to keep the module decoupled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:00:51 +01:00
xRangerDE
54d04d4f73 feat: support parallel downloads (up to 2 simultaneous)
Add parallel_downloads config option (1 or 2) with Settings UI dropdown.
Refactor processQueue to run concurrent download slots using Promise.race,
extracting per-item logic into processOneQueueItem. Add per-item process
tracking via activeDownloads Map and cancelledItemIds Set so cancel/pause
correctly terminates all active downloads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:54:20 +01:00
xRangerDE
63aafae85d feat: add light theme with toggle in settings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:46:26 +01:00
xRangerDE
424b312551 refactor: extract shared interfaces to src/types.ts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:44:37 +01:00
xRangerDE
fbcf3935d0 feat: add drag & drop queue reordering and expandable queue item details
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:42:35 +01:00
xRangerDE
2481230983 feat: add keyboard shortcuts (Del/S) and download statistics bar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:40:39 +01:00
xRangerDE
c96fd13aff feat: add ETA calculation for download progress display
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:39:27 +01:00
xRangerDE
6a32387add release: 4.4.0 performance optimizations, collision detection, notifications
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:28:42 +01:00
xRangerDE
3537d28eb2 test: add filename collision detection unit tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:28:06 +01:00
xRangerDE
a4ca410641 feat: filename collision detection, queue JSON validation, download-complete notification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:27:19 +01:00
xRangerDE
76ecbc652d perf: parallel init with Promise.all, targeted DOM updates for download progress
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:26:19 +01:00
xRangerDE
b7499c87a3 release: 4.3.4 fix queue buttons overflow
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:06:52 +01:00
xRangerDE
00cf7781d3 fix(ui): prevent queue buttons from being pushed off-screen by long queue
Queue section now uses flex layout with flex-shrink:0 on action buttons,
so they stay visible regardless of queue list length.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:06:10 +01:00
xRangerDE
46dc29a226 release: 4.3.3 numbered merge selection, user-defined order
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 08:57:26 +01:00
xRangerDE
0e6b219455 feat(merge-split): numbered selection instead of checkboxes, user-defined merge order
Replace checkboxes with numbered selectors (1, 2, 3...) that show the
merge order. Click order determines VOD sequence in the merged result.
Chronological auto-sort removed — user controls the order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 08:55:35 +01:00
26 changed files with 13852 additions and 1206 deletions

138
docs/IMPROVEMENT_LOG.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.3.2", "version": "4.6.155",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "xRangerDE", "author": "xRangerDE",
@ -9,9 +9,9 @@
"build": "tsc", "build": "tsc",
"start": "npm run build && electron .", "start": "npm run build && electron .",
"test:e2e:update-logic": "node scripts/smoke-test-update-version-logic.js", "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": "node scripts/smoke-test.js",
"test:e2e:guide": "npm exec --yes --package=playwright -- node scripts/smoke-test-template-guide.js", "test:e2e:guide": "node scripts/smoke-test-template-guide.js",
"test:e2e:full": "npm exec --yes --package=playwright -- node scripts/smoke-test-full.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: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", "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", "pack": "npm run build && electron-builder --dir",
@ -25,10 +25,15 @@
"electron-updater": "^6.1.0" "electron-updater": "^6.1.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"electron": "^28.0.0", "electron": "^28.0.0",
"electron-builder": "^24.9.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": { "build": {
"appId": "de.24-music.twitch-vod-manager", "appId": "de.24-music.twitch-vod-manager",

View File

@ -28,10 +28,13 @@ function parseArgs(argv) {
if (args.includes("--help") || args.includes("-h")) { if (args.includes("--help") || args.includes("-h")) {
return { help: true }; return { help: true };
} }
const FLAGS = new Set(["--dry-run", "--skip-build"]);
const dryRun = args.includes("--dry-run"); const dryRun = args.includes("--dry-run");
const version = args.find((arg) => arg !== "--dry-run") || ""; const skipBuild = args.includes("--skip-build");
const notes = args.filter((arg) => arg !== "--dry-run").slice(1).join(" ").trim(); const positional = args.filter((arg) => !FLAGS.has(arg));
return { help: false, dryRun, version, notes }; const version = positional[0] || "";
const notes = positional.slice(1).join(" ").trim();
return { help: false, dryRun, skipBuild, version, notes };
} }
function ensureVersion(version) { 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() { async function main() {
const args = parseArgs(process.argv); const args = parseArgs(process.argv);
if (args.help) { 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"); process.stdout.write("Env: GITEA_BASE_URL, GITEA_REPO_OWNER, GITEA_REPO_NAME, GITEA_TOKEN\n");
return; return;
} }
@ -141,7 +156,20 @@ async function main() {
run("git", ["push", "origin", tag]); 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"]); 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); const assets = ensureAssets(version);
if (args.dryRun) { if (args.dryRun) {
process.stdout.write(`Dry run complete for ${tag}\n`); process.stdout.write(`Dry run complete for ${tag}\n`);

View File

@ -105,6 +105,21 @@ function run() {
const iIndex = args.indexOf('-i'); const iIndex = args.indexOf('-i');
assert(ssIndex < iIndex, `FFmpeg args: -ss (${ssIndex}) must be before -i (${iIndex})`); assert(ssIndex < iIndex, `FFmpeg args: -ss (${ssIndex}) must be before -i (${iIndex})`);
// ---- Test 9: ensureUniqueFilename pattern ----
function ensureUnique(base, ext, existingFiles) {
let candidate = base + ext;
if (!existingFiles.includes(candidate)) return candidate;
let counter = 1;
while (existingFiles.includes(candidate)) {
candidate = `${base}_${counter}${ext}`;
counter++;
}
return candidate;
}
assert(ensureUnique('video', '.mp4', []) === 'video.mp4', 'Unique: no conflict');
assert(ensureUnique('video', '.mp4', ['video.mp4']) === 'video_1.mp4', 'Unique: one conflict');
assert(ensureUnique('video', '.mp4', ['video.mp4', 'video_1.mp4']) === 'video_2.mp4', 'Unique: two conflicts');
// ---- Results ---- // ---- Results ----
if (failures.length > 0) { if (failures.length > 0) {
console.error(`FAIL: ${failures.length} test(s) failed:`); console.error(`FAIL: ${failures.length} test(s) failed:`);

View File

@ -10,23 +10,23 @@
<body class="theme-twitch"> <body class="theme-twitch">
<div class="update-banner" id="updateBanner"> <div class="update-banner" id="updateBanner">
<span id="updateText">Neue Version verfügbar!</span> <span id="updateText">Neue Version verfügbar!</span>
<div id="updateProgress" style="display: none; flex: 1; margin: 0 15px;"> <div id="updateProgress" class="update-banner-progress-wrap is-hidden">
<div style="background: rgba(0,0,0,0.3); border-radius: 4px; height: 8px; overflow: 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" style="background: white; height: 100%; width: 0%; transition: width 0.3s;"></div> <div id="updateProgressBar" class="update-banner-progress-bar"></div>
</div> </div>
</div> </div>
<button id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button> <button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
</div> </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"> <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> <div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div>
<h2 id="updateModalTitle">Update verfugbar</h2> <h2 id="updateModalTitle">Update verfugbar</h2>
<p class="update-modal-message" id="updateModalMessage">Version 0.0.0 ist verfugbar. Jetzt herunterladen?</p> <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"> <div class="update-changelog-header">
<span class="update-changelog-label" id="updateChangelogLabel">Changelog</span> <span class="update-changelog-label" id="updateChangelogLabel">Changelog</span>
<button type="button" class="update-changelog-toggle" id="updateChangelogToggle" onclick="toggleUpdateChangelog()">Changelog anzeigen</button> <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"> <div class="modal-actions update-modal-actions">
<button class="btn-secondary" id="updateModalDismissBtn" type="button" onclick="dismissUpdateModal()">Nein</button> <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> <button class="btn-primary" id="updateModalConfirmBtn" type="button" onclick="confirmUpdateModal()">Ja, herunterladen</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Clip Dialog Modal --> <!-- Clip Dialog Modal -->
<div class="modal-overlay" id="clipModal"> <div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-labelledby="clipDialogTitle">
<div class="modal" style="background: #2b2b2b; max-width: 500px;"> <div class="modal clip-modal">
<button class="modal-close" onclick="closeClipDialog()">x</button> <button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeClipDialog()">x</button>
<h2 style="color: #E5A00D; text-align: center; margin-bottom: 20px;" id="clipDialogTitle">Clip zuschneiden</h2> <h2 class="clip-modal-title" id="clipDialogTitle">VOD zuschneiden</h2>
<!-- Start Zeit mit Slider --> <div class="clip-modal-field">
<div style="margin-bottom: 15px;"> <label class="clip-modal-label" id="clipDialogStartLabel" for="clipStartSlider">Start:</label>
<label style="display: block; margin-bottom: 5px;">Start:</label> <input type="range" id="clipStartSlider" min="0" max="100" value="0" oninput="updateFromSlider('start')">
<input type="range" id="clipStartSlider" min="0" max="100" value="0" <div class="clip-modal-time-row">
style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;" <label class="clip-modal-meta" id="clipDialogStartTimeLabel" for="clipStartTime">Startzeit (HH:MM:SS):</label>
oninput="updateFromSlider('start')"> <input type="text" id="clipStartTime" value="00:00:00" class="clip-modal-time-input" onchange="updateFromInput('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> </div>
</div> </div>
<!-- End Zeit mit Slider --> <div class="clip-modal-field">
<div style="margin-bottom: 15px;"> <label class="clip-modal-label" id="clipDialogEndLabel" for="clipEndSlider">Ende:</label>
<label style="display: block; margin-bottom: 5px;">Ende:</label> <input type="range" id="clipEndSlider" min="0" max="100" value="60" oninput="updateFromSlider('end')">
<input type="range" id="clipEndSlider" min="0" max="100" value="60" <div class="clip-modal-time-row">
style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;" <label class="clip-modal-meta" id="clipDialogEndTimeLabel" for="clipEndTime">Endzeit (HH:MM:SS):</label>
oninput="updateFromSlider('end')"> <input type="text" id="clipEndTime" value="00:01:00" class="clip-modal-time-input" onchange="updateFromInput('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> </div>
</div> </div>
<!-- Dauer Anzeige --> <div class="clip-modal-duration">
<div style="text-align: center; margin-bottom: 20px;"> <span id="clipDialogDurationLabel" class="clip-modal-meta">Dauer: </span>
<span style="color: #888;">Dauer: </span> <span id="clipDurationDisplay" class="clip-modal-duration-value">00:01:00</span>
<span id="clipDurationDisplay" style="color: #00c853;">00:01:00</span>
</div> </div>
<!-- Teil Nummer --> <div class="clip-modal-field">
<div style="margin-bottom: 15px;"> <label class="clip-modal-label" id="clipDialogPartLabel" for="clipStartPart">Start Part-Nummer (optional, fur Fortsetzung):</label>
<label style="display: block; margin-bottom: 8px;">Start Part-Nummer (optional, fur Fortsetzung):</label> <input type="text" id="clipStartPart" placeholder="z.B. 42" class="clip-modal-part-input" oninput="updateFilenameExamples()">
<input type="text" id="clipStartPart" placeholder="z.B. 42" <div id="clipDialogPartHint" class="clip-modal-hint">Leer lassen = Teil 1</div>
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> </div>
<!-- Dateinamen Format --> <div class="clip-modal-field">
<div style="margin-bottom: 20px;"> <label class="clip-modal-label" id="clipDialogFormatLabel">Dateinamen-Format:</label>
<label style="display: block; margin-bottom: 10px;">Dateinamen-Format:</label> <label class="clip-radio-row">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;"> <input type="radio" name="filenameFormat" value="simple" checked onchange="updateFilenameExamples()">
<input type="radio" name="filenameFormat" value="simple" checked onchange="updateFilenameExamples()" <span id="formatSimple" class="clip-radio-label">01.02.2026_1.mp4 (Standard)</span>
style="width: 18px; height: 18px; accent-color: #9146FF;">
<span id="formatSimple" style="color: #aaa;">01.02.2026_1.mp4 (Standard)</span>
</label> </label>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;"> <label class="clip-radio-row">
<input type="radio" name="filenameFormat" value="timestamp" onchange="updateFilenameExamples()" <input type="radio" name="filenameFormat" value="timestamp" onchange="updateFilenameExamples()">
style="width: 18px; height: 18px; accent-color: #9146FF;"> <span id="formatTimestamp" class="clip-radio-label">01.02.2026_CLIP_00-00-00_1.mp4 (mit Zeitstempel)</span>
<span id="formatTimestamp" style="color: #aaa;">01.02.2026_CLIP_00-00-00_1.mp4 (mit Zeitstempel)</span>
</label> </label>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 10px;"> <label class="clip-radio-row">
<input type="radio" name="filenameFormat" value="template" onchange="updateFilenameExamples()" <input type="radio" name="filenameFormat" value="parts" onchange="updateFilenameExamples()">
style="width: 18px; height: 18px; accent-color: #9146FF;"> <span id="formatParts" class="clip-radio-label">01.02.2026_Part01.mp4 (Parts-Format)</span>
<span id="formatTemplate" style="color: #aaa;">{date}_{part}.mp4 (benutzerdefiniert)</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> </label>
<div id="clipFilenameTemplateWrap" style="display:none; margin-top: 10px;"> <div id="clipFilenameTemplateWrap" class="clip-template-wrap">
<input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4" <input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4" placeholder="{date}_{part}.mp4" class="clip-modal-template-input" oninput="updateFilenameExamples()">
placeholder="{date}_{part}.mp4" <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>
style="width: 100%; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white; font-family: monospace;" <div id="clipTemplateLint" class="template-lint ok">Template-Check: OK</div>
oninput="updateFilenameExamples()"> <button type="button" class="btn-secondary" id="clipTemplateGuideBtn" onclick="openTemplateGuide('clip')">Template Guide</button>
<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> </div>
</div> </div>
<!-- Button --> <div class="clip-modal-actions">
<div style="text-align: center;"> <button type="button" class="btn-pill success" id="clipDialogConfirmBtn" style="padding: 12px 30px;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
<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> </div>
</div> </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 --> <!-- 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"> <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> <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> <p id="templateGuideIntro" class="template-guide-intro">Nutze Variablen fur Dateinamen und prufe das Ergebnis als Live-Vorschau.</p>
<div class="template-guide-actions"> <div class="template-guide-actions">
<button class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button> <button type="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 type="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="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button>
</div> </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"> <input type="text" id="templateGuideInput" class="template-guide-input" oninput="updateTemplateGuidePreview()" placeholder="{title}.mp4">
<div class="template-guide-preview-box"> <div class="template-guide-preview-box">
@ -154,12 +160,12 @@
<h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfugbare Variablen</h3> <h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfugbare Variablen</h3>
<div class="template-guide-table-wrap"> <div class="template-guide-table-wrap">
<table class="template-guide-table"> <table class="template-guide-table" aria-labelledby="templateGuideVarsTitle">
<thead> <thead>
<tr> <tr>
<th id="templateGuideVarCol">Variable</th> <th id="templateGuideVarCol" scope="col">Variable</th>
<th id="templateGuideDescCol">Beschreibung</th> <th id="templateGuideDescCol" scope="col">Beschreibung</th>
<th id="templateGuideExampleCol">Beispiel</th> <th id="templateGuideExampleCol" scope="col">Beispiel</th>
</tr> </tr>
</thead> </thead>
<tbody id="templateGuideBody"></tbody> <tbody id="templateGuideBody"></tbody>
@ -167,7 +173,7 @@
</div> </div>
<div class="template-guide-footer"> <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> </div>
</div> </div>
@ -175,34 +181,49 @@
<div class="app"> <div class="app">
<aside class="sidebar"> <aside class="sidebar">
<div class="logo"> <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> <span id="logoText">Twitch VOD Manager</span>
</div> </div>
<nav class="nav"> <nav class="nav">
<div class="nav-item active" data-tab="vods" onclick="showTab('vods')"> <div class="nav-item active" role="button" tabindex="0" aria-current="page" 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> <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> <span id="navVodsText">Twitch VODs</span>
</div> </div>
<div class="nav-item" data-tab="clips" onclick="showTab('clips')"> <div class="nav-item" role="button" tabindex="0" 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> <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> <span id="navClipsText">Twitch Clips</span>
</div> </div>
<div class="nav-item" data-tab="cutter" onclick="showTab('cutter')"> <div class="nav-item" role="button" tabindex="0" 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> <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> <span id="navCutterText">Video schneiden</span>
</div> </div>
<div class="nav-item" data-tab="merge" onclick="showTab('merge')"> <div class="nav-item" role="button" tabindex="0" 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> <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> <span id="navMergeText">Videos zusammenfugen</span>
</div> </div>
<div class="nav-item" data-tab="settings" onclick="showTab('settings')"> <div class="nav-item" role="button" tabindex="0" data-tab="stats" onclick="showTab('stats')">
<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> <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> <span id="navSettingsText">Einstellungen</span>
</div> </div>
</nav> </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="streamers" id="streamerList"></div>
<div class="queue-section"> <div class="queue-section">
@ -212,12 +233,13 @@
</div> </div>
<div class="queue-list" id="queueList"></div> <div class="queue-list" id="queueList"></div>
<div class="queue-actions"> <div class="queue-actions">
<button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button> <button type="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 &amp; Split</button> <button type="button" class="btn btn-merge-group is-hidden" id="btnMergeGroup" onclick="createMergeGroupFromSelection()">Merge &amp; Split</button>
<button class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button> <button type="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-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
</div> </div>
</div> </div>
<div class="stats-bar" id="statsBar"></div>
</aside> </aside>
<main class="main"> <main class="main">
@ -226,10 +248,10 @@
<div class="header-actions"> <div class="header-actions">
<div class="header-search"> <div class="header-search">
<input type="text" id="newStreamer" placeholder="Streamer hinzufugen..." onkeypress="if(event.key==='Enter')addStreamer()"> <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> </div>
<button class="btn-icon" onclick="refreshVODs()"> <button type="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> <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> <span id="refreshText">Aktualisieren</span>
</button> </button>
</div> </div>
@ -238,11 +260,37 @@
<div class="content"> <div class="content">
<!-- VODs Tab --> <!-- VODs Tab -->
<div class="tab-content active" id="vodsTab"> <div class="tab-content active" id="vodsTab">
<div 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="vod-grid" id="vodGrid">
<div class="empty-state"> <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> <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>Keine VODs</h3> <h3 id="vodGridEmptyTitle">Keine VODs</h3>
<p>Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p> <p id="vodGridEmptyText">Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
</div> </div>
</div> </div>
</div> </div>
@ -252,13 +300,13 @@
<div class="clip-input"> <div class="clip-input">
<h2 id="clipsHeading">Twitch Clip-Download</h2> <h2 id="clipsHeading">Twitch Clip-Download</h2>
<input type="text" id="clipUrl" placeholder="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/..."> <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> <button type="button" class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
<div class="clip-status" id="clipStatus"></div> <div class="clip-status" id="clipStatus" role="status" aria-live="polite"></div>
</div> </div>
<div class="settings-card" style="max-width: 600px; margin: 20px auto;"> <div class="settings-card centered">
<h3 id="clipsInfoTitle">Info</h3> <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: Unterstutzte Formate:
- https://clips.twitch.tv/ClipName - https://clips.twitch.tv/ClipName
- https://www.twitch.tv/streamer/clip/ClipName - https://www.twitch.tv/streamer/clip/ClipName
@ -275,37 +323,37 @@
<h3 id="cutterSelectTitle">Video auswahlen</h3> <h3 id="cutterSelectTitle">Video auswahlen</h3>
<div class="form-row"> <div class="form-row">
<input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt..."> <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> </div>
<div class="video-preview" id="cutterPreview"> <div class="video-preview" id="cutterPreview">
<div class="placeholder"> <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> <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 style="margin-top:10px">Video auswahlen um Vorschau zu sehen</p> <p>Video auswahlen um Vorschau zu sehen</p>
</div> </div>
</div> </div>
<div class="cutter-info" id="cutterInfo" style="display:none"> <div class="cutter-info" id="cutterInfo">
<div class="cutter-info-item"> <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> <span class="cutter-info-value" id="infoDuration">--:--:--</span>
</div> </div>
<div class="cutter-info-item"> <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> <span class="cutter-info-value" id="infoResolution">----x----</span>
</div> </div>
<div class="cutter-info-item"> <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> <span class="cutter-info-value" id="infoFps">--</span>
</div> </div>
<div class="cutter-info-item"> <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> <span class="cutter-info-value" id="infoSelection">--:--:--</span>
</div> </div>
</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" id="timeline" onclick="seekTimeline(event)">
<div class="timeline-selection" id="timelineSelection"></div> <div class="timeline-selection" id="timelineSelection"></div>
<div class="timeline-current" id="timelineCurrent"></div> <div class="timeline-current" id="timelineCurrent"></div>
@ -313,25 +361,25 @@
<div class="time-inputs"> <div class="time-inputs">
<div class="time-input-group"> <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()"> <input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()">
</div> </div>
<div class="time-input-group"> <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()"> <input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()">
</div> </div>
</div> </div>
</div> </div>
<div class="progress-container" id="cutProgress"> <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 class="progress-bar-fill" id="cutProgressBar"></div>
</div> </div>
<div class="progress-text" id="cutProgressText">0%</div> <div class="progress-text" id="cutProgressText">0%</div>
</div> </div>
<div class="cutter-actions"> <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> </div>
</div> </div>
@ -341,49 +389,115 @@
<div class="merge-container"> <div class="merge-container">
<div class="settings-card"> <div class="settings-card">
<h3 id="mergeTitle">Videos zusammenfugen</h3> <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. Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen.
Die Reihenfolge kann per Drag & Drop geandert werden. Die Reihenfolge kann per Drag & Drop geandert werden.
</p> </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>
<div class="file-list" id="mergeFileList"> <div class="file-list" id="mergeFileList">
<div class="empty-state" style="padding: 40px 20px;"> <div class="empty-state merge-empty-state">
<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> <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 style="margin-top:10px">Keine Videos ausgewahlt</p> <p id="mergeEmptyText">Keine Videos ausgewahlt</p>
</div> </div>
</div> </div>
<div class="progress-container" id="mergeProgress"> <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 class="progress-bar-fill" id="mergeProgressBar"></div>
</div> </div>
<div class="progress-text" id="mergeProgressText">0%</div> <div class="progress-text" id="mergeProgressText">0%</div>
</div> </div>
<div class="merge-actions"> <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> </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 --> <!-- Settings Tab -->
<div class="tab-content" id="settingsTab"> <div class="tab-content" id="settingsTab">
<div class="settings-card"> <div class="settings-card">
<h3 id="designTitle">Design</h3> <h3 id="designTitle">Design</h3>
<div class="form-group"> <div class="form-group">
<label id="themeLabel">Theme</label> <label id="themeLabel" for="themeSelect">Theme</label>
<select id="themeSelect" onchange="changeTheme(this.value)"> <select id="themeSelect" onchange="changeTheme(this.value)">
<option value="twitch">Twitch</option> <option value="twitch">Twitch</option>
<option value="discord">Discord</option> <option value="discord">Discord</option>
<option value="youtube">YouTube</option> <option value="youtube">YouTube</option>
<option value="apple">Apple</option> <option value="apple">Apple</option>
<option value="light" id="themeLightOption">Light</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="languageLabel">Sprache</label> <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"> <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 class="flag-icon flag-de" aria-hidden="true"></span>
<span id="languageDeText">Deutsch</span> <span id="languageDeText">Deutsch</span>
@ -402,40 +516,63 @@
<div class="settings-card"> <div class="settings-card">
<h3 id="apiTitle">Twitch API</h3> <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"> <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"> <input type="text" id="clientId" placeholder="Twitch Client ID">
</div> </div>
<div class="form-group"> <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"> <input type="password" id="clientSecret" placeholder="Twitch Client Secret">
</div> </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>
<div class="settings-card"> <div class="settings-card">
<h3 id="downloadSettingsTitle">Download-Einstellungen</h3> <h3 id="downloadSettingsTitle">Download-Einstellungen</h3>
<div class="form-group"> <div class="form-group">
<label id="storageLabel">Speicherort</label> <label id="storageLabel" for="downloadPath">Speicherort</label>
<div class="form-row"> <div class="form-row">
<input type="text" id="downloadPath" readonly> <input type="text" id="downloadPath" readonly>
<button class="btn-secondary" onclick="selectFolder()">Ordner</button> <button type="button" class="btn-secondary" onclick="selectFolder()">Ordner</button>
<button class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button> <button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="modeLabel">Download-Modus</label> <label id="modeLabel" for="downloadMode">Download-Modus</label>
<select id="downloadMode"> <select id="downloadMode">
<option value="full" id="modeFullText">Ganzes VOD</option> <option value="full" id="modeFullText">Ganzes VOD</option>
<option value="parts" id="modePartsText">In Teile splitten</option> <option value="parts" id="modePartsText">In Teile splitten</option>
</select> </select>
</div> </div>
<div class="form-group"> <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"> <input type="number" id="partMinutes" value="120" min="10" max="480">
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="performanceModeLabel">Performance-Profil</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="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"> <select id="performanceMode">
<option value="stability" id="performanceModeStability">Max Stabilitat</option> <option value="stability" id="performanceModeStability">Max Stabilitat</option>
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option> <option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
@ -443,26 +580,62 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label style="display:flex; align-items:center; gap:8px;"> <label class="toggle-row">
<input type="checkbox" id="smartSchedulerToggle" checked> <input type="checkbox" id="smartSchedulerToggle" checked>
<span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span> <span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span>
</label> </label>
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;"> <label class="toggle-row">
<input type="checkbox" id="duplicatePreventionToggle" checked> <input type="checkbox" id="duplicatePreventionToggle" checked>
<span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span> <span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span>
</label> </label>
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;"> <label class="toggle-row">
<input type="checkbox" id="persistQueueToggle" checked> <input type="checkbox" id="persistQueueToggle" checked>
<span id="persistQueueLabel">Queue zwischen App-Starts speichern</span> <span id="persistQueueLabel">Queue zwischen App-Starts speichern</span>
</label> </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>
<div class="form-group"> <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"> <input type="number" id="metadataCacheMinutes" value="10" min="1" max="120">
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="form-row" style="align-items:center; margin-bottom: 4px;"> <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> <button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
</div> </div>
<div class="form-row" style="gap: 8px; margin: 8px 0 6px;"> <div class="form-row" style="gap: 8px; margin: 8px 0 6px;">
@ -470,44 +643,45 @@
<button class="btn-secondary" id="templatePresetArchive" type="button" onclick="applyTemplatePreset('archive')">Preset: Archive</button> <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> <button class="btn-secondary" id="templatePresetClipper" type="button" onclick="applyTemplatePreset('clipper')">Preset: Clipper</button>
</div> </div>
<div style="display: grid; gap: 8px; margin-top: 8px;"> <div class="filename-template-grid">
<label id="vodTemplateLabel" style="font-size: 13px; color: var(--text-secondary);">VOD Template</label> <label id="vodTemplateLabel" for="vodFilenameTemplate">VOD Template</label>
<input type="text" id="vodFilenameTemplate" placeholder="{title}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()"> <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> <label id="partsTemplateLabel" for="partsFilenameTemplate">VOD Part Template</label>
<input type="text" id="partsFilenameTemplate" placeholder="{date}_Part{part_padded}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()"> <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> <label id="defaultClipTemplateLabel" for="defaultClipFilenameTemplate">Clip Template</label>
<input type="text" id="defaultClipFilenameTemplate" placeholder="{date}_{part}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()"> <input type="text" id="defaultClipFilenameTemplate" class="input-monospace" placeholder="{date}_{part}.mp4" oninput="validateFilenameTemplates()">
</div> </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="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" style="font-size: 12px; margin-top: 6px; color: #8bc34a;">Template-Check: OK</div> <div id="filenameTemplateLint" class="template-lint ok">Template-Check: OK</div>
</div> </div>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h3 id="updateTitle">Updates</h3> <h3 id="updateTitle">Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.13</p> <p id="versionInfo" class="card-intro">Version: v4.1.13</p>
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button> <button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<div class="form-row" style="align-items:center; justify-content:space-between; margin-bottom: 10px;"> <div class="form-row section-header">
<h3 id="preflightTitle" style="margin: 0;">System-Check</h3> <h3 id="preflightTitle">System-Check</h3>
<span class="health-badge unknown" id="healthBadge">System: Unbekannt</span> <span class="health-badge unknown" id="healthBadge">System: Unbekannt</span>
</div> </div>
<div class="form-row" style="margin-bottom: 10px;"> <div class="form-row" style="margin-bottom: 10px;">
<button class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button> <button type="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="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
</div> </div>
<pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre> <pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h3 id="debugLogTitle">Live Debug-Log</h3> <h3 id="debugLogTitle">Live Debug-Log</h3>
<div class="form-row" style="margin-bottom: 10px; align-items: center;"> <div class="form-row aligned">
<button class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button> <button type="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);"> <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)"> <input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)">
<span id="autoRefreshText">Auto-Refresh</span> <span id="autoRefreshText">Auto-Refresh</span>
</label> </label>
@ -515,12 +689,108 @@
<pre id="debugLogOutput" class="log-panel">Lade...</pre> <pre id="debugLogOutput" class="log-panel">Lade...</pre>
</div> </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 &amp; 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"> <div class="settings-card">
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3> <h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
<div class="form-row" style="margin-bottom: 10px; align-items: center;"> <div class="form-row aligned">
<button class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button> <button type="button" class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
<button class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button> <button type="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);"> <label class="inline-toggle">
<input type="checkbox" id="runtimeMetricsAutoRefresh" onchange="toggleRuntimeMetricsAutoRefresh(this.checked)"> <input type="checkbox" id="runtimeMetricsAutoRefresh" onchange="toggleRuntimeMetricsAutoRefresh(this.checked)">
<span id="runtimeMetricsAutoRefreshText">Auto-Refresh</span> <span id="runtimeMetricsAutoRefreshText">Auto-Refresh</span>
</label> </label>
@ -532,10 +802,11 @@
<div class="status-bar"> <div class="status-bar">
<div class="status-indicator"> <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> <span id="statusText">Nicht verbunden</span>
</div> </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> </div>
</main> </main>
</div> </div>
@ -548,6 +819,10 @@
<script src="../dist/renderer-streamers.js"></script> <script src="../dist/renderer-streamers.js"></script>
<script src="../dist/renderer-queue.js"></script> <script src="../dist/renderer-queue.js"></script>
<script src="../dist/renderer-updates.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> <script src="../dist/renderer.js"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@ -1,62 +1,7 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types';
// Types // Types
interface CustomClip {
startSec: number;
durationSec: number;
startPart: number;
filenameFormat: 'simple' | 'timestamp' | 'template';
filenameTemplate?: string;
}
interface MergeGroupItem {
url: string;
title: string;
date: string;
streamer: string;
duration_str: string;
}
interface MergeGroup {
items: MergeGroupItem[];
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
currentItemIndex: number;
downloadedFiles: Record<number, string>;
mergedFile?: string;
splitFiles?: string[];
totalDurationSec?: number;
}
interface QueueItem {
id: string;
title: string;
url: string;
date: string;
streamer: string;
duration_str: string;
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
progress: number;
currentPart?: number;
totalParts?: number;
speed?: string;
eta?: string;
customClip?: CustomClip;
mergeGroup?: MergeGroup;
}
interface DownloadProgress {
id: string;
progress: number;
speed: string;
speedBytesPerSec?: number;
eta: string;
status: string;
currentPart?: number;
totalParts?: number;
downloadedBytes?: number;
totalBytes?: number;
}
interface RuntimeMetricsSnapshot { interface RuntimeMetricsSnapshot {
cacheHits: number; cacheHits: number;
cacheMisses: number; cacheMisses: number;
@ -119,10 +64,12 @@ contextBridge.exposeInMainWorld('api', {
// Queue // Queue
getQueue: () => ipcRenderer.invoke('get-queue'), getQueue: () => ipcRenderer.invoke('get-queue'),
addToQueue: (item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => ipcRenderer.invoke('add-to-queue', item), 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), removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds), reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
clearCompleted: () => ipcRenderer.invoke('clear-completed'), clearCompleted: () => ipcRenderer.invoke('clear-completed'),
retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'), retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'),
retryQueueItem: (id: string) => ipcRenderer.invoke('retry-queue-item', id),
createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds), createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds),
// Download // Download
@ -138,6 +85,27 @@ contextBridge.exposeInMainWorld('api', {
selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'), selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'),
saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName), saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName),
openFolder: (path: string) => ipcRenderer.invoke('open-folder', path), 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 // Video Cutter
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath), getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
@ -160,6 +128,14 @@ contextBridge.exposeInMainWorld('api', {
getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'), getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'),
exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> => exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
ipcRenderer.invoke('export-runtime-metrics'), 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 // Events
onDownloadProgress: (callback: (progress: DownloadProgress) => void) => { onDownloadProgress: (callback: (progress: DownloadProgress) => void) => {

175
src/renderer-archive.ts Normal file
View 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;

View File

@ -15,6 +15,32 @@ interface AppConfig {
prevent_duplicate_downloads?: boolean; prevent_duplicate_downloads?: boolean;
persist_queue_on_restart?: boolean; persist_queue_on_restart?: boolean;
metadata_cache_minutes?: number; 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; [key: string]: unknown;
} }
@ -33,7 +59,7 @@ interface CustomClip {
startSec: number; startSec: number;
durationSec: number; durationSec: number;
startPart: number; startPart: number;
filenameFormat: 'simple' | 'timestamp' | 'template'; filenameFormat: 'simple' | 'timestamp' | 'template' | 'parts';
filenameTemplate?: string; filenameTemplate?: string;
} }
@ -74,6 +100,9 @@ interface QueueItem {
last_error?: string; last_error?: string;
customClip?: CustomClip; customClip?: CustomClip;
mergeGroup?: MergeGroup; mergeGroup?: MergeGroup;
outputFiles?: string[];
isLive?: boolean;
recordingHealth?: 'ok' | 'stale' | 'unknown';
} }
interface DownloadProgress { interface DownloadProgress {
@ -87,6 +116,7 @@ interface DownloadProgress {
totalParts?: number; totalParts?: number;
downloadedBytes?: number; downloadedBytes?: number;
totalBytes?: number; totalBytes?: number;
recordingHealth?: 'ok' | 'stale' | 'unknown';
} }
interface RuntimeMetricsSnapshot { interface RuntimeMetricsSnapshot {
@ -173,6 +203,116 @@ interface PreflightResult {
timestamp: string; 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 { interface ApiBridge {
getConfig(): Promise<AppConfig>; getConfig(): Promise<AppConfig>;
saveConfig(config: Partial<AppConfig>): Promise<AppConfig>; saveConfig(config: Partial<AppConfig>): Promise<AppConfig>;
@ -181,10 +321,12 @@ interface ApiBridge {
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>; getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
getQueue(): Promise<QueueItem[]>; getQueue(): Promise<QueueItem[]>;
addToQueue(item: Omit<QueueItem, 'id' | 'status' | 'progress'>): 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[]>; removeFromQueue(id: string): Promise<QueueItem[]>;
reorderQueue(orderIds: string[]): Promise<QueueItem[]>; reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
clearCompleted(): Promise<QueueItem[]>; clearCompleted(): Promise<QueueItem[]>;
retryFailedDownloads(): Promise<QueueItem[]>; retryFailedDownloads(): Promise<QueueItem[]>;
retryQueueItem(id: string): Promise<QueueItem[]>;
createMergeGroup(itemIds: string[]): Promise<QueueItem[]>; createMergeGroup(itemIds: string[]): Promise<QueueItem[]>;
startDownload(): Promise<boolean>; startDownload(): Promise<boolean>;
pauseDownload(): Promise<boolean>; pauseDownload(): Promise<boolean>;
@ -196,6 +338,34 @@ interface ApiBridge {
selectMultipleVideos(): Promise<string[] | null>; selectMultipleVideos(): Promise<string[] | null>;
saveVideoDialog(defaultName: string): Promise<string | null>; saveVideoDialog(defaultName: string): Promise<string | null>;
openFolder(path: string): Promise<void>; 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>; getVideoInfo(filePath: string): Promise<VideoInfo | null>;
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>; extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>; cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
@ -209,6 +379,10 @@ interface ApiBridge {
getDebugLog(lines: number): Promise<string>; getDebugLog(lines: number): Promise<string>;
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>; getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>; exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
resetDownloadedVodIds(): Promise<{ success: boolean; removedCount: number }>;
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; onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
onQueueUpdated(callback: (queue: QueueItem[]) => void): void; onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void; onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void;

View File

@ -26,6 +26,7 @@ const UI_TEXT_DE = {
mergeAdd: '+ Videos hinzufugen', mergeAdd: '+ Videos hinzufugen',
designTitle: 'Design', designTitle: 'Design',
themeLabel: 'Theme', themeLabel: 'Theme',
themeLight: 'Hell',
languageLabel: 'Sprache', languageLabel: 'Sprache',
languageDe: 'Deutsch', languageDe: 'Deutsch',
languageEn: 'Englisch', languageEn: 'Englisch',
@ -40,13 +41,148 @@ const UI_TEXT_DE = {
modeFull: 'Ganzes VOD', modeFull: 'Ganzes VOD',
modeParts: 'In Teile splitten', modeParts: 'In Teile splitten',
partMinutesLabel: 'Teil-Lange (Minuten)', partMinutesLabel: 'Teil-Lange (Minuten)',
parallelDownloadsLabel: 'Parallele Downloads',
parallelDownloads1: '1 (Standard)',
parallelDownloads2: '2 (Parallel)',
performanceModeLabel: 'Performance-Profil', performanceModeLabel: 'Performance-Profil',
performanceModeStability: 'Max Stabilitat', performanceModeStability: 'Max Stabilitat',
performanceModeBalanced: 'Ausgewogen', performanceModeBalanced: 'Ausgewogen',
performanceModeSpeed: 'Max Geschwindigkeit', performanceModeSpeed: 'Max Geschwindigkeit',
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren', 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', duplicatePreventionLabel: 'Duplikate in Queue verhindern',
persistQueueLabel: 'Queue zwischen App-Starts speichern', 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)', metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
filenameTemplatesTitle: 'Dateinamen-Templates', filenameTemplatesTitle: 'Dateinamen-Templates',
vodTemplateLabel: 'VOD-Template', vodTemplateLabel: 'VOD-Template',
@ -124,10 +260,15 @@ const UI_TEXT_DE = {
clips: 'Clips', clips: 'Clips',
cutter: 'Video schneiden', cutter: 'Video schneiden',
merge: 'Videos zusammenfugen', merge: 'Videos zusammenfugen',
stats: 'Statistik',
archive: 'Archiv',
settings: 'Einstellungen' settings: 'Einstellungen'
}, },
queue: { queue: {
empty: 'Keine Downloads in der Warteschlange', empty: 'Keine Downloads in der Warteschlange',
detailStreamer: 'Streamer:',
detailDuration: 'Dauer:',
detailDate: 'Datum:',
start: 'Start', start: 'Start',
stop: 'Pausieren', stop: 'Pausieren',
resume: 'Fortsetzen', resume: 'Fortsetzen',
@ -147,9 +288,87 @@ const UI_TEXT_DE = {
eta: 'Restzeit', eta: 'Restzeit',
part: 'Teil', part: 'Teil',
emptyAlert: 'Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.', 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: { vods: {
selectAriaLabel: 'VOD fuer Bulk-Aktion auswaehlen',
noneTitle: 'Keine VODs', noneTitle: 'Keine VODs',
noneText: 'Wahle einen Streamer aus der Liste.', noneText: 'Wahle einen Streamer aus der Liste.',
loading: 'Lade VODs...', loading: 'Lade VODs...',
@ -158,11 +377,53 @@ const UI_TEXT_DE = {
noResultsText: 'Dieser Streamer hat keine VODs.', noResultsText: 'Dieser Streamer hat keine VODs.',
untitled: 'Unbenanntes VOD', untitled: 'Unbenanntes VOD',
views: 'Aufrufe', 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: { 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!', invalidDuration: 'Ungultig!',
invalidTime: 'Ungueltige Zeitangaben',
endBeforeStart: 'Endzeit muss grosser als Startzeit sein!', endBeforeStart: 'Endzeit muss grosser als Startzeit sein!',
outOfRange: 'Zeit ausserhalb des VOD-Bereichs!', outOfRange: 'Zeit ausserhalb des VOD-Bereichs!',
enterUrl: 'Bitte URL eingeben', enterUrl: 'Bitte URL eingeben',
@ -174,26 +435,40 @@ const UI_TEXT_DE = {
unknownError: 'Unbekannter Fehler', unknownError: 'Unbekannter Fehler',
formatSimple: '(Standard)', formatSimple: '(Standard)',
formatTimestamp: '(mit Zeitstempel)', formatTimestamp: '(mit Zeitstempel)',
formatParts: '(Parts-Format)',
formatTemplate: '(benutzerdefiniert)', formatTemplate: '(benutzerdefiniert)',
templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.', templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.',
templatePlaceholder: '{date}_{part}.mp4', 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: { cutter: {
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?', videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
previewLoading: 'Lade Vorschau...', previewLoading: 'Lade Vorschau...',
previewUnavailable: 'Vorschau nicht verfugbar', previewUnavailable: 'Vorschau nicht verfugbar',
previewAlt: 'Vorschau',
cutting: 'Schneidet...', cutting: 'Schneidet...',
cut: 'Schneiden', cut: 'Schneiden',
cutSuccess: 'Video erfolgreich geschnitten!', 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: { merge: {
empty: 'Keine Videos ausgewahlt', empty: 'Keine Videos ausgewahlt',
merging: 'Zusammenfugen...', merging: 'Zusammenfugen...',
merge: 'Zusammenfugen', merge: 'Zusammenfugen',
success: 'Videos erfolgreich zusammengefugt!', 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: { mergeGroup: {
btn: 'Zusammenfugen & Splitten', btn: 'Zusammenfugen & Splitten',
@ -228,6 +503,7 @@ const UI_TEXT_DE = {
modalDismiss: 'Nein', modalDismiss: 'Nein',
modalDownloadConfirm: 'Ja, herunterladen', modalDownloadConfirm: 'Ja, herunterladen',
modalInstallConfirm: 'Ja, installieren', modalInstallConfirm: 'Ja, installieren',
modalSkipVersion: 'Diese Version ueberspringen',
changelogLabel: 'Changelog', changelogLabel: 'Changelog',
showChangelog: 'Changelog anzeigen', showChangelog: 'Changelog anzeigen',
hideChangelog: 'Changelog ausblenden', hideChangelog: 'Changelog ausblenden',

View File

@ -26,6 +26,7 @@ const UI_TEXT_EN = {
mergeAdd: '+ Add videos', mergeAdd: '+ Add videos',
designTitle: 'Design', designTitle: 'Design',
themeLabel: 'Theme', themeLabel: 'Theme',
themeLight: 'Light',
languageLabel: 'Language', languageLabel: 'Language',
languageDe: 'German', languageDe: 'German',
languageEn: 'English', languageEn: 'English',
@ -40,13 +41,148 @@ const UI_TEXT_EN = {
modeFull: 'Full VOD', modeFull: 'Full VOD',
modeParts: 'Split into parts', modeParts: 'Split into parts',
partMinutesLabel: 'Part Length (Minutes)', partMinutesLabel: 'Part Length (Minutes)',
parallelDownloadsLabel: 'Parallel Downloads',
parallelDownloads1: '1 (Default)',
parallelDownloads2: '2 (Parallel)',
performanceModeLabel: 'Performance Profile', performanceModeLabel: 'Performance Profile',
performanceModeStability: 'Max Stability', performanceModeStability: 'Max Stability',
performanceModeBalanced: 'Balanced', performanceModeBalanced: 'Balanced',
performanceModeSpeed: 'Max Speed', performanceModeSpeed: 'Max Speed',
smartSchedulerLabel: 'Enable smart queue scheduler', 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', duplicatePreventionLabel: 'Prevent duplicate queue entries',
persistQueueLabel: 'Keep queue between app restarts', 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)', metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
filenameTemplatesTitle: 'Filename Templates', filenameTemplatesTitle: 'Filename Templates',
vodTemplateLabel: 'VOD Template', vodTemplateLabel: 'VOD Template',
@ -124,10 +260,15 @@ const UI_TEXT_EN = {
clips: 'Clips', clips: 'Clips',
cutter: 'Video Cutter', cutter: 'Video Cutter',
merge: 'Merge Videos', merge: 'Merge Videos',
stats: 'Statistics',
archive: 'Archive',
settings: 'Settings' settings: 'Settings'
}, },
queue: { queue: {
empty: 'No downloads in queue', empty: 'No downloads in queue',
detailStreamer: 'Streamer:',
detailDuration: 'Duration:',
detailDate: 'Date:',
start: 'Start', start: 'Start',
stop: 'Pause', stop: 'Pause',
resume: 'Resume', resume: 'Resume',
@ -147,9 +288,87 @@ const UI_TEXT_EN = {
eta: 'ETA', eta: 'ETA',
part: 'Part', part: 'Part',
emptyAlert: 'Queue is empty. Add a VOD or clip first.', 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: { vods: {
selectAriaLabel: 'Select VOD for bulk action',
noneTitle: 'No VODs', noneTitle: 'No VODs',
noneText: 'Select a streamer from the list.', noneText: 'Select a streamer from the list.',
loading: 'Loading VODs...', loading: 'Loading VODs...',
@ -158,11 +377,53 @@ const UI_TEXT_EN = {
noResultsText: 'This streamer has no VODs.', noResultsText: 'This streamer has no VODs.',
untitled: 'Untitled VOD', untitled: 'Untitled VOD',
views: 'views', 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: { 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!', invalidDuration: 'Invalid!',
invalidTime: 'Invalid time values',
endBeforeStart: 'End time must be greater than start time!', endBeforeStart: 'End time must be greater than start time!',
outOfRange: 'Time is outside VOD range!', outOfRange: 'Time is outside VOD range!',
enterUrl: 'Please enter a URL', enterUrl: 'Please enter a URL',
@ -174,26 +435,40 @@ const UI_TEXT_EN = {
unknownError: 'Unknown error', unknownError: 'Unknown error',
formatSimple: '(default)', formatSimple: '(default)',
formatTimestamp: '(with timestamp)', formatTimestamp: '(with timestamp)',
formatParts: '(parts naming)',
formatTemplate: '(custom template)', formatTemplate: '(custom template)',
templateEmpty: 'Template cannot be empty in custom template mode.', templateEmpty: 'Template cannot be empty in custom template mode.',
templatePlaceholder: '{date}_{part}.mp4', 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: { cutter: {
videoInfoFailed: 'Could not read video info. Is FFprobe installed?', videoInfoFailed: 'Could not read video info. Is FFprobe installed?',
previewLoading: 'Loading preview...', previewLoading: 'Loading preview...',
previewUnavailable: 'Preview unavailable', previewUnavailable: 'Preview unavailable',
previewAlt: 'Preview',
cutting: 'Cutting...', cutting: 'Cutting...',
cut: 'Cut', cut: 'Cut',
cutSuccess: 'Video cut successfully!', 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: { merge: {
empty: 'No videos selected', empty: 'No videos selected',
merging: 'Merging...', merging: 'Merging...',
merge: 'Merge', merge: 'Merge',
success: 'Videos merged successfully!', 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: { mergeGroup: {
btn: 'Merge & Split', btn: 'Merge & Split',
@ -228,6 +503,7 @@ const UI_TEXT_EN = {
modalDismiss: 'No', modalDismiss: 'No',
modalDownloadConfirm: 'Yes, download', modalDownloadConfirm: 'Yes, download',
modalInstallConfirm: 'Yes, install', modalInstallConfirm: 'Yes, install',
modalSkipVersion: 'Skip this version',
changelogLabel: 'Changelog', changelogLabel: 'Changelog',
showChangelog: 'Show changelog', showChangelog: 'Show changelog',
hideChangelog: 'Hide changelog', hideChangelog: 'Hide changelog',

218
src/renderer-profile.ts Normal file
View 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;

View File

@ -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, '&quot;');
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, '&quot;');
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, '&quot;');
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 { function buildQueueFingerprint(url: string, streamer: string, date: string, customClip?: CustomClip): string {
const clipFingerprint = customClip const clipFingerprint = customClip
? [ ? [
@ -35,7 +105,7 @@ function getQueueRenderFingerprint(items: QueueItem[]): string {
item.mergeGroup?.mergePhase || '' item.mergeGroup?.mergePhase || ''
].join(':')); ].join(':'));
return `${lang}|${pieces.join('|')}`; return `${lang}|${selectedQueueIds.join(',')}|${[...expandedQueueIds].join(',')}|${pieces.join('|')}`;
} }
function hasActiveQueueDuplicate(url: string, streamer: string, date: string, customClip?: CustomClip): boolean { function hasActiveQueueDuplicate(url: string, streamer: string, date: string, customClip?: CustomClip): boolean {
@ -80,6 +150,145 @@ async function retryFailedDownloads(): Promise<void> {
renderQueue(); 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 { function getQueueStatusLabel(item: QueueItem): string {
if (item.status === 'completed') return UI_TEXT.queue.statusDone; if (item.status === 'completed') return UI_TEXT.queue.statusDone;
if (item.status === 'error') return UI_TEXT.queue.statusFailed; if (item.status === 'error') return UI_TEXT.queue.statusFailed;
@ -140,10 +349,11 @@ function getQueueMetaText(item: QueueItem): string {
} }
function toggleQueueSelection(id: string): void { function toggleQueueSelection(id: string): void {
if (selectedQueueIds.has(id)) { const index = selectedQueueIds.indexOf(id);
selectedQueueIds.delete(id); if (index >= 0) {
selectedQueueIds.splice(index, 1);
} else { } else {
selectedQueueIds.add(id); selectedQueueIds.push(id);
} }
renderQueue(); renderQueue();
updateMergeGroupButton(); updateMergeGroupButton();
@ -157,27 +367,115 @@ function updateMergeGroupButton(): void {
const validIds = new Set( const validIds = new Set(
queue.filter(item => item.status === 'pending' && !item.mergeGroup).map(item => item.id) queue.filter(item => item.status === 'pending' && !item.mergeGroup).map(item => item.id)
); );
selectedQueueIds = new Set([...selectedQueueIds].filter(id => validIds.has(id))); selectedQueueIds = selectedQueueIds.filter(id => validIds.has(id));
if (selectedQueueIds.size >= 2) { if (selectedQueueIds.length >= 2) {
btn.style.display = ''; btn.classList.remove('is-hidden');
btn.textContent = `${UI_TEXT.mergeGroup.btn} (${selectedQueueIds.size})`; btn.textContent = `${UI_TEXT.mergeGroup.btn} (${selectedQueueIds.length})`;
btn.disabled = false; btn.disabled = false;
} else { } else {
btn.style.display = 'none'; btn.classList.add('is-hidden');
} }
} }
async function createMergeGroupFromSelection(): Promise<void> { async function createMergeGroupFromSelection(): Promise<void> {
if (selectedQueueIds.size < 2) return; if (selectedQueueIds.length < 2) return;
const ids = [...selectedQueueIds]; const ids = [...selectedQueueIds];
selectedQueueIds.clear(); selectedQueueIds = [];
queue = await window.api.createMergeGroup(ids); queue = await window.api.createMergeGroup(ids);
renderQueue(); renderQueue();
updateMergeGroupButton(); updateMergeGroupButton();
} }
function updateQueueItemProgress(progress: DownloadProgress): void {
// 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 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 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${isDeterminate ? '' : ' indeterminate'}`;
if (wrap) wrap.setAttribute('aria-valuenow', String(Math.round(pct)));
}
if (text) text.textContent = getQueueProgressText(item);
if (meta) meta.textContent = getQueueMetaText(item);
}
function toggleQueueDetails(id: string): void {
if (expandedQueueIds.has(id)) {
expandedQueueIds.delete(id);
} else {
expandedQueueIds.add(id);
}
renderQueue();
}
function initQueueDragDrop(): void {
if (queueDragDropInitialized) return;
queueDragDropInitialized = true;
const list = byId('queueList');
list.addEventListener('dragstart', (e: DragEvent) => {
const el = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
if (!el) return;
// Prevent dragging items that are no longer pending (race window between status change and re-render)
const itemId = el.dataset.id;
if (itemId) {
const item = queue.find(i => i.id === itemId);
if (!item || item.status !== 'pending') {
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'none';
e.dataTransfer.clearData();
}
return;
}
}
draggedQueueItemId = el.dataset.id || null;
el.classList.add('dragging');
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
});
list.addEventListener('dragover', (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
});
list.addEventListener('drop', (e: DragEvent) => {
e.preventDefault();
const target = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
if (!target || !draggedQueueItemId) return;
const targetId = target.dataset.id;
if (!targetId || targetId === draggedQueueItemId) return;
const fromIdx = queue.findIndex(i => i.id === draggedQueueItemId);
const toIdx = queue.findIndex(i => i.id === targetId);
if (fromIdx < 0 || toIdx < 0) return;
const [moved] = queue.splice(fromIdx, 1);
queue.splice(toIdx, 0, moved);
window.api.reorderQueue(queue.map(i => i.id));
renderQueue();
});
list.addEventListener('dragend', () => {
draggedQueueItemId = null;
document.querySelectorAll('.queue-item.dragging').forEach(el => el.classList.remove('dragging'));
});
}
function renderQueue(): void { function renderQueue(): void {
if (!Array.isArray(queue)) { if (!Array.isArray(queue)) {
queue = []; queue = [];
@ -196,7 +494,15 @@ function renderQueue(): void {
if (queue.length === 0) { if (queue.length === 0) {
lastQueueRenderFingerprint = renderFingerprint; 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; return;
} }
@ -213,39 +519,55 @@ function renderQueue(): void {
const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : ''; const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : '';
const isMergeGroup = !!item.mergeGroup; const isMergeGroup = !!item.mergeGroup;
const showCheckbox = item.status === 'pending' && !isMergeGroup; const showSelector = item.status === 'pending' && !isMergeGroup && !item.isLive;
const isChecked = selectedQueueIds.has(item.id); const selectionIndex = selectedQueueIds.indexOf(item.id);
const isSelected = selectionIndex >= 0;
const mergeIcon = isMergeGroup 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 const mergeMetaExtra = isMergeGroup
? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})` ? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})`
: ''; : '';
return ` return `
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}"> <div class="queue-item${isMergeGroup ? ' merge-group' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${item.id}">
${showCheckbox ${showSelector
? `<input type="checkbox" class="queue-checkbox" ${isChecked ? 'checked' : ''} onchange="toggleQueueSelection('${item.id}')" />` ? `<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="status ${item.status}"></div>
<div class="queue-main"> <div class="queue-main">
<div class="queue-title-row"> <div class="queue-title-row">
<div class="title" title="${safeTitle}">${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 class="queue-status-label">${safeStatusLabel}</div>
</div> </div>
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</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 class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
</div> </div>
<div class="queue-progress-text">${safeProgressText}</div> <div class="queue-progress-text">${safeProgressText}</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> </div>
${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}')">&#x21bb;</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> </div>
`; `;
}).join(''); }).join('');
updateMergeGroupButton(); updateMergeGroupButton();
initQueueContextMenu();
lastQueueRenderFingerprint = renderFingerprint; lastQueueRenderFingerprint = renderFingerprint;
} }

View File

@ -49,12 +49,12 @@ function validateFilenameTemplates(showAlert = false): boolean {
const lintNode = byId('filenameTemplateLint'); const lintNode = byId('filenameTemplateLint');
if (!uniqueUnknown.length) { if (!uniqueUnknown.length) {
lintNode.style.color = '#8bc34a'; lintNode.className = 'template-lint ok';
lintNode.textContent = UI_TEXT.static.templateLintOk; lintNode.textContent = UI_TEXT.static.templateLintOk;
return true; return true;
} }
lintNode.style.color = '#ff8a80'; lintNode.className = 'template-lint warn';
lintNode.textContent = `${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`; lintNode.textContent = `${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`;
if (showAlert) { if (showAlert) {
@ -88,6 +88,11 @@ function applyTemplatePreset(preset: string): void {
byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts; byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts;
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip; byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip;
validateFilenameTemplates(); 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> { async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
@ -162,6 +167,7 @@ function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
} }
void refreshRuntimeMetrics(false); void refreshRuntimeMetrics(false);
void refreshAutomationStatusLine();
}, 2000); }, 2000);
} }
} }
@ -185,16 +191,22 @@ function changeLanguage(lang: string): void {
renderQueue(); renderQueue();
renderStreamers(); 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 activeTabId = document.querySelector('.tab-content.active')?.id || 'vodsTab';
const activeTab = activeTabId.replace('Tab', ''); const activeTab = activeTabId.replace('Tab', '');
if (activeTab === 'vods' && currentStreamer) { const titleText = (activeTab === 'vods' && currentStreamer)
byId('pageTitle').textContent = currentStreamer; ? currentStreamer
} else { : ((UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName);
byId('pageTitle').textContent = (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 refreshRuntimeMetrics();
void refreshAutomationStatusLine();
validateFilenameTemplates(); 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> { async function refreshDebugLog(): Promise<void> {
const text = await window.api.getDebugLog(250); const text = await window.api.getDebugLog(250);
const panel = byId('debugLogOutput'); const panel = byId('debugLogOutput');
@ -315,10 +542,32 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
return { return {
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full', download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120, part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120,
parallel_downloads: parseInt(byId<HTMLSelectElement>('parallelDownloads').value, 10) || 1,
performance_mode: byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed', performance_mode: byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed',
smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked, smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked,
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked, prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked,
persist_queue_on_restart: byId<HTMLInputElement>('persistQueueToggle').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 metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
}; };
} }
@ -356,10 +605,32 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
effective.client_secret ?? '', effective.client_secret ?? '',
effective.download_mode ?? 'full', effective.download_mode ?? 'full',
effective.part_minutes ?? 120, effective.part_minutes ?? 120,
effective.parallel_downloads ?? 1,
effective.performance_mode ?? 'balanced', effective.performance_mode ?? 'balanced',
effective.smart_queue_scheduler !== false, effective.smart_queue_scheduler !== false,
effective.prevent_duplicate_downloads !== false, effective.prevent_duplicate_downloads !== false,
effective.persist_queue_on_restart !== 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.metadata_cache_minutes ?? 10,
effective.filename_template_vod ?? '{title}.mp4', effective.filename_template_vod ?? '{title}.mp4',
effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4', effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4',
@ -372,10 +643,32 @@ function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? ''; byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full'; byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120); byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120);
byId<HTMLSelectElement>('parallelDownloads').value = String((config.parallel_downloads as number) || 1);
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced'; byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false; byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false;
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads 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>('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>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4'; 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'; byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
@ -482,10 +775,24 @@ function initSettingsAutoSave(): void {
const immediateSaveIds = [ const immediateSaveIds = [
'downloadMode', 'downloadMode',
'parallelDownloads',
'performanceMode', 'performanceMode',
'smartSchedulerToggle', 'smartSchedulerToggle',
'duplicatePreventionToggle', 'duplicatePreventionToggle',
'persistQueueToggle' 'persistQueueToggle',
'autoResumeQueueToggle',
'notifyEachCompletionToggle',
'streamlinkDisableAdsToggle',
'downloadChatReplayToggle',
'captureLiveChatToggle',
'logStreamEventsToggle',
'discordNotifyLiveStartToggle',
'discordNotifyLiveEndToggle',
'discordNotifyVodCompleteToggle',
'autoCleanupEnabledToggle',
'autoCleanupTarget',
'autoCleanupAction',
'streamlinkQuality'
] as const; ] as const;
const debouncedSaveIds = [ const debouncedSaveIds = [
@ -493,7 +800,9 @@ function initSettingsAutoSave(): void {
'metadataCacheMinutes', 'metadataCacheMinutes',
'vodFilenameTemplate', 'vodFilenameTemplate',
'partsFilenameTemplate', 'partsFilenameTemplate',
'defaultClipFilenameTemplate' 'defaultClipFilenameTemplate',
'discordWebhookUrl',
'autoCleanupDays'
] as const; ] as const;
const credentialIds = [ const credentialIds = [
@ -569,6 +878,18 @@ async function selectFolder(): Promise<void> {
byId<HTMLInputElement>('downloadPath').value = folder; byId<HTMLInputElement>('downloadPath').value = folder;
config = await window.api.saveConfig({ download_path: 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 { function openFolder(): void {
@ -582,5 +903,82 @@ function openFolder(): void {
function changeTheme(theme: string): void { function changeTheme(theme: string): void {
document.body.className = `theme-${theme}`; document.body.className = `theme-${theme}`;
config.theme = theme;
void window.api.saveConfig({ 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;

View File

@ -10,8 +10,9 @@ function queryAll<T = any>(selector: string): T[] {
return Array.from(document.querySelectorAll(selector)) as T[]; return Array.from(document.querySelectorAll(selector)) as T[];
} }
function escapeHtml(value: string): string { function escapeHtml(value: string | number | null | undefined): string {
return value if (value == null) return '';
return String(value)
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
@ -19,12 +20,53 @@ function escapeHtml(value: string): string {
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
/* 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 config: AppConfig = {};
let currentStreamer: string | null = null; let currentStreamer: string | null = null;
let isConnected = false; let isConnected = false;
let downloading = false; let downloading = false;
let queue: QueueItem[] = []; let queue: QueueItem[] = [];
let selectedQueueIds: Set<string> = new Set(); let selectedQueueIds: string[] = [];
let expandedQueueIds: Set<string> = new Set();
let queueDragDropInitialized = false;
let cutterFile: string | null = null; let cutterFile: string | null = null;
let cutterVideoInfo: VideoInfo | null = null; let cutterVideoInfo: VideoInfo | null = null;

157
src/renderer-stats.ts Normal file
View 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">&middot;</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">&middot;</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

View File

@ -26,6 +26,12 @@ function setText(id: string, value: string): void {
if (node) node.textContent = value; 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 { function setPlaceholder(id: string, value: string): void {
const node = document.getElementById(id) as HTMLInputElement | null; const node = document.getElementById(id) as HTMLInputElement | null;
if (node) node.placeholder = value; if (node) node.placeholder = value;
@ -36,6 +42,11 @@ function setTitle(id: string, value: string): void {
if (node) node.setAttribute('title', value); 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 { function setLanguage(lang: string): LanguageCode {
currentLanguage = lang === 'en' ? 'en' : 'de'; currentLanguage = lang === 'en' ? 'en' : 'de';
UI_TEXT = UI_TEXTS[currentLanguage]; UI_TEXT = UI_TEXTS[currentLanguage];
@ -49,7 +60,39 @@ function applyLanguageToStaticUI(): void {
setText('navClipsText', UI_TEXT.static.navClips); setText('navClipsText', UI_TEXT.static.navClips);
setText('navCutterText', UI_TEXT.static.navCutter); setText('navCutterText', UI_TEXT.static.navCutter);
setText('navMergeText', UI_TEXT.static.navMerge); 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('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('queueTitleText', UI_TEXT.static.queueTitle);
setText('healthBadge', UI_TEXT.static.healthUnknown); setText('healthBadge', UI_TEXT.static.healthUnknown);
setText('btnRetryFailed', UI_TEXT.static.retryFailed); setText('btnRetryFailed', UI_TEXT.static.retryFailed);
@ -61,17 +104,40 @@ function applyLanguageToStaticUI(): void {
setText('clipsInfoText', UI_TEXT.static.clipsInfoText); setText('clipsInfoText', UI_TEXT.static.clipsInfoText);
setText('clipTemplateHelp', UI_TEXT.clips.templateHelp); setText('clipTemplateHelp', UI_TEXT.clips.templateHelp);
setPlaceholder('clipFilenameTemplate', UI_TEXT.clips.templatePlaceholder); 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('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse); 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('mergeTitle', UI_TEXT.static.mergeTitle);
setText('mergeDesc', UI_TEXT.static.mergeDesc); setText('mergeDesc', UI_TEXT.static.mergeDesc);
setText('mergeAddBtn', UI_TEXT.static.mergeAdd); setText('mergeAddBtn', UI_TEXT.static.mergeAdd);
setText('btnMerge', UI_TEXT.merge.merge);
setText('designTitle', UI_TEXT.static.designTitle); setText('designTitle', UI_TEXT.static.designTitle);
setText('themeLabel', UI_TEXT.static.themeLabel); setText('themeLabel', UI_TEXT.static.themeLabel);
setText('themeLightOption', UI_TEXT.static.themeLight);
setText('languageLabel', UI_TEXT.static.languageLabel); setText('languageLabel', UI_TEXT.static.languageLabel);
setText('languageDeText', UI_TEXT.static.languageDe); setText('languageDeText', UI_TEXT.static.languageDe);
setText('languageEnText', UI_TEXT.static.languageEn); setText('languageEnText', UI_TEXT.static.languageEn);
setText('apiTitle', UI_TEXT.static.apiTitle); 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('clientIdLabel', UI_TEXT.static.clientIdLabel);
setText('clientSecretLabel', UI_TEXT.static.clientSecretLabel); setText('clientSecretLabel', UI_TEXT.static.clientSecretLabel);
setText('saveSettingsBtn', UI_TEXT.static.saveSettings); setText('saveSettingsBtn', UI_TEXT.static.saveSettings);
@ -82,13 +148,49 @@ function applyLanguageToStaticUI(): void {
setText('modeFullText', UI_TEXT.static.modeFull); setText('modeFullText', UI_TEXT.static.modeFull);
setText('modePartsText', UI_TEXT.static.modeParts); setText('modePartsText', UI_TEXT.static.modeParts);
setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel); setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel);
setText('parallelDownloadsLabel', UI_TEXT.static.parallelDownloadsLabel);
setText('parallelDownloads1', UI_TEXT.static.parallelDownloads1);
setText('parallelDownloads2', UI_TEXT.static.parallelDownloads2);
setText('performanceModeLabel', UI_TEXT.static.performanceModeLabel); setText('performanceModeLabel', UI_TEXT.static.performanceModeLabel);
setText('performanceModeStability', UI_TEXT.static.performanceModeStability); setText('performanceModeStability', UI_TEXT.static.performanceModeStability);
setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced); setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced);
setText('performanceModeSpeed', UI_TEXT.static.performanceModeSpeed); setText('performanceModeSpeed', UI_TEXT.static.performanceModeSpeed);
setText('smartSchedulerLabel', UI_TEXT.static.smartSchedulerLabel); 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('duplicatePreventionLabel', UI_TEXT.static.duplicatePreventionLabel);
setText('persistQueueLabel', UI_TEXT.static.persistQueueLabel); 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('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle); setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel); setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);
@ -123,6 +225,59 @@ function applyLanguageToStaticUI(): void {
setText('preflightResult', UI_TEXT.static.preflightEmpty); setText('preflightResult', UI_TEXT.static.preflightEmpty);
setText('debugLogTitle', UI_TEXT.static.debugLogTitle); setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
setText('btnRefreshLog', UI_TEXT.static.refreshLog); 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('autoRefreshText', UI_TEXT.static.autoRefresh);
setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle); setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle);
setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh); setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh);
@ -135,10 +290,30 @@ function applyLanguageToStaticUI(): void {
setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle); setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle);
setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss); setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss);
setText('updateModalConfirmBtn', UI_TEXT.updates.modalDownloadConfirm); setText('updateModalConfirmBtn', UI_TEXT.updates.modalDownloadConfirm);
setText('updateModalSkipBtn', UI_TEXT.updates.modalSkipVersion);
setText('updateChangelogLabel', UI_TEXT.updates.changelogLabel); setText('updateChangelogLabel', UI_TEXT.updates.changelogLabel);
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog); setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog); setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder); 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() || ''; const status = document.getElementById('statusText')?.textContent?.trim() || '';
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) { if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {

View File

@ -9,6 +9,20 @@ let updateBannerState: 'idle' | 'available' | 'downloading' | 'ready' = 'idle';
let updateChangelogExpanded = false; let updateChangelogExpanded = false;
let shouldOpenUpdateModalOnAvailable = 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 { function notifyUpdate(message: string, type: 'info' | 'warn' = 'info'): void {
const toastFn = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; const toastFn = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (typeof toastFn === 'function') { if (typeof toastFn === 'function') {
@ -74,11 +88,11 @@ function setCheckButtonCheckingState(enabled: boolean): void {
} }
function showUpdateBanner(): void { function showUpdateBanner(): void {
byId('updateBanner').style.display = 'flex'; byId('updateBanner').classList.add('show');
} }
function hideUpdateBanner(): void { function hideUpdateBanner(): void {
byId('updateBanner').style.display = 'none'; byId('updateBanner').classList.remove('show');
} }
function setUpdateBannerAvailableUi(info: UpdateInfo): void { function setUpdateBannerAvailableUi(info: UpdateInfo): void {
@ -89,7 +103,7 @@ function setUpdateBannerAvailableUi(info: UpdateInfo): void {
updateBannerState = 'available'; updateBannerState = 'available';
showUpdateBanner(); showUpdateBanner();
byId('updateProgress').style.display = 'none'; byId('updateProgress').classList.add('is-hidden');
const bar = byId('updateProgressBar'); const bar = byId('updateProgressBar');
bar.classList.remove('downloading'); bar.classList.remove('downloading');
@ -109,11 +123,13 @@ function setDownloadPendingUi(): void {
const button = byId<HTMLButtonElement>('updateButton'); const button = byId<HTMLButtonElement>('updateButton');
button.textContent = UI_TEXT.updates.downloading; button.textContent = UI_TEXT.updates.downloading;
button.disabled = true; button.disabled = true;
byId('updateProgress').style.display = 'block'; byId('updateProgress').classList.remove('is-hidden');
const bar = byId('updateProgressBar'); const bar = byId('updateProgressBar');
bar.classList.add('downloading'); 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) { if (!latestDownloadProgress) {
byId('updateText').textContent = `Version ${latestUpdateVersion || '?'} ${UI_TEXT.updates.downloading}`; byId('updateText').textContent = `Version ${latestUpdateVersion || '?'} ${UI_TEXT.updates.downloading}`;
@ -131,8 +147,9 @@ function setDownloadReadyUi(info?: UpdateInfo): void {
const bar = byId('updateProgressBar'); const bar = byId('updateProgressBar');
bar.classList.remove('downloading'); bar.classList.remove('downloading');
bar.style.width = '100%'; 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}`; byId('updateText').textContent = `Version ${activeInfo.version} ${UI_TEXT.updates.ready}`;
const button = byId<HTMLButtonElement>('updateButton'); const button = byId<HTMLButtonElement>('updateButton');
button.textContent = UI_TEXT.updates.installNow; button.textContent = UI_TEXT.updates.installNow;
@ -170,13 +187,13 @@ function renderUpdateChangelog(notes?: string): void {
empty.hidden = true; empty.hidden = true;
if (!normalized) { if (!normalized) {
card.style.display = 'none'; card.classList.add('is-hidden');
panel.hidden = true; panel.hidden = true;
updateChangelogExpanded = false; updateChangelogExpanded = false;
return; return;
} }
card.style.display = 'block'; card.classList.remove('is-hidden');
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
let currentList: HTMLUListElement | null = null; let currentList: HTMLUListElement | null = null;
@ -256,7 +273,7 @@ function renderUpdateChangelog(notes?: string): void {
function refreshUpdateChangelogToggleText(): void { function refreshUpdateChangelogToggleText(): void {
const toggle = byId<HTMLButtonElement>('updateChangelogToggle'); const toggle = byId<HTMLButtonElement>('updateChangelogToggle');
const card = byId<HTMLElement>('updateChangelogCard'); const card = byId<HTMLElement>('updateChangelogCard');
if (card.style.display === 'none') { if (card.classList.contains('is-hidden')) {
return; return;
} }
@ -278,13 +295,18 @@ function refreshUpdateModalTexts(): void {
byId('updateModalConfirmBtn').textContent = isReady byId('updateModalConfirmBtn').textContent = isReady
? UI_TEXT.updates.modalInstallConfirm ? UI_TEXT.updates.modalInstallConfirm
: UI_TEXT.updates.modalDownloadConfirm; : 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('updateChangelogLabel').textContent = UI_TEXT.updates.changelogLabel;
byId('updateChangelogEmpty').textContent = UI_TEXT.updates.noChangelog; byId('updateChangelogEmpty').textContent = UI_TEXT.updates.noChangelog;
const metaText = getUpdateModalMetaText(info); const metaText = getUpdateModalMetaText(info);
const meta = byId('updateModalMeta'); const meta = byId('updateModalMeta');
meta.textContent = metaText; meta.textContent = metaText;
meta.style.display = metaText ? 'block' : 'none'; meta.classList.toggle('is-hidden', !metaText);
renderUpdateChangelog(info.releaseNotes); renderUpdateChangelog(info.releaseNotes);
refreshUpdateChangelogToggleText(); refreshUpdateChangelogToggleText();
@ -301,6 +323,19 @@ function dismissUpdateModal(): void {
byId('updateModal').classList.remove('show'); 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 { function confirmUpdateModal(): void {
dismissUpdateModal(); dismissUpdateModal();
@ -314,7 +349,7 @@ function confirmUpdateModal(): void {
function toggleUpdateChangelog(): void { function toggleUpdateChangelog(): void {
const card = byId<HTMLElement>('updateChangelogCard'); const card = byId<HTMLElement>('updateChangelogCard');
if (card.style.display === 'none') { if (card.classList.contains('is-hidden')) {
return; return;
} }
@ -339,7 +374,7 @@ function refreshUpdateUiTexts(): void {
} else if (updateBannerState === 'downloading') { } else if (updateBannerState === 'downloading') {
button.textContent = UI_TEXT.updates.downloading; button.textContent = UI_TEXT.updates.downloading;
button.disabled = true; button.disabled = true;
progress.style.display = 'block'; progress.classList.remove('is-hidden');
if (latestDownloadProgress) { if (latestDownloadProgress) {
bar.classList.remove('downloading'); bar.classList.remove('downloading');
bar.style.width = `${latestDownloadProgress.percent}%`; bar.style.width = `${latestDownloadProgress.percent}%`;
@ -353,7 +388,7 @@ function refreshUpdateUiTexts(): void {
setDownloadReadyUi(latestUpdateInfo); setDownloadReadyUi(latestUpdateInfo);
} else { } else {
hideUpdateBanner(); hideUpdateBanner();
progress.style.display = 'none'; progress.classList.add('is-hidden');
bar.classList.remove('downloading'); bar.classList.remove('downloading');
bar.style.width = '0%'; bar.style.width = '0%';
byId('updateText').textContent = UI_TEXT.updates.bannerDefault; byId('updateText').textContent = UI_TEXT.updates.bannerDefault;
@ -423,7 +458,7 @@ async function checkUpdate(): Promise<void> {
setCheckButtonCheckingState(false); setCheckButtonCheckingState(false);
window.setTimeout(() => { window.setTimeout(() => {
if (!manualUpdateOutcomeHandled && !updateReady && byId('updateBanner').style.display !== 'flex') { if (!manualUpdateOutcomeHandled && !updateReady && !byId('updateBanner').classList.contains('show')) {
shouldOpenUpdateModalOnAvailable = false; shouldOpenUpdateModalOnAvailable = false;
notifyUpdate(UI_TEXT.updates.latest, 'info'); notifyUpdate(UI_TEXT.updates.latest, 'info');
} }
@ -495,11 +530,22 @@ window.api.onUpdateAvailable((info: UpdateInfo) => {
updateCheckInProgress = false; updateCheckInProgress = false;
updateReady = false; updateReady = false;
updateDownloadInProgress = false; updateDownloadInProgress = false;
const wasManual = manualUpdateCheckPending;
manualUpdateCheckPending = false; manualUpdateCheckPending = false;
manualUpdateOutcomeHandled = true; manualUpdateOutcomeHandled = true;
latestDownloadProgress = null; latestDownloadProgress = null;
setCheckButtonCheckingState(false); 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); setUpdateBannerAvailableUi(activeInfo);
if (shouldOpenUpdateModalOnAvailable) { if (shouldOpenUpdateModalOnAvailable) {
@ -509,6 +555,7 @@ window.api.onUpdateAvailable((info: UpdateInfo) => {
shouldOpenUpdateModalOnAvailable = false; shouldOpenUpdateModalOnAvailable = false;
}); });
window.api.onUpdateNotAvailable(() => { window.api.onUpdateNotAvailable(() => {
updateCheckInProgress = false; updateCheckInProgress = false;
setCheckButtonCheckingState(false); setCheckButtonCheckingState(false);
@ -530,9 +577,10 @@ window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
const bar = byId('updateProgressBar'); const bar = byId('updateProgressBar');
bar.classList.remove('downloading'); bar.classList.remove('downloading');
bar.style.width = progress.percent + '%'; bar.style.width = progress.percent + '%';
byId('updateProgressGauge').setAttribute('aria-valuenow', String(Math.round(progress.percent)));
showUpdateBanner(); showUpdateBanner();
byId('updateProgress').style.display = 'block'; byId('updateProgress').classList.remove('is-hidden');
const mb = (progress.transferred / 1024 / 1024).toFixed(1); const mb = (progress.transferred / 1024 / 1024).toFixed(1);
const totalMb = (progress.total / 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) => { 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); const activeInfo = rememberUpdateInfo(info);
setDownloadReadyUi(activeInfo); setDownloadReadyUi(activeInfo);
openUpdateModal(activeInfo); openUpdateModal(activeInfo);

162
src/renderer-vod-hover.ts Normal file
View 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();
}

View File

@ -5,17 +5,22 @@ const QUEUE_SYNC_HIDDEN_MS = 9000;
const QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS = 15000; const QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS = 15000;
async function init(): Promise<void> { async function init(): Promise<void> {
config = await window.api.getConfig(); const [loadedConfig, initialQueue, isDown, version] = await Promise.all([
window.api.getConfig(),
window.api.getQueue(),
window.api.isDownloading(),
window.api.getVersion()
]);
config = loadedConfig;
const language = setLanguage((config.language as string) || 'en'); const language = setLanguage((config.language as string) || 'en');
config.language = language; config.language = language;
const initialQueue = await window.api.getQueue();
queue = Array.isArray(initialQueue) ? initialQueue : []; queue = Array.isArray(initialQueue) ? initialQueue : [];
downloading = await window.api.isDownloading(); downloading = isDown;
markQueueActivity(); markQueueActivity();
const version = await window.api.getVersion();
byId('versionText').textContent = `v${version}`; byId('versionText').textContent = `v${version}`;
byId('versionInfo').textContent = `Version: v${version}`; byId('versionInfo').textContent = `Version: v${version}`;
appVersion = version;
document.title = `${UI_TEXT.appName} v${version}`; document.title = `${UI_TEXT.appName} v${version}`;
byId<HTMLInputElement>('clientId').value = config.client_id ?? ''; byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
@ -38,11 +43,81 @@ async function init(): Promise<void> {
changeTheme(config.theme ?? 'twitch'); changeTheme(config.theme ?? 'twitch');
renderStreamers(); renderStreamers();
renderQueue(); renderQueue();
updateDownloadButtonState();
window.api.onQueueUpdated((q: QueueItem[]) => { // Keyboard activation for nav-items (Enter / Space). The items are
queue = mergeQueueState(Array.isArray(q) ? q : []); // 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();
}
}
renderQueue(); renderQueue();
updateStatusBarQueueSummary();
markQueueActivity(); markQueueActivity();
}); });
@ -66,10 +141,21 @@ async function init(): Promise<void> {
item.downloadedBytes = progress.downloadedBytes; item.downloadedBytes = progress.downloadedBytes;
item.totalBytes = progress.totalBytes; item.totalBytes = progress.totalBytes;
item.progressStatus = progress.status; item.progressStatus = progress.status;
renderQueue(); if (progress.recordingHealth) {
item.recordingHealth = progress.recordingHealth;
}
updateQueueItemProgress(progress);
updateStatusBarQueueSummary();
markQueueActivity(); 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(() => { window.api.onDownloadStarted(() => {
downloading = true; downloading = true;
updateDownloadButtonState(); updateDownloadButtonState();
@ -83,13 +169,26 @@ async function init(): Promise<void> {
}); });
window.api.onCutProgress((percent: number) => { window.api.onCutProgress((percent: number) => {
const rounded = Math.round(percent);
byId('cutProgressBar').style.width = 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) => { window.api.onMergeProgress((percent: number) => {
const rounded = Math.round(percent);
byId('mergeProgressBar').style.width = 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 — 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) { if (config.client_id && config.client_secret) {
@ -115,11 +214,445 @@ async function init(): Promise<void> {
scheduleQueueSync(document.hidden ? 600 : 150); scheduleQueueSync(document.hidden ? 600 : 150);
}); });
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// 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];
selectedQueueIds = [];
(async () => {
for (const id of idsToRemove) {
queue = await window.api.removeFromQueue(id);
}
renderQueue();
})();
}
if ((e.key === 's' || e.key === 'S') && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
toggleDownload();
}
});
scheduleQueueSync(QUEUE_SYNC_DEFAULT_MS); 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`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatSpeedRenderer(bytesPerSec: number): string {
if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s`;
if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
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();
const bar = byId('statsBar');
if (!bar) return;
const totalDL = formatBytesRenderer(metrics.downloadedBytesTotal);
const avgSpeed = metrics.avgSpeedBytesPerSec > 0 ? formatSpeedRenderer(metrics.avgSpeedBytesPerSec) : '-';
bar.textContent = `${totalDL} | ${avgSpeed} avg | ${metrics.downloadsCompleted} done | ${metrics.downloadsFailed} failed`;
} catch { }
}
let toastHideTimer: number | null = null; let toastHideTimer: number | null = null;
let queueSyncTimer: 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 queueSyncInFlight = false;
let lastQueueActivityAt = Date.now(); let lastQueueActivityAt = Date.now();
@ -183,14 +716,28 @@ function showAppToast(message: string, type: 'info' | 'warn' = 'info'): void {
toast = document.createElement('div'); toast = document.createElement('div');
toast.id = 'appToast'; toast.id = 'appToast';
toast.className = 'app-toast'; 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); document.body.appendChild(toast);
} }
toast.textContent = message;
toast.classList.remove('warn', 'show'); toast.classList.remove('warn', 'show');
if (type === 'warn') { if (type === 'warn') {
toast.classList.add('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(() => { requestAnimationFrame(() => {
toast?.classList.add('show'); toast?.classList.add('show');
@ -219,9 +766,14 @@ function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] {
return item; return item;
} }
// Keep the higher progress value to prevent backward jumps from stale data
const bestProgress = (prev.status === 'downloading' && prev.progress > item.progress)
? prev.progress
: (item.progress > 0 ? item.progress : prev.progress);
return { return {
...item, ...item,
progress: item.progress > 0 ? item.progress : prev.progress, progress: bestProgress,
speed: item.speed || prev.speed, speed: item.speed || prev.speed,
eta: item.eta || prev.eta, eta: item.eta || prev.eta,
currentPart: item.currentPart || prev.currentPart, currentPart: item.currentPart || prev.currentPart,
@ -269,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 { 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')); 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'); byId(tab + 'Tab').classList.add('active');
const titles: Record<string, string> = UI_TEXT.tabs; 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 { function parseDurationToSeconds(durStr: string): number {
@ -361,15 +964,18 @@ function formatSecondsWithPattern(totalSeconds: number, pattern: string): string
.replace(/\\(.)/g, '$1'); .replace(/\\(.)/g, '$1');
} }
function getSelectedFilenameFormat(): 'simple' | 'timestamp' | 'template' { function getSelectedFilenameFormat(): 'simple' | 'timestamp' | 'template' | 'parts' {
const selected = query<HTMLInputElement>('input[name="filenameFormat"]:checked').value; 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 { function updateFilenameTemplateVisibility(): void {
const selected = getSelectedFilenameFormat(); const selected = getSelectedFilenameFormat();
const wrap = byId('clipFilenameTemplateWrap'); const wrap = byId('clipFilenameTemplateWrap');
wrap.style.display = selected === 'template' ? 'block' : 'none'; wrap.classList.toggle('shown', selected === 'template');
} }
interface TemplatePreviewContext { interface TemplatePreviewContext {
@ -682,13 +1288,11 @@ function updateClipDuration(): void {
const duration = endSec - startSec; const duration = endSec - startSec;
const durationDisplay = byId('clipDurationDisplay'); const durationDisplay = byId('clipDurationDisplay');
if (duration > 0) { const isValid = duration > 0;
durationDisplay.textContent = formatSecondsToTime(duration); durationDisplay.classList.toggle('invalid', !isValid);
durationDisplay.style.color = '#00c853'; durationDisplay.textContent = isValid
} else { ? formatSecondsToTime(duration)
durationDisplay.textContent = UI_TEXT.clips.invalidDuration; : UI_TEXT.clips.invalidDuration;
durationDisplay.style.color = '#ff4444';
}
updateFilenameExamples(); updateFilenameExamples();
} }
@ -712,15 +1316,16 @@ function updateFilenameExamples(): void {
updateFilenameTemplateVisibility(); updateFilenameTemplateVisibility();
if (!unknownTokens.length) { if (!unknownTokens.length) {
clipLint.style.color = '#8bc34a'; clipLint.className = 'template-lint ok';
clipLint.textContent = UI_TEXT.static.templateLintOk; clipLint.textContent = UI_TEXT.static.templateLintOk;
} else { } else {
clipLint.style.color = '#ff8a80'; clipLint.className = 'template-lint warn';
clipLint.textContent = `${UI_TEXT.static.templateLintWarn}: ${unknownTokens.join(' ')}`; clipLint.textContent = `${UI_TEXT.static.templateLintWarn}: ${unknownTokens.join(' ')}`;
} }
byId('formatSimple').textContent = `${dateStr}_${partNum}.mp4 ${UI_TEXT.clips.formatSimple}`; byId('formatSimple').textContent = `${dateStr}_${partNum}.mp4 ${UI_TEXT.clips.formatSimple}`;
byId('formatTimestamp').textContent = `${dateStr}_CLIP_${timeStr}_${partNum}.mp4 ${UI_TEXT.clips.formatTimestamp}`; 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, { byId('formatTemplate').textContent = `${buildTemplatePreview(template, {
title: clipDialogData.title, title: clipDialogData.title,
date, date,
@ -744,17 +1349,28 @@ async function confirmClipDialog(): Promise<void> {
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value); const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value); const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
const durationSec = endSec - startSec;
const startPartStr = byId<HTMLInputElement>('clipStartPart').value.trim(); const startPartStr = byId<HTMLInputElement>('clipStartPart').value.trim();
const startPart = startPartStr ? parseInt(startPartStr, 10) : 1; const startPart = startPartStr ? parseInt(startPartStr, 10) : 1;
const filenameFormat = getSelectedFilenameFormat(); const filenameFormat = getSelectedFilenameFormat();
const filenameTemplate = byId<HTMLInputElement>('clipFilenameTemplate').value.trim(); 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); alert(UI_TEXT.clips.endBeforeStart);
return; return;
} }
if (startSec < 0 || endSec > clipTotalSeconds) { if (endSec > clipTotalSeconds) {
alert(UI_TEXT.clips.outOfRange); alert(UI_TEXT.clips.outOfRange);
return; return;
} }
@ -772,7 +1388,6 @@ async function confirmClipDialog(): Promise<void> {
} }
} }
const durationSec = endSec - startSec;
const customClip: CustomClip = { const customClip: CustomClip = {
startSec, startSec,
durationSec, durationSec,
@ -831,28 +1446,15 @@ async function downloadClip(): Promise<void> {
return; 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(); const backendError = (result.error || '').trim();
let localizedError = backendError; status.textContent = UI_TEXT.clips.errorPrefix + (backendError || UI_TEXT.clips.unknownError);
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.className = 'clip-status error'; status.className = 'clip-status error';
} }
async function selectCutterVideo(): Promise<void> { async function loadCutterFromPath(filePath: string): Promise<void> {
const filePath = await window.api.selectVideoFile(); if (!filePath) return;
if (!filePath) {
return;
}
cutterFile = filePath; cutterFile = filePath;
byId<HTMLInputElement>('cutterFilePath').value = filePath; byId<HTMLInputElement>('cutterFilePath').value = filePath;
@ -867,8 +1469,8 @@ async function selectCutterVideo(): Promise<void> {
cutterStartTime = 0; cutterStartTime = 0;
cutterEndTime = info.duration; cutterEndTime = info.duration;
byId('cutterInfo').style.display = 'flex'; byId('cutterInfo').classList.add('shown');
byId('timelineContainer').style.display = 'block'; byId('timelineContainer').classList.add('shown');
byId('btnCut').disabled = false; byId('btnCut').disabled = false;
byId('infoDuration').textContent = formatTime(info.duration); byId('infoDuration').textContent = formatTime(info.duration);
@ -883,6 +1485,12 @@ async function selectCutterVideo(): Promise<void> {
await updatePreview(0); 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 { function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
@ -949,15 +1557,15 @@ async function updatePreview(time: number): Promise<void> {
} }
const preview = byId('cutterPreview'); 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); const frame = await window.api.extractFrame(cutterFile, time);
if (frame) { if (frame) {
preview.innerHTML = `<img src="${frame}" alt="Preview">`; applyHtml(preview, `<img src="${escapeHtml(frame)}" alt="${escapeHtml(UI_TEXT.cutter.previewAlt)}">`);
return; 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> { async function startCutting(): Promise<void> {
@ -1000,12 +1608,23 @@ function renderMergeFiles(): void {
byId('btnMerge').disabled = mergeFiles.length < 2; byId('btnMerge').disabled = mergeFiles.length < 2;
if (mergeFiles.length === 0) { if (mergeFiles.length === 0) {
list.innerHTML = ` // Build via DOM API to keep the renderer clean of inline-styled
<div class="empty-state" style="padding: 40px 20px;"> // HTML strings. The empty-state SVG is the same plus-icon the
<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> // static HTML uses, just built programmatically.
<p style="margin-top:10px">${UI_TEXT.merge.empty}</p> list.replaceChildren();
</div> 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; return;
} }
@ -1016,9 +1635,9 @@ function renderMergeFiles(): void {
<div class="file-order">${index + 1}</div> <div class="file-order">${index + 1}</div>
<div class="file-name" title="${file}">${name}</div> <div class="file-name" title="${file}">${name}</div>
<div class="file-actions"> <div class="file-actions">
<button class="file-btn" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>&#9650;</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' : ''}>&#9650;</button>
<button class="file-btn" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>&#9660;</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' : ''}>&#9660;</button>
<button class="file-btn remove" onclick="removeMergeFile(${index})">x</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>
</div> </div>
`; `;

File diff suppressed because it is too large Load Diff

494
src/tools.ts Normal file
View File

@ -0,0 +1,494 @@
import * as path from 'path';
import * as fs from 'fs';
import { spawn, execSync, spawnSync } from 'child_process';
import axios from 'axios';
// ==========================================
// CONSTANTS
// ==========================================
const TOOL_PATH_REFRESH_TTL_MS = 10 * 1000;
// ==========================================
// DEBUG LOG CALLBACK
// ==========================================
let _appendDebugLog: (message: string, details?: unknown) => void = () => {};
export function setDebugLogFn(fn: (message: string, details?: unknown) => void): void {
_appendDebugLog = fn;
}
// ==========================================
// TOOL DIRECTORIES (set once from main)
// ==========================================
let TOOLS_STREAMLINK_DIR = '';
let TOOLS_FFMPEG_DIR = '';
let _getTempPath: () => string = () => '';
export function initToolDirs(streamlinkDir: string, ffmpegDir: string, getTempPath: () => string): void {
TOOLS_STREAMLINK_DIR = streamlinkDir;
TOOLS_FFMPEG_DIR = ffmpegDir;
_getTempPath = getTempPath;
}
// ==========================================
// CACHE STATE
// ==========================================
let streamlinkPathCache: string | null = null;
let streamlinkCommandCache: { command: string; prefixArgs: string[] } | null = null;
let ffmpegPathCache: string | null = null;
let ffprobePathCache: string | null = null;
let bundledStreamlinkPath: string | null = null;
let bundledFFmpegPath: string | null = null;
let bundledFFprobePath: string | null = null;
let verifiedStreamlinkCommandKey: string | null = null;
let verifiedFfmpegCommandKey: string | null = null;
let bundledToolPathSignature = '';
let bundledToolPathRefreshedAt = 0;
// ==========================================
// INTERNAL HELPERS
// ==========================================
function findFileRecursive(rootDir: string, fileName: string): string | null {
if (!fs.existsSync(rootDir)) return null;
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
return fullPath;
}
if (entry.isDirectory()) {
const nested = findFileRecursive(fullPath, fileName);
if (nested) return nested;
}
}
return null;
}
function getDirectoryMtimeMs(directoryPath: string): number {
try {
return fs.statSync(directoryPath).mtimeMs;
} catch {
return 0;
}
}
function getCommandCacheKey(command: string, args: string[]): string {
return [command, ...args].join('\u0000');
}
export function canExecute(cmd: string): boolean {
try {
execSync(cmd, { stdio: 'ignore', windowsHide: true });
return true;
} catch {
return false;
}
}
export function canExecuteCommand(command: string, args: string[]): boolean {
try {
const result = spawnSync(command, args, { stdio: 'ignore', windowsHide: true });
return result.status === 0;
} catch {
return false;
}
}
// ==========================================
// VERIFIED COMMAND CACHES
// ==========================================
export function cacheVerifiedStreamlinkCommand(command: string, args: string[]): void {
verifiedStreamlinkCommandKey = getCommandCacheKey(command, args);
}
export function isVerifiedStreamlinkCommand(command: string, args: string[]): boolean {
return verifiedStreamlinkCommandKey === getCommandCacheKey(command, args);
}
export function cacheVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): void {
verifiedFfmpegCommandKey = getCommandCacheKey(ffmpegCommand, [ffprobeCommand]);
}
export function isVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): boolean {
return verifiedFfmpegCommandKey === getCommandCacheKey(ffmpegCommand, [ffprobeCommand]);
}
export function invalidateVerifiedToolCaches(): void {
verifiedStreamlinkCommandKey = null;
verifiedFfmpegCommandKey = null;
}
// ==========================================
// TOOL PATH DISCOVERY
// ==========================================
export function getStreamlinkPath(): string {
if (streamlinkPathCache) {
if (streamlinkPathCache === 'streamlink' || fs.existsSync(streamlinkPathCache)) {
return streamlinkPathCache;
}
streamlinkPathCache = null;
}
if (bundledStreamlinkPath && fs.existsSync(bundledStreamlinkPath)) {
streamlinkPathCache = bundledStreamlinkPath;
return streamlinkPathCache;
}
try {
if (process.platform === 'win32') {
const result = execSync('where streamlink', { encoding: 'utf-8' });
const paths = result.trim().split('\n');
if (paths.length > 0) {
streamlinkPathCache = paths[0].trim();
return streamlinkPathCache;
}
} else {
const result = execSync('which streamlink', { encoding: 'utf-8' });
streamlinkPathCache = result.trim();
return streamlinkPathCache;
}
} catch { }
const commonPaths = [
'C:\\Program Files\\Streamlink\\bin\\streamlink.exe',
'C:\\Program Files (x86)\\Streamlink\\bin\\streamlink.exe',
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Streamlink', 'bin', 'streamlink.exe')
];
for (const p of commonPaths) {
if (fs.existsSync(p)) {
streamlinkPathCache = p;
return streamlinkPathCache;
}
}
streamlinkPathCache = 'streamlink';
return streamlinkPathCache;
}
export function getStreamlinkCommand(): { command: string; prefixArgs: string[] } {
if (streamlinkCommandCache) {
return streamlinkCommandCache;
}
const directPath = getStreamlinkPath();
if (directPath !== 'streamlink' || canExecute('streamlink --version')) {
streamlinkCommandCache = { command: directPath, prefixArgs: [] };
return streamlinkCommandCache;
}
if (process.platform === 'win32') {
if (canExecute('py -3 -m streamlink --version')) {
streamlinkCommandCache = { command: 'py', prefixArgs: ['-3', '-m', 'streamlink'] };
return streamlinkCommandCache;
}
if (canExecute('python -m streamlink --version')) {
streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] };
return streamlinkCommandCache;
}
} else {
if (canExecute('python3 -m streamlink --version')) {
streamlinkCommandCache = { command: 'python3', prefixArgs: ['-m', 'streamlink'] };
return streamlinkCommandCache;
}
if (canExecute('python -m streamlink --version')) {
streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] };
return streamlinkCommandCache;
}
}
streamlinkCommandCache = { command: directPath, prefixArgs: [] };
return streamlinkCommandCache;
}
export function getFFmpegPath(): string {
if (ffmpegPathCache) {
if (ffmpegPathCache === 'ffmpeg' || fs.existsSync(ffmpegPathCache)) {
return ffmpegPathCache;
}
ffmpegPathCache = null;
}
if (bundledFFmpegPath && fs.existsSync(bundledFFmpegPath)) {
ffmpegPathCache = bundledFFmpegPath;
return ffmpegPathCache;
}
try {
if (process.platform === 'win32') {
const result = execSync('where ffmpeg', { encoding: 'utf-8' });
const paths = result.trim().split('\n');
if (paths.length > 0) {
ffmpegPathCache = paths[0].trim();
return ffmpegPathCache;
}
} else {
const result = execSync('which ffmpeg', { encoding: 'utf-8' });
ffmpegPathCache = result.trim();
return ffmpegPathCache;
}
} catch { }
const commonPaths = [
'C:\\ffmpeg\\bin\\ffmpeg.exe',
'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ffmpeg', 'bin', 'ffmpeg.exe')
];
for (const p of commonPaths) {
if (fs.existsSync(p)) {
ffmpegPathCache = p;
return ffmpegPathCache;
}
}
ffmpegPathCache = 'ffmpeg';
return ffmpegPathCache;
}
export function getFFprobePath(): string {
if (ffprobePathCache) {
if (ffprobePathCache === 'ffprobe' || ffprobePathCache === 'ffprobe.exe' || fs.existsSync(ffprobePathCache)) {
return ffprobePathCache;
}
ffprobePathCache = null;
}
if (bundledFFprobePath && fs.existsSync(bundledFFprobePath)) {
ffprobePathCache = bundledFFprobePath;
return ffprobePathCache;
}
const ffmpegPath = getFFmpegPath();
const ffprobeExe = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe';
if (ffmpegPath === 'ffmpeg') {
ffprobePathCache = ffprobeExe;
return ffprobePathCache;
}
const derivedFfprobePath = path.join(path.dirname(ffmpegPath), ffprobeExe);
if (fs.existsSync(derivedFfprobePath)) {
ffprobePathCache = derivedFfprobePath;
return ffprobePathCache;
}
ffprobePathCache = ffprobeExe;
return ffprobePathCache;
}
// ==========================================
// BUNDLED TOOL PATH REFRESH
// ==========================================
export function refreshBundledToolPaths(force = false): void {
const now = Date.now();
const signature = `${getDirectoryMtimeMs(TOOLS_STREAMLINK_DIR)}|${getDirectoryMtimeMs(TOOLS_FFMPEG_DIR)}`;
if (!force && signature === bundledToolPathSignature && (now - bundledToolPathRefreshedAt) < TOOL_PATH_REFRESH_TTL_MS) {
return;
}
bundledToolPathSignature = signature;
bundledToolPathRefreshedAt = now;
const nextBundledStreamlinkPath = findFileRecursive(TOOLS_STREAMLINK_DIR, process.platform === 'win32' ? 'streamlink.exe' : 'streamlink');
const nextBundledFFmpegPath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
const nextBundledFFprobePath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe');
const changed =
nextBundledStreamlinkPath !== bundledStreamlinkPath ||
nextBundledFFmpegPath !== bundledFFmpegPath ||
nextBundledFFprobePath !== bundledFFprobePath;
bundledStreamlinkPath = nextBundledStreamlinkPath;
bundledFFmpegPath = nextBundledFFmpegPath;
bundledFFprobePath = nextBundledFFprobePath;
if (changed) {
streamlinkPathCache = null;
ffmpegPathCache = null;
ffprobePathCache = null;
streamlinkCommandCache = null;
invalidateVerifiedToolCaches();
}
}
// ==========================================
// DOWNLOAD & EXTRACT HELPERS
// ==========================================
async function downloadFile(url: string, destinationPath: string): Promise<boolean> {
try {
const response = await axios.get(url, { responseType: 'stream', timeout: 120000 });
await new Promise<void>((resolve, reject) => {
const writer = fs.createWriteStream(destinationPath);
response.data.pipe(writer);
writer.on('finish', () => resolve());
writer.on('error', (err) => reject(err));
});
return true;
} catch (e) {
_appendDebugLog('download-file-failed', { url, destinationPath, error: String(e) });
return false;
}
}
async function extractZip(zipPath: string, destinationDir: string): Promise<boolean> {
try {
fs.mkdirSync(destinationDir, { recursive: true });
const command = `Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destinationDir.replace(/'/g, "''")}' -Force`;
await new Promise<void>((resolve, reject) => {
const proc = spawn('powershell', [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-Command',
command
], { windowsHide: true });
let stderr = '';
proc.stderr?.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Expand-Archive exit code ${code}: ${stderr.trim()}`));
}
});
proc.on('error', (err) => reject(err));
});
return true;
} catch (e) {
_appendDebugLog('extract-zip-failed', { zipPath, destinationDir, error: String(e) });
return false;
}
}
// ==========================================
// AUTO-INSTALL TOOLS
// ==========================================
export async function ensureStreamlinkInstalled(): Promise<boolean> {
refreshBundledToolPaths();
const current = getStreamlinkCommand();
const versionArgs = [...current.prefixArgs, '--version'];
if (isVerifiedStreamlinkCommand(current.command, versionArgs)) {
return true;
}
if (canExecuteCommand(current.command, versionArgs)) {
cacheVerifiedStreamlinkCommand(current.command, versionArgs);
return true;
}
if (process.platform !== 'win32') {
return false;
}
_appendDebugLog('streamlink-install-start');
try {
fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true });
const release = await axios.get('https://api.github.com/repos/streamlink/windows-builds/releases/latest', {
timeout: 120000,
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'Twitch-VOD-Manager'
}
});
const assets = release.data?.assets || [];
const zipAsset = assets.find((a: any) => typeof a?.name === 'string' && /x86_64\.zip$/i.test(a.name));
if (!zipAsset?.browser_download_url) {
_appendDebugLog('streamlink-install-no-asset-found');
return false;
}
const zipPath = path.join(_getTempPath(), `streamlink_portable_${Date.now()}.zip`);
const downloadOk = await downloadFile(zipAsset.browser_download_url, zipPath);
if (!downloadOk) return false;
fs.rmSync(TOOLS_STREAMLINK_DIR, { recursive: true, force: true });
fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true });
const extractOk = await extractZip(zipPath, TOOLS_STREAMLINK_DIR);
try { fs.unlinkSync(zipPath); } catch { }
if (!extractOk) return false;
refreshBundledToolPaths(true);
streamlinkCommandCache = null;
const cmd = getStreamlinkCommand();
const installedVersionArgs = [...cmd.prefixArgs, '--version'];
const works = canExecuteCommand(cmd.command, installedVersionArgs);
if (works) {
cacheVerifiedStreamlinkCommand(cmd.command, installedVersionArgs);
}
_appendDebugLog('streamlink-install-finished', { works, command: cmd.command, prefixArgs: cmd.prefixArgs });
return works;
} catch (e) {
_appendDebugLog('streamlink-install-failed', String(e));
return false;
}
}
export async function ensureFfmpegInstalled(): Promise<boolean> {
refreshBundledToolPaths();
const ffmpegPath = getFFmpegPath();
const ffprobePath = getFFprobePath();
if (isVerifiedFfmpegCommands(ffmpegPath, ffprobePath)) {
return true;
}
if (canExecuteCommand(ffmpegPath, ['-version']) && canExecuteCommand(ffprobePath, ['-version'])) {
cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath);
return true;
}
if (process.platform !== 'win32') {
return false;
}
_appendDebugLog('ffmpeg-install-start');
try {
fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true });
const zipPath = path.join(_getTempPath(), `ffmpeg_essentials_${Date.now()}.zip`);
const downloadOk = await downloadFile('https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip', zipPath);
if (!downloadOk) return false;
fs.rmSync(TOOLS_FFMPEG_DIR, { recursive: true, force: true });
fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true });
const extractOk = await extractZip(zipPath, TOOLS_FFMPEG_DIR);
try { fs.unlinkSync(zipPath); } catch { }
if (!extractOk) return false;
refreshBundledToolPaths(true);
const newFfmpegPath = getFFmpegPath();
const newFfprobePath = getFFprobePath();
const works = canExecuteCommand(newFfmpegPath, ['-version']) && canExecuteCommand(newFfprobePath, ['-version']);
if (works) {
cacheVerifiedFfmpegCommands(newFfmpegPath, newFfprobePath);
}
_appendDebugLog('ffmpeg-install-finished', { works, ffmpeg: newFfmpegPath, ffprobe: newFfprobePath });
return works;
} catch (e) {
_appendDebugLog('ffmpeg-install-failed', String(e));
return false;
}
}

81
src/types.ts Normal file
View File

@ -0,0 +1,81 @@
export interface CustomClip {
startSec: number;
durationSec: number;
startPart: number;
filenameFormat: 'simple' | 'timestamp' | 'template' | 'parts';
filenameTemplate?: string;
}
export interface MergeGroupItem {
url: string;
title: string;
date: string;
streamer: string;
duration_str: string;
}
export interface MergeGroup {
items: MergeGroupItem[];
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
currentItemIndex: number;
downloadedFiles: Record<number, string>;
mergedFile?: string;
splitFiles?: string[];
totalDurationSec?: number;
}
export interface QueueItem {
id: string;
title: string;
url: string;
date: string;
streamer: string;
duration_str: string;
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
progress: number;
currentPart?: number;
totalParts?: number;
speed?: string;
eta?: string;
downloadedBytes?: number;
totalBytes?: number;
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 {
id: string;
progress: number;
speed: string;
speedBytesPerSec?: number;
eta: string;
status: string;
currentPart?: number;
totalParts?: number;
downloadedBytes?: number;
totalBytes?: number;
recordingHealth?: 'ok' | 'stale' | 'unknown';
}
export interface DownloadResult {
success: boolean;
error?: string;
outputFiles?: string[];
}