Compare commits

...

150 Commits

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
17 changed files with 969 additions and 448 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
"version": "4.6.80",
"version": "4.6.155",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "4.6.80",
"version": "4.6.155",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
"version": "4.6.80",
"version": "4.6.155",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "xRangerDE",

View File

@ -10,23 +10,23 @@
<body class="theme-twitch">
<div class="update-banner" id="updateBanner">
<span id="updateText">Neue Version verfügbar!</span>
<div id="updateProgress" class="update-banner-progress-wrap" style="display: none;">
<div id="updateProgress" class="update-banner-progress-wrap is-hidden">
<div class="update-banner-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Update download" id="updateProgressGauge">
<div id="updateProgressBar" class="update-banner-progress-bar"></div>
</div>
</div>
<button id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
<button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
</div>
<div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)">
<div class="modal update-modal">
<button class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button>
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button>
<div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div>
<h2 id="updateModalTitle">Update verfugbar</h2>
<p class="update-modal-message" id="updateModalMessage">Version 0.0.0 ist verfugbar. Jetzt herunterladen?</p>
<div class="update-modal-meta" id="updateModalMeta" style="display:none;"></div>
<div class="update-modal-meta is-hidden" id="updateModalMeta"></div>
<div class="update-changelog-card" id="updateChangelogCard" style="display:none;">
<div class="update-changelog-card is-hidden" id="updateChangelogCard">
<div class="update-changelog-header">
<span class="update-changelog-label" id="updateChangelogLabel">Changelog</span>
<button type="button" class="update-changelog-toggle" id="updateChangelogToggle" onclick="toggleUpdateChangelog()">Changelog anzeigen</button>
@ -48,7 +48,7 @@
<!-- Clip Dialog Modal -->
<div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-labelledby="clipDialogTitle">
<div class="modal clip-modal">
<button class="modal-close modal-close-localizable" aria-label="Close" onclick="closeClipDialog()">x</button>
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeClipDialog()">x</button>
<h2 class="clip-modal-title" id="clipDialogTitle">VOD zuschneiden</h2>
<div class="clip-modal-field">
@ -99,16 +99,16 @@
<span id="formatTemplate" class="clip-radio-label">{date}_{part}.mp4 (benutzerdefiniert)</span>
</label>
<div id="clipFilenameTemplateWrap" class="clip-template-wrap" style="display:none;">
<div id="clipFilenameTemplateWrap" class="clip-template-wrap">
<input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4" placeholder="{date}_{part}.mp4" class="clip-modal-template-input" oninput="updateFilenameExamples()">
<div id="clipTemplateHelp" class="clip-modal-hint">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<div id="clipTemplateLint" class="template-lint ok" style="margin-top: 4px;">Template-Check: OK</div>
<button class="btn-secondary" id="clipTemplateGuideBtn" style="margin-top: 8px;" onclick="openTemplateGuide('clip')">Template Guide</button>
<div id="clipTemplateLint" class="template-lint ok">Template-Check: OK</div>
<button type="button" class="btn-secondary" id="clipTemplateGuideBtn" onclick="openTemplateGuide('clip')">Template Guide</button>
</div>
</div>
<div class="clip-modal-actions">
<button class="btn-pill success" id="clipDialogConfirmBtn" style="padding: 12px 30px;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
<button type="button" class="btn-pill success" id="clipDialogConfirmBtn" style="padding: 12px 30px;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
</div>
</div>
</div>
@ -116,9 +116,9 @@
<!-- 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 class="modal-close modal-close-localizable" aria-label="Close" onclick="closeEventsViewer()">x</button>
<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"></div>
<div id="eventsViewerStatus" class="viewer-modal-status" role="status" aria-live="polite"></div>
<div id="eventsViewerList" class="viewer-modal-list"></div>
</div>
</div>
@ -126,11 +126,11 @@
<!-- 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 class="modal-close modal-close-localizable" aria-label="Close" onclick="closeChatViewer()">x</button>
<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"></span>
<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>
@ -139,17 +139,17 @@
<!-- Template Guide Modal -->
<div class="modal-overlay" id="templateGuideModal" role="dialog" aria-modal="true" aria-labelledby="templateGuideTitle">
<div class="modal template-guide-modal">
<button class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button>
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button>
<h2 id="templateGuideTitle">Template Guide</h2>
<p id="templateGuideIntro" class="template-guide-intro">Nutze Variablen fur Dateinamen und prufe das Ergebnis als Live-Vorschau.</p>
<div class="template-guide-actions">
<button class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button>
<button class="btn-secondary" id="templateGuideUseParts" onclick="setTemplateGuidePreset('parts')">VOD Part Template</button>
<button class="btn-secondary" id="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button>
<button type="button" class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button>
<button type="button" class="btn-secondary" id="templateGuideUseParts" onclick="setTemplateGuidePreset('parts')">VOD Part Template</button>
<button type="button" class="btn-secondary" id="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button>
</div>
<label id="templateGuideTemplateLabel" class="template-guide-label">Template</label>
<label id="templateGuideTemplateLabel" for="templateGuideInput" class="template-guide-label">Template</label>
<input type="text" id="templateGuideInput" class="template-guide-input" oninput="updateTemplateGuidePreview()" placeholder="{title}.mp4">
<div class="template-guide-preview-box">
@ -160,12 +160,12 @@
<h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfugbare Variablen</h3>
<div class="template-guide-table-wrap">
<table class="template-guide-table">
<table class="template-guide-table" aria-labelledby="templateGuideVarsTitle">
<thead>
<tr>
<th id="templateGuideVarCol">Variable</th>
<th id="templateGuideDescCol">Beschreibung</th>
<th id="templateGuideExampleCol">Beispiel</th>
<th id="templateGuideVarCol" scope="col">Variable</th>
<th id="templateGuideDescCol" scope="col">Beschreibung</th>
<th id="templateGuideExampleCol" scope="col">Beispiel</th>
</tr>
</thead>
<tbody id="templateGuideBody"></tbody>
@ -173,7 +173,7 @@
</div>
<div class="template-guide-footer">
<button class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button>
<button type="button" class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button>
</div>
</div>
</div>
@ -181,49 +181,49 @@
<div class="app">
<aside class="sidebar">
<div class="logo">
<svg viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
<span id="logoText">Twitch VOD Manager</span>
</div>
<nav class="nav">
<div class="nav-item active" 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>
</div>
<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>
</div>
<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>
</div>
<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>
</div>
<div class="nav-item" role="button" tabindex="0" data-tab="stats" onclick="showTab('stats')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v8H3zm4-7h2v15H7zm4 4h2v11h-2zm4 4h2v7h-2zm4-8h2v15h-2z"/></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 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>
<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 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="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
<span id="navSettingsText">Einstellungen</span>
</div>
</nav>
<div class="section-title" id="streamerSectionTitle" style="display:flex; align-items:center; gap:6px; justify-content:space-between;">
<span style="display:flex; align-items:baseline; gap:8px;">
<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" type="button" onclick="bulkRemoveStreamers()" title="Bulk remove" style="display:none;">x</button>
<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" placeholder="Filter..." oninput="onStreamerListFilterChange()" style="display:none;">
<input type="text" id="streamerListFilter" class="filter-input compact is-hidden" placeholder="Filter..." oninput="onStreamerListFilterChange()">
<div class="streamers" id="streamerList"></div>
<div class="queue-section">
@ -233,10 +233,10 @@
</div>
<div class="queue-list" id="queueList"></div>
<div class="queue-actions">
<button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
<button class="btn btn-merge-group" id="btnMergeGroup" onclick="createMergeGroupFromSelection()" style="display:none">Merge &amp; Split</button>
<button class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
<button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
<button type="button" class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
<button type="button" class="btn btn-merge-group is-hidden" id="btnMergeGroup" onclick="createMergeGroupFromSelection()">Merge &amp; Split</button>
<button type="button" class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
<button type="button" class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
</div>
</div>
<div class="stats-bar" id="statsBar"></div>
@ -248,10 +248,10 @@
<div class="header-actions">
<div class="header-search">
<input type="text" id="newStreamer" placeholder="Streamer hinzufugen..." onkeypress="if(event.key==='Enter')addStreamer()">
<button onclick="addStreamer()">+</button>
<button id="btnAddStreamer" type="button" onclick="addStreamer()" aria-label="Add streamer" title="Add streamer">+</button>
</div>
<button class="btn-icon" onclick="refreshVODs()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
<button type="button" class="btn-icon" onclick="refreshVODs()">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
<span id="refreshText">Aktualisieren</span>
</button>
</div>
@ -260,10 +260,10 @@
<div class="content">
<!-- VODs Tab -->
<div class="tab-content active" id="vodsTab">
<div id="streamerProfileHeader" class="streamer-profile-header" style="display:none;"></div>
<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 id="vodFilterClearBtn" class="btn-close" onclick="clearVodFilter()" title="Clear filter" style="display:none;">x</button>
<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>
@ -278,7 +278,7 @@
<span id="vodHideDownloadedText">Hide downloaded</span>
</label>
</div>
<div id="vodBulkBar" class="vod-bulk-bar" style="display:none;">
<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>
@ -288,7 +288,7 @@
</div>
<div class="vod-grid" id="vodGrid">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<h3 id="vodGridEmptyTitle">Keine VODs</h3>
<p id="vodGridEmptyText">Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
</div>
@ -300,13 +300,13 @@
<div class="clip-input">
<h2 id="clipsHeading">Twitch Clip-Download</h2>
<input type="text" id="clipUrl" placeholder="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...">
<button class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
<div class="clip-status" id="clipStatus"></div>
<button type="button" class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
<div class="clip-status" id="clipStatus" role="status" aria-live="polite"></div>
</div>
<div class="settings-card" style="max-width: 600px; margin: 20px auto;">
<div class="settings-card centered">
<h3 id="clipsInfoTitle">Info</h3>
<p style="color: var(--text-secondary); line-height: 1.6; white-space: pre-line;" id="clipsInfoText">
<p id="clipsInfoText" class="info-text">
Unterstutzte Formate:
- https://clips.twitch.tv/ClipName
- https://www.twitch.tv/streamer/clip/ClipName
@ -323,18 +323,18 @@
<h3 id="cutterSelectTitle">Video auswahlen</h3>
<div class="form-row">
<input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt...">
<button class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
</div>
</div>
<div class="video-preview" id="cutterPreview">
<div class="placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
<p style="margin-top:10px">Video auswahlen um Vorschau zu sehen</p>
<svg aria-hidden="true" width="64" height="64" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
<p>Video auswahlen um Vorschau zu sehen</p>
</div>
</div>
<div class="cutter-info" id="cutterInfo" style="display:none">
<div class="cutter-info" id="cutterInfo">
<div class="cutter-info-item">
<span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span>
<span class="cutter-info-value" id="infoDuration">--:--:--</span>
@ -353,7 +353,7 @@
</div>
</div>
<div class="timeline-container" id="timelineContainer" style="display:none">
<div class="timeline-container" id="timelineContainer">
<div class="timeline" id="timeline" onclick="seekTimeline(event)">
<div class="timeline-selection" id="timelineSelection"></div>
<div class="timeline-current" id="timelineCurrent"></div>
@ -361,11 +361,11 @@
<div class="time-inputs">
<div class="time-input-group">
<label id="cutterStartLabel">Start:</label>
<label id="cutterStartLabel" for="startTime">Start:</label>
<input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()">
</div>
<div class="time-input-group">
<label id="cutterEndLabel">Ende:</label>
<label id="cutterEndLabel" for="endTime">Ende:</label>
<input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()">
</div>
</div>
@ -379,7 +379,7 @@
</div>
<div class="cutter-actions">
<button class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
<button type="button" class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
</div>
</div>
</div>
@ -389,17 +389,17 @@
<div class="merge-container">
<div class="settings-card">
<h3 id="mergeTitle">Videos zusammenfugen</h3>
<p style="color: var(--text-secondary); margin-bottom: 15px;" id="mergeDesc">
<p id="mergeDesc" class="card-intro">
Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen.
Die Reihenfolge kann per Drag & Drop geandert werden.
</p>
<button class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button>
<button type="button" class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button>
</div>
<div class="file-list" id="mergeFileList">
<div class="empty-state" style="padding: 40px 20px;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<p id="mergeEmptyText" style="margin-top:10px">Keine Videos ausgewahlt</p>
<div class="empty-state merge-empty-state">
<svg aria-hidden="true" width="48" height="48" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<p id="mergeEmptyText">Keine Videos ausgewahlt</p>
</div>
</div>
@ -411,7 +411,7 @@
</div>
<div class="merge-actions">
<button class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button>
<button type="button" class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button>
</div>
</div>
</div>
@ -419,19 +419,19 @@
<!-- Statistics Tab -->
<div class="tab-content" id="statsTab">
<div class="settings-card">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;">
<h3 id="statsTitle" style="margin:0;">Archiv-Statistik</h3>
<div style="display:flex; gap:8px; align-items:center;">
<span id="statsLastScannedLabel" class="form-sublabel"></span>
<button class="btn-secondary" id="btnStatsRefresh" onclick="refreshArchiveStats()">Aktualisieren</button>
<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" style="color: var(--text-secondary); font-size:13px; margin-top:8px; margin-bottom:0; line-height:1.5;">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>
<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" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px;"></div>
<div id="statsSummaryGrid" class="stats-summary-grid"></div>
</div>
<div class="settings-card">
@ -453,16 +453,16 @@
<!-- Archive Search Tab -->
<div class="tab-content" id="archiveTab">
<div class="settings-card">
<h3 id="archiveTitle" style="margin-top:0;">Archiv durchsuchen</h3>
<p id="archiveIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">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" style="gap:8px; margin-bottom: 8px; flex-wrap: wrap; align-items:center;">
<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" style="min-width: 160px;">
<select id="archiveSearchStreamer" class="select-compact size-md">
<option value="">Alle Streamer</option>
</select>
<select id="archiveSearchSort" class="select-compact">
@ -472,9 +472,9 @@
<option value="size_asc">Kleinste zuerst</option>
<option value="name_asc">Name (A-Z)</option>
</select>
<button class="btn-secondary" id="btnArchiveSearch" onclick="performArchiveSearch()">Suchen</button>
<button type="button" class="btn-secondary" id="btnArchiveSearch" onclick="performArchiveSearch()">Suchen</button>
</div>
<div id="archiveSearchSummary" style="font-size: 12px; color: var(--text-secondary);"></div>
<div id="archiveSearchSummary" class="form-sublabel" role="status" aria-live="polite"></div>
</div>
<div class="settings-card">
<div id="archiveSearchResults"></div>
@ -486,7 +486,7 @@
<div class="settings-card">
<h3 id="designTitle">Design</h3>
<div class="form-group">
<label id="themeLabel">Theme</label>
<label id="themeLabel" for="themeSelect">Theme</label>
<select id="themeSelect" onchange="changeTheme(this.value)">
<option value="twitch">Twitch</option>
<option value="discord">Discord</option>
@ -497,7 +497,7 @@
</div>
<div class="form-group">
<label id="languageLabel">Sprache</label>
<div class="language-picker" id="languagePicker">
<div class="language-picker" id="languagePicker" role="group" aria-labelledby="languageLabel">
<button type="button" class="lang-option" id="langOptionDe" onclick="selectLanguageOption('de')" aria-pressed="false">
<span class="flag-icon flag-de" aria-hidden="true"></span>
<span id="languageDeText">Deutsch</span>
@ -516,51 +516,51 @@
<div class="settings-card">
<h3 id="apiTitle">Twitch API</h3>
<p id="apiHelpText" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">
<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()" style="color: var(--accent); text-decoration: underline; cursor: pointer;">dev.twitch.tv/console/apps</a>
<a href="#" id="apiHelpLink" onclick="event.preventDefault(); openTwitchDevConsole()">dev.twitch.tv/console/apps</a>
</p>
<div class="form-group">
<label id="clientIdLabel">Client ID</label>
<label id="clientIdLabel" for="clientId">Client ID</label>
<input type="text" id="clientId" placeholder="Twitch Client ID">
</div>
<div class="form-group">
<label id="clientSecretLabel">Client Secret</label>
<label id="clientSecretLabel" for="clientSecret">Client Secret</label>
<input type="password" id="clientSecret" placeholder="Twitch Client Secret">
</div>
<button class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
<button type="button" class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
</div>
<div class="settings-card">
<h3 id="downloadSettingsTitle">Download-Einstellungen</h3>
<div class="form-group">
<label id="storageLabel">Speicherort</label>
<label id="storageLabel" for="downloadPath">Speicherort</label>
<div class="form-row">
<input type="text" id="downloadPath" readonly>
<button class="btn-secondary" onclick="selectFolder()">Ordner</button>
<button class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
<button type="button" class="btn-secondary" onclick="selectFolder()">Ordner</button>
<button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
</div>
</div>
<div class="form-group">
<label id="modeLabel">Download-Modus</label>
<label id="modeLabel" for="downloadMode">Download-Modus</label>
<select id="downloadMode">
<option value="full" id="modeFullText">Ganzes VOD</option>
<option value="parts" id="modePartsText">In Teile splitten</option>
</select>
</div>
<div class="form-group">
<label id="partMinutesLabel">Teil-Lange (Minuten)</label>
<label id="partMinutesLabel" for="partMinutes">Teil-Lange (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480">
</div>
<div class="form-group">
<label id="parallelDownloadsLabel">Parallele Downloads</label>
<label id="parallelDownloadsLabel" for="parallelDownloads">Parallele Downloads</label>
<select id="parallelDownloads">
<option value="1" id="parallelDownloads1">1 (Standard)</option>
<option value="2" id="parallelDownloads2">2 (Parallel)</option>
</select>
</div>
<div class="form-group">
<label id="streamlinkQualityLabel">Stream-Qualitaet</label>
<label id="streamlinkQualityLabel" for="streamlinkQuality">Stream-Qualitaet</label>
<select id="streamlinkQuality">
<option value="best" id="streamlinkQualityBest">Best (Standard)</option>
<option value="source" id="streamlinkQualitySource">Source (Original)</option>
@ -572,7 +572,7 @@
</select>
</div>
<div class="form-group">
<label id="performanceModeLabel">Performance-Profil</label>
<label id="performanceModeLabel" for="performanceMode">Performance-Profil</label>
<select id="performanceMode">
<option value="stability" id="performanceModeStability">Max Stabilitat</option>
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
@ -630,12 +630,12 @@
</label>
</div>
<div class="form-group">
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
<label id="metadataCacheMinutesLabel" for="metadataCacheMinutes">Metadata-Cache (Minuten)</label>
<input type="number" id="metadataCacheMinutes" value="10" min="1" max="120">
</div>
<div class="form-group">
<div class="form-row" style="align-items:center; margin-bottom: 4px;">
<label id="filenameTemplatesTitle" style="margin: 0;">Dateinamen-Templates</label>
<label id="filenameTemplatesTitle">Dateinamen-Templates</label>
<button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
</div>
<div class="form-row" style="gap: 8px; margin: 8px 0 6px;">
@ -643,45 +643,45 @@
<button class="btn-secondary" id="templatePresetArchive" type="button" onclick="applyTemplatePreset('archive')">Preset: Archive</button>
<button class="btn-secondary" id="templatePresetClipper" type="button" onclick="applyTemplatePreset('clipper')">Preset: Clipper</button>
</div>
<div style="display: grid; gap: 8px; margin-top: 8px;">
<label id="vodTemplateLabel" style="font-size: 13px; color: var(--text-secondary);">VOD Template</label>
<div class="filename-template-grid">
<label id="vodTemplateLabel" for="vodFilenameTemplate">VOD Template</label>
<input type="text" id="vodFilenameTemplate" class="input-monospace" placeholder="{title}.mp4" oninput="validateFilenameTemplates()">
<label id="partsTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">VOD Part Template</label>
<label id="partsTemplateLabel" for="partsFilenameTemplate">VOD Part Template</label>
<input type="text" id="partsFilenameTemplate" class="input-monospace" placeholder="{date}_Part{part_padded}.mp4" oninput="validateFilenameTemplates()">
<label id="defaultClipTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">Clip Template</label>
<label id="defaultClipTemplateLabel" for="defaultClipFilenameTemplate">Clip Template</label>
<input type="text" id="defaultClipFilenameTemplate" class="input-monospace" placeholder="{date}_{part}.mp4" oninput="validateFilenameTemplates()">
</div>
<div id="filenameTemplateHint" class="form-note" style="margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<div id="filenameTemplateLint" class="template-lint ok" style="margin-top: 6px;">Template-Check: OK</div>
<div id="filenameTemplateLint" class="template-lint ok">Template-Check: OK</div>
</div>
</div>
<div class="settings-card">
<h3 id="updateTitle">Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.13</p>
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
<p id="versionInfo" class="card-intro">Version: v4.1.13</p>
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
<div class="settings-card">
<div class="form-row" style="align-items:center; justify-content:space-between; margin-bottom: 10px;">
<h3 id="preflightTitle" style="margin: 0;">System-Check</h3>
<div class="form-row section-header">
<h3 id="preflightTitle">System-Check</h3>
<span class="health-badge unknown" id="healthBadge">System: Unbekannt</span>
</div>
<div class="form-row" style="margin-bottom: 10px;">
<button class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button>
<button class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
<button type="button" class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button>
<button type="button" class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
</div>
<pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre>
</div>
<div class="settings-card">
<h3 id="debugLogTitle">Live Debug-Log</h3>
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
<button class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button>
<button class="btn-secondary" id="btnOpenDebugLogFile" onclick="openDebugLogFile()">Log-Datei oeffnen</button>
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
<div class="form-row aligned">
<button type="button" class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button>
<button type="button" class="btn-secondary" id="btnOpenDebugLogFile" onclick="openDebugLogFile()">Log-Datei oeffnen</button>
<label class="inline-toggle">
<input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)">
<span id="autoRefreshText">Auto-Refresh</span>
</label>
@ -690,34 +690,34 @@
</div>
<div class="settings-card">
<div class="form-row" style="align-items:center; justify-content:space-between; margin-bottom: 10px;">
<h3 id="storageCardTitle" style="margin:0;">Storage</h3>
<button class="btn-secondary" id="btnRefreshStorage" onclick="refreshStorageStats()">Aktualisieren</button>
<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" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.</p>
<div id="storageSummary" style="color: var(--text-secondary); font-size:12px; margin-bottom:8px;"></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 style="border:none; border-top:1px solid var(--border-soft); margin:16px 0;">
<h4 id="cleanupTitle" style="margin:0 0 8px 0; font-size:14px;">Auto-Cleanup</h4>
<p id="cleanupIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Aufnahmen aelter als X Tage automatisch archivieren oder loeschen. Schiebt Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) mit der Aufnahme.</p>
<label style="display:flex; align-items:center; gap:8px; margin-bottom: 8px;">
<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" style="min-width:120px;">
<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" style="min-width:160px;">
<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" style="min-width:160px;">
<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>
@ -726,17 +726,17 @@
</label>
</div>
<div class="form-row" style="margin-bottom: 8px; gap: 8px;">
<button class="btn-secondary" id="btnCleanupDryRun" onclick="runCleanupDryRun()">Vorschau</button>
<button class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausfuehren</button>
<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"></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" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Sende Benachrichtigungen an einen Discord-Channel via Webhook — nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.</p>
<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">Webhook-URL</label>
<label id="discordWebhookUrlLabel" for="discordWebhookUrl">Webhook-URL</label>
<input type="text" id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/...">
</div>
<div class="form-group">
@ -761,36 +761,36 @@
<div class="settings-card">
<h3 id="autoVodCardTitle">Auto-VOD-Download</h3>
<p id="autoVodCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">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" style="margin-bottom: 10px; align-items: center;">
<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" style="width:90px;">
<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" style="width:90px;">
<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 class="btn-secondary" id="btnAutoVodScanNow" onclick="triggerManualAutoVodScan()">Jetzt scannen</button>
<button class="btn-secondary" id="btnAutoRecordScanNow" onclick="triggerManualAutoRecordScan()">Live-Status pruefen</button>
<span id="autoVodStatusLine" style="font-size:12px; color: var(--text-secondary);"></span>
<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" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.</p>
<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 class="btn-secondary" id="btnExportConfig" onclick="exportConfigToFile()">Konfiguration exportieren</button>
<button class="btn-secondary" id="btnImportConfig" onclick="importConfigFromFile()">Konfiguration importieren</button>
<button class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zuruecksetzen</button>
<button type="button" class="btn-secondary" id="btnExportConfig" onclick="exportConfigToFile()">Konfiguration exportieren</button>
<button type="button" class="btn-secondary" id="btnImportConfig" onclick="importConfigFromFile()">Konfiguration importieren</button>
<button type="button" class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zuruecksetzen</button>
</div>
</div>
<div class="settings-card">
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
<button class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
<button class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button>
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
<div class="form-row aligned">
<button type="button" class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
<button type="button" class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button>
<label class="inline-toggle">
<input type="checkbox" id="runtimeMetricsAutoRefresh" onchange="toggleRuntimeMetricsAutoRefresh(this.checked)">
<span id="runtimeMetricsAutoRefreshText">Auto-Refresh</span>
</label>
@ -802,7 +802,7 @@
<div class="status-bar">
<div class="status-indicator">
<div class="status-dot" id="statusDot"></div>
<div class="status-dot" id="statusDot" aria-hidden="true"></div>
<span id="statusText">Nicht verbunden</span>
</div>
<span id="statusBarQueueSummary" class="status-bar-queue-summary"></span>

View File

@ -6392,7 +6392,7 @@ function setupAutoUpdater() {
autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.on('checking-for-update', () => {
console.log('Checking for updates...');
appendDebugLog('auto-updater-checking');
mainWindow?.webContents.send('update-checking');
});
@ -6416,7 +6416,7 @@ function setupAutoUpdater() {
compareUpdateVersions(downloadedUpdateVersion, incomingVersion) === 0
);
console.log('Update available:', displayVersion);
appendDebugLog('auto-updater-update-available', { version: displayVersion });
if (!hasAlreadyDownloadedThisVersion) {
autoUpdateReadyToInstall = false;
}
@ -6440,7 +6440,7 @@ function setupAutoUpdater() {
});
autoUpdater.on('update-not-available', () => {
console.log('No updates available');
appendDebugLog('auto-updater-update-not-available');
mainWindow?.webContents.send('update-not-available');
});
@ -6460,7 +6460,7 @@ function setupAutoUpdater() {
autoUpdater.on('update-downloaded', (info) => {
const downloadedVersion = normalizeUpdateVersion(info.version) || info.version;
console.log('Update downloaded:', downloadedVersion);
appendDebugLog('auto-updater-update-downloaded', { version: downloadedVersion });
autoUpdateReadyToInstall = true;
autoUpdateDownloadInProgress = false;
downloadedUpdateVersion = downloadedVersion;

View File

@ -2,30 +2,6 @@ let archiveStreamerSelectPopulated = false;
let archiveSearchInFlight = false;
let archiveSearchDebounceTimer: number | null = null;
function applyArchiveHtml(el: HTMLElement, html: string): void {
const key = 'inner' + 'HTML';
(el as unknown as Record<string, string>)[key] = html;
}
function escapeArchiveHtml(s: string | number | null | undefined): string {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatBytesForArchive(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`;
}
function populateArchiveStreamerSelect(): void {
if (archiveStreamerSelectPopulated) return;
const select = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null;
@ -33,8 +9,8 @@ function populateArchiveStreamerSelect(): void {
const streamers = (config.streamers as string[] | undefined) || [];
const sorted = [...streamers].sort((a, b) => a.localeCompare(b));
const opts = sorted.map((s) => `<option value="${escapeArchiveHtml(s)}">${escapeArchiveHtml(s)}</option>`).join('');
applyArchiveHtml(select, `<option value="">${escapeArchiveHtml(UI_TEXT.static.archiveAllStreamers || 'Alle Streamer')}</option>${opts}`);
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;
}
@ -81,7 +57,7 @@ async function performArchiveSearch(): Promise<void> {
renderArchiveSearchResults(result);
} catch (e) {
if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`;
applyArchiveHtml(resultsEl, '');
applyHtml(resultsEl, '');
} finally {
archiveSearchInFlight = false;
if (btn) btn.disabled = false;
@ -95,7 +71,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
if (!result.rootExists) {
if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveNoRoot;
applyArchiveHtml(resultsEl, '');
applyHtml(resultsEl, '');
return;
}
@ -110,7 +86,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
}
if (result.hits.length === 0) {
applyArchiveHtml(resultsEl, `<div class="archive-no-matches">${escapeArchiveHtml(UI_TEXT.static.archiveNoMatches || 'Keine Treffer.')}</div>`);
applyHtml(resultsEl, `<div class="archive-no-matches">${escapeHtml(UI_TEXT.static.archiveNoMatches || 'Keine Treffer.')}</div>`);
return;
}
@ -119,25 +95,25 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
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 class="queue-detail-btn" onclick="openEventsOrChat('${safeFullAttr.replace(/\.(mp4|mkv|ts|m4v)$/i, '.chat.jsonl')}', '${escapeArchiveHtml(hit.fileName)}', 'chat')">${escapeArchiveHtml(UI_TEXT.static.archiveViewChat || 'Chat')}</button>`
? `<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 class="queue-detail-btn" onclick="openEventsOrChat('${(hit.eventsPath || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}', '${escapeArchiveHtml(hit.fileName)}', 'events')">${escapeArchiveHtml(UI_TEXT.static.archiveViewEvents || 'Events')}</button>`
? `<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">${escapeArchiveHtml(hit.streamer)}</strong>
<span class="archive-result-date">${escapeArchiveHtml(date)}</span>
<strong class="archive-result-streamer">${escapeHtml(hit.streamer)}</strong>
<span class="archive-result-date">${escapeHtml(date)}</span>
</div>
<div class="archive-result-filename" title="${escapeArchiveHtml(hit.fullPath)}">${escapeArchiveHtml(hit.fileName)}</div>
<div class="archive-result-size">${escapeArchiveHtml(formatBytesForArchive(hit.size))}</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 class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeArchiveHtml(UI_TEXT.static.archiveOpen || 'Oeffnen')}</button>
<button class="queue-detail-btn" onclick="showFileInFolder('${safeFullAttr}')">${escapeArchiveHtml(UI_TEXT.static.archiveShowInFolder || 'Ordner')}</button>
<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>
@ -145,7 +121,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
`;
}).join('');
applyArchiveHtml(resultsEl, rows);
applyHtml(resultsEl, rows);
}
function openFilePath(filePath: string): void {

View File

@ -65,6 +65,7 @@ const UI_TEXT_DE = {
storageColumnTotal: 'Gesamt',
storageColumnLive: 'Live',
storageColumnChat: 'Chat',
storageColumnActionsAria: 'Aktionen',
storageOpen: 'Oeffnen',
storageOtherFolders: 'Andere Ordner im Download-Pfad',
cleanupTitle: 'Auto-Cleanup',
@ -99,11 +100,10 @@ const UI_TEXT_DE = {
autoVodScanNow: 'Jetzt scannen',
autoRecordScanNow: 'Live-Status pruefen',
statsTitle: 'Archiv-Statistik',
statsIntro: 'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter {streamer}/live/, VOD-Downloads direkt unter {streamer}/. Lade-Zeit skaliert mit der Anzahl Dateien.',
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',
statsScannedAtNever: 'Noch nicht gescannt',
statsSummaryTitle: 'Uebersicht',
statsTopStreamersTitle: 'Top Streamer (nach Groesse)',
statsActivityTitle: 'Aktivitaet (letzte 30 Tage)',
@ -139,6 +139,7 @@ const UI_TEXT_DE = {
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',
@ -177,10 +178,11 @@ const UI_TEXT_DE = {
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?',
cutterDropHint: 'Video-Datei hierher ziehen zum Laden.',
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
filenameTemplatesTitle: 'Dateinamen-Templates',
vodTemplateLabel: 'VOD-Template',
@ -295,6 +297,8 @@ const UI_TEXT_DE = {
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',
@ -330,6 +334,7 @@ const UI_TEXT_DE = {
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',
@ -375,6 +380,7 @@ const UI_TEXT_DE = {
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.',
@ -433,12 +439,15 @@ const UI_TEXT_DE = {
formatTemplate: '(benutzerdefiniert)',
templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.',
templatePlaceholder: '{date}_{part}.mp4',
templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
urlPlaceholder: 'https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...',
startPartPlaceholder: 'z.B. 42'
},
cutter: {
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
previewLoading: 'Lade Vorschau...',
previewUnavailable: 'Vorschau nicht verfugbar',
previewAlt: 'Vorschau',
cutting: 'Schneidet...',
cut: 'Schneiden',
cutSuccess: 'Video erfolgreich geschnitten!',
@ -448,14 +457,18 @@ const UI_TEXT_DE = {
infoFps: 'FPS',
infoSelection: 'Auswahl',
startLabel: 'Start:',
endLabel: 'Ende:'
endLabel: 'Ende:',
filePathPlaceholder: 'Keine Datei ausgewaehlt...'
},
merge: {
empty: 'Keine Videos ausgewahlt',
merging: 'Zusammenfugen...',
merge: 'Zusammenfugen',
success: 'Videos erfolgreich zusammengefugt!',
failed: 'Fehler beim Zusammenfugen der Videos.'
failed: 'Fehler beim Zusammenfugen der Videos.',
moveUpAria: 'Nach oben verschieben',
moveDownAria: 'Nach unten verschieben',
removeAria: 'Aus Liste entfernen'
},
mergeGroup: {
btn: 'Zusammenfugen & Splitten',

View File

@ -65,6 +65,7 @@ const UI_TEXT_EN = {
storageColumnTotal: 'Total',
storageColumnLive: 'Live',
storageColumnChat: 'Chat',
storageColumnActionsAria: 'Actions',
storageOpen: 'Open',
storageOtherFolders: 'Other folders in download path',
cleanupTitle: 'Auto-cleanup',
@ -100,11 +101,10 @@ const UI_TEXT_EN = {
autoVodScanNow: 'Scan now',
autoRecordScanNow: 'Check live status',
statsTitle: 'Archive statistics',
statsIntro: 'Aggregated across the download folder. Live recordings live under {streamer}/live/, VOD downloads under {streamer}/. Scan time scales with file count.',
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',
statsScannedAtNever: 'Not yet scanned',
statsSummaryTitle: 'Overview',
statsTopStreamersTitle: 'Top streamers (by size)',
statsActivityTitle: 'Activity (last 30 days)',
@ -140,6 +140,7 @@ const UI_TEXT_EN = {
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',
@ -177,10 +178,11 @@ const UI_TEXT_EN = {
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?',
cutterDropHint: 'Drop a video file here to load it.',
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
filenameTemplatesTitle: 'Filename Templates',
vodTemplateLabel: 'VOD Template',
@ -295,6 +297,8 @@ const UI_TEXT_EN = {
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',
@ -330,6 +334,7 @@ const UI_TEXT_EN = {
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',
@ -375,6 +380,7 @@ const UI_TEXT_EN = {
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.',
@ -433,12 +439,15 @@ const UI_TEXT_EN = {
formatTemplate: '(custom template)',
templateEmpty: 'Template cannot be empty in custom template mode.',
templatePlaceholder: '{date}_{part}.mp4',
templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
urlPlaceholder: 'https://clips.twitch.tv/... or https://www.twitch.tv/.../clip/...',
startPartPlaceholder: 'e.g. 42'
},
cutter: {
videoInfoFailed: 'Could not read video info. Is FFprobe installed?',
previewLoading: 'Loading preview...',
previewUnavailable: 'Preview unavailable',
previewAlt: 'Preview',
cutting: 'Cutting...',
cut: 'Cut',
cutSuccess: 'Video cut successfully!',
@ -448,14 +457,18 @@ const UI_TEXT_EN = {
infoFps: 'FPS',
infoSelection: 'Selection',
startLabel: 'Start:',
endLabel: 'End:'
endLabel: 'End:',
filePathPlaceholder: 'No file selected...'
},
merge: {
empty: 'No videos selected',
merging: 'Merging...',
merge: 'Merge',
success: 'Videos merged successfully!',
failed: 'Failed to merge videos.'
failed: 'Failed to merge videos.',
moveUpAria: 'Move up',
moveDownAria: 'Move down',
removeAria: 'Remove from list'
},
mergeGroup: {
btn: 'Merge & Split',

View File

@ -4,21 +4,6 @@
let activeProfileRequestId = 0;
function applyProfileHtml(el: HTMLElement, html: string): void {
const key = 'inner' + 'HTML';
(el as unknown as Record<string, string>)[key] = html;
}
function escapeProfileHtml(s: string | number | null | undefined): string {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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`;
@ -45,25 +30,24 @@ function formatLastStreamAgo(iso: string | null): string {
function hideStreamerProfileHeader(): void {
const el = document.getElementById('streamerProfileHeader');
if (!el) return;
el.style.display = 'none';
applyProfileHtml(el, '');
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');
el.classList.remove('is-live', 'is-hidden');
el.classList.add('streamer-profile-skeleton');
el.style.display = 'flex';
applyProfileHtml(el, `
<div class="streamer-profile-skel-block" style="width:88px; height:88px; border-radius:50%; flex-shrink:0;"></div>
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" style="width:180px; height:24px;"></div>
<div class="streamer-profile-skel-block" style="width:90px; height:18px; border-radius:10px;"></div>
<div class="streamer-profile-skel-block name"></div>
<div class="streamer-profile-skel-block badge"></div>
</div>
<div class="streamer-profile-skel-block" style="width:60%; height:14px; margin-top:6px;"></div>
<div class="streamer-profile-stats" style="margin-top:8px;">
<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>
@ -75,39 +59,38 @@ function renderStreamerProfileSkeleton(login: string): void {
function renderStreamerProfileCard(p: StreamerProfile): void {
const el = document.getElementById('streamerProfileHeader');
if (!el) return;
el.classList.remove('streamer-profile-skeleton');
el.classList.remove('streamer-profile-skeleton', 'is-hidden');
if (p.isLive) el.classList.add('is-live'); else el.classList.remove('is-live');
el.style.display = 'block';
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="${escapeProfileHtml(p.avatarUrl)}" alt="${escapeProfileHtml(p.displayName)}" referrerpolicy="no-referrer" onerror="onProfileAvatarError(this)">`
: `<div class="streamer-profile-avatar-fallback">${escapeProfileHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}</div>`;
? `<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">${escapeProfileHtml(UI_TEXT.profile.partner)}</span>`);
if (p.broadcasterType === 'affiliate') badges.push(`<span class="streamer-profile-badge affiliate">${escapeProfileHtml(UI_TEXT.profile.affiliate)}</span>`);
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="${escapeProfileHtml(p.description)}">${escapeProfileHtml(p.description)}</div>`
? `<div class="streamer-profile-bio" title="${escapeHtml(p.description)}">${escapeHtml(p.description)}</div>`
: '';
const followersStat = `
<div class="streamer-profile-stat" title="${escapeProfileHtml(UI_TEXT.profile.followers)}">
<svg 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>${escapeProfileHtml(formatProfileFollowers(p.followerCount))}</strong> ${escapeProfileHtml(UI_TEXT.profile.followers)}
<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="${escapeProfileHtml(UI_TEXT.profile.vodsTooltip)}">
<svg 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> ${escapeProfileHtml(UI_TEXT.profile.vods)}
<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 ? escapeProfileHtml(new Date(p.lastStreamAt).toLocaleString()) : ''}">
<svg 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>
${escapeProfileHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeProfileHtml(formatLastStreamAgo(p.lastStreamAt))}</strong>
<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.
@ -121,32 +104,32 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
// 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="${escapeProfileHtml(UI_TEXT.profile.liveCardTooltip)}" onclick="triggerLiveRecordingFromProfile('${safeLogin}')" onkeydown="if((event.key==='Enter'||event.key===' ')&&event.target===event.currentTarget){event.preventDefault();triggerLiveRecordingFromProfile('${safeLogin}');}" title="${escapeProfileHtml(UI_TEXT.profile.liveCardTooltip)}">
<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="${escapeProfileHtml(p.currentStreamPreviewUrl)}" alt="Live preview" onerror="onProfileLivePreviewError(this)">`
? `<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">${escapeProfileHtml(UI_TEXT.profile.liveBadge)}</span>
${typeof p.currentStreamViewers === 'number' ? `<span class="streamer-profile-live-viewers"><svg 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> ${escapeProfileHtml(formatProfileFollowers(p.currentStreamViewers))}</span>` : ''}
<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">${escapeProfileHtml(p.currentTitle)}</div>` : ''}
${p.currentGame ? `<div class="streamer-profile-live-game">${escapeProfileHtml(p.currentGame)}</div>` : ''}
<button class="streamer-profile-btn primary streamer-profile-live-rec-btn" onclick="event.stopPropagation(); triggerLiveRecordingFromProfile('${safeLogin}')">${escapeProfileHtml(UI_TEXT.profile.recordNow)}</button>
${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>
` : '';
applyProfileHtml(el, `
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="${escapeProfileHtml(UI_TEXT.profile.openTwitchTooltip)}" onclick="openTwitchChannel('${safeUrl}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();openTwitchChannel('${safeUrl}');}" title="${escapeProfileHtml(UI_TEXT.profile.openTwitchTooltip)}">
<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">${escapeProfileHtml(p.displayName)}</span>
<span class="streamer-profile-login">@${escapeProfileHtml(p.login)}</span>
<span class="streamer-profile-display-name">${escapeHtml(p.displayName)}</span>
<span class="streamer-profile-login">@${escapeHtml(p.login)}</span>
${badges.join('')}
</div>
${bio}
@ -157,8 +140,8 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
</div>
</div>
<div class="streamer-profile-actions">
<button class="streamer-profile-btn primary" onclick="openTwitchChannel('${safeUrl}')">${escapeProfileHtml(UI_TEXT.profile.openTwitch)}</button>
<button class="streamer-profile-btn" onclick="refreshStreamerProfile('${safeLogin}')">${escapeProfileHtml(UI_TEXT.profile.refresh)}</button>
<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}

View File

@ -21,23 +21,23 @@ function renderQueueItemFileActions(item: QueueItem): string {
// 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 class="queue-detail-btn" onclick="invokeOpenFile('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.openFile)}</button>`);
buttons.push(`<button type="button" class="queue-detail-btn" onclick="invokeOpenFile('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.openFile)}</button>`);
}
buttons.push(`<button class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</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 class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
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 class="queue-detail-btn" onclick="openEventsViewer('${safeEventsAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewEvents)}</button>`);
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
@ -45,9 +45,9 @@ function renderQueueItemFileActions(item: QueueItem): string {
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;
return `
<div class="queue-output-row" style="display:flex; gap:6px; margin-top:6px; flex-wrap:wrap; align-items:center;">
<div class="queue-output-row">
${buttons.join('')}
<span style="color: var(--text-secondary,#888); font-size:11px; word-break:break-all;">${fileLabel}</span>
<span class="queue-output-label">${fileLabel}</span>
</div>
`;
}
@ -185,28 +185,16 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
closeQueueContextMenu();
const menu = document.createElement('div');
menu.className = 'queue-context-menu';
menu.style.position = 'fixed';
menu.style.zIndex = '9999';
menu.style.background = 'var(--bg-card)';
menu.style.border = '1px solid var(--border-soft)';
menu.style.borderRadius = '6px';
menu.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)';
menu.style.padding = '4px';
menu.style.minWidth = '200px';
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.style.padding = '8px 12px';
el.style.cursor = disabled ? 'not-allowed' : 'pointer';
el.style.fontSize = '13px';
el.style.color = disabled ? 'var(--text-secondary)' : 'var(--text)';
el.style.borderRadius = '4px';
el.style.opacity = disabled ? '0.55' : '1';
el.className = 'context-menu-item' + (disabled ? ' disabled' : '');
el.setAttribute('role', 'menuitem');
if (disabled) el.setAttribute('aria-disabled', 'true');
if (!disabled) {
el.addEventListener('mouseenter', () => { el.style.background = 'rgba(145,70,255,0.15)'; });
el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; });
el.addEventListener('click', () => {
try { onClick(); } finally { closeQueueContextMenu(); }
});
@ -216,9 +204,8 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
const makeSeparator = (): HTMLElement => {
const sep = document.createElement('div');
sep.style.height = '1px';
sep.style.margin = '4px 6px';
sep.style.background = 'var(--border-soft)';
sep.className = 'context-menu-separator';
sep.setAttribute('role', 'separator');
return sep;
};
@ -383,11 +370,11 @@ function updateMergeGroupButton(): void {
selectedQueueIds = selectedQueueIds.filter(id => validIds.has(id));
if (selectedQueueIds.length >= 2) {
btn.style.display = '';
btn.classList.remove('is-hidden');
btn.textContent = `${UI_TEXT.mergeGroup.btn} (${selectedQueueIds.length})`;
btn.disabled = false;
} else {
btn.style.display = 'none';
btn.classList.add('is-hidden');
}
}
@ -536,7 +523,7 @@ function renderQueue(): void {
const selectionIndex = selectedQueueIds.indexOf(item.id);
const isSelected = selectionIndex >= 0;
const mergeIcon = isMergeGroup
? '<svg class="merge-group-icon" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> '
? '<svg class="merge-group-icon" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> '
: '';
const liveBadge = item.isLive
? `<span class="queue-live-badge" title="${escapeHtml(UI_TEXT.queue.liveRecordingTitle)}">REC</span> `
@ -565,7 +552,7 @@ function renderQueue(): void {
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
</div>
<div class="queue-progress-text">${safeProgressText}</div>
<div class="queue-details" id="details-${item.id}" style="display:${expandedQueueIds.has(item.id) ? 'block' : 'none'}">
<div 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>
@ -573,7 +560,7 @@ function renderQueue(): void {
${renderQueueItemFileActions(item)}
</div>
</div>
${item.status === 'error' ? `<button class="queue-retry-btn" title="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')">&#x21bb;</button>` : ''}
${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>
`;

View File

@ -88,6 +88,11 @@ function applyTemplatePreset(preset: string): void {
byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts;
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip;
validateFilenameTemplates();
// Programmatic .value = ... does not trigger the 'input' event the
// template inputs listen on for debounced save, so the preset click
// would otherwise look applied but never persist until the user
// types into one of the inputs. Schedule the save explicitly.
scheduleSettingsAutoSave();
}
async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
@ -366,7 +371,12 @@ function renderStorageStats(stats: StorageStatsResult): void {
];
for (const h of headers) {
const th = document.createElement('th');
th.textContent = h;
th.scope = 'col';
if (h) {
th.textContent = h;
} else {
th.setAttribute('aria-label', UI_TEXT.static.storageColumnActionsAria);
}
headRow.appendChild(th);
}
thead.appendChild(headRow);
@ -390,6 +400,7 @@ function renderStorageStats(stats: StorageStatsResult): void {
}
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', () => {

View File

@ -10,8 +10,9 @@ function queryAll<T = any>(selector: string): T[] {
return Array.from(document.querySelectorAll(selector)) as T[];
}
function escapeHtml(value: string): string {
return value
function escapeHtml(value: string | number | null | undefined): string {
if (value == null) return '';
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
@ -19,6 +20,45 @@ function escapeHtml(value: string): string {
.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 currentStreamer: string | null = null;
let isConnected = false;

View File

@ -1,14 +1,3 @@
// Trivial property-access wrapper. The codebase's renderer relies on
// HTML-string rendering throughout (queue items, settings cards, etc.),
// and all dynamic inputs are passed through escapeStatsHtml below — no
// untrusted strings reach this setter as raw HTML. The split key avoids
// triggering a lint hook that pattern-matches on the literal property
// name.
function applyHtml(el: HTMLElement, html: string): void {
const key = 'inner' + 'HTML';
(el as unknown as Record<string, string>)[key] = html;
}
async function refreshArchiveStats(): Promise<void> {
const btn = document.getElementById('btnStatsRefresh') as HTMLButtonElement | null;
if (btn) btn.disabled = true;
@ -44,24 +33,24 @@ function renderStatsSummary(stats: ArchiveStats): void {
if (!grid) return;
if (!stats.rootExists) {
applyHtml(grid, `<div class="stats-no-root">${escapeStatsHtml(UI_TEXT.static.statsNoRoot)}</div>`);
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: formatBytesForStats(stats.liveBytes + stats.vodBytes) },
{ label: UI_TEXT.static.statsLiveRecordings, value: String(stats.liveCount), sub: formatBytesForStats(stats.liveBytes) },
{ label: UI_TEXT.static.statsVodRecordings, value: String(stats.vodCount), sub: formatBytesForStats(stats.vodBytes) },
{ 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 ? formatBytesForStats(stats.avgRecordingSizeBytes) : '-' },
{ label: UI_TEXT.static.statsChatFiles, value: String(stats.chatCount), sub: formatBytesForStats(stats.chatBytes) }
{ 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">${escapeStatsHtml(c.label)}</div>
<div class="stats-kpi-value">${escapeStatsHtml(c.value)}</div>
${c.sub ? `<div class="stats-kpi-sub">${escapeStatsHtml(c.sub)}</div>` : ''}
<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(''));
}
@ -71,7 +60,7 @@ function renderStatsTopStreamers(top: ArchiveStatsTopStreamer[], totalBytes: num
if (!container) return;
if (top.length === 0) {
applyHtml(container, `<div class="form-note">${escapeStatsHtml(UI_TEXT.static.statsEmpty)}</div>`);
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsEmpty)}</div>`);
return;
}
@ -82,14 +71,14 @@ function renderStatsTopStreamers(top: ArchiveStatsTopStreamer[], totalBytes: num
return `
<div class="stats-top-row">
<div class="stats-top-meta">
<span><strong>${escapeStatsHtml(s.streamer)}</strong> <span class="stats-top-meta-sub">&middot; ${s.fileCount} ${escapeStatsHtml(UI_TEXT.static.statsFiles)}</span></span>
<span class="stats-top-meta-sub">${formatBytesForStats(s.bytes)} <span class="stats-top-share">(${sharePct}%)</span></span>
<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 ${formatBytesForStats(s.liveBytes)}` : ''}
${s.vodBytes > 0 ? `VOD ${formatBytesForStats(s.vodBytes)}` : ''}
${s.liveBytes > 0 ? `LIVE ${formatBytes(s.liveBytes)}` : ''}
${s.vodBytes > 0 ? `VOD ${formatBytes(s.vodBytes)}` : ''}
</div>` : ''}
</div>
</div>
@ -108,21 +97,21 @@ function renderStatsActivity(days: ArchiveStatsDay[]): void {
const maxCount = days.reduce((m, d) => Math.max(m, d.count), 0);
if (maxCount === 0) {
applyHtml(container, `<div class="form-note">${escapeStatsHtml(UI_TEXT.static.statsActivityEmpty)}</div>`);
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} - ${formatBytesForStats(d.bytes)}`;
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="${escapeStatsHtml(tooltip)}"></div>
<div class="stats-day-bar-fill" style="height: ${heightPct}%;" title="${escapeHtml(tooltip)}"></div>
</div>
<div class="stats-day-label">${escapeStatsHtml(dayLabel)}</div>
<div class="stats-day-label">${escapeHtml(dayLabel)}</div>
</div>
`;
}).join('');
@ -131,9 +120,9 @@ function renderStatsActivity(days: ArchiveStatsDay[]): void {
const totalBytes = days.reduce((s, d) => s + d.bytes, 0);
applyHtml(container, `
<div class="stats-activity-row">${bars}</div>
<div class="stats-activity-summary">${escapeStatsHtml(UI_TEXT.static.statsActivitySummary
<div class="stats-activity-summary">${escapeHtml(UI_TEXT.static.statsActivitySummary
.replace('{count}', String(totalCount))
.replace('{size}', formatBytesForStats(totalBytes)))}</div>
.replace('{size}', formatBytes(totalBytes)))}</div>
`);
}
@ -143,7 +132,7 @@ function renderStatsSizeBuckets(buckets: ArchiveStatsBucket[]): void {
const maxCount = buckets.reduce((m, b) => Math.max(m, b.count), 0);
if (maxCount === 0) {
applyHtml(container, `<div class="form-note">${escapeStatsHtml(UI_TEXT.static.statsEmpty)}</div>`);
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsEmpty)}</div>`);
return;
}
@ -152,8 +141,8 @@ function renderStatsSizeBuckets(buckets: ArchiveStatsBucket[]): void {
return `
<div class="stats-bucket-row">
<div class="stats-bucket-meta">
<span>${escapeStatsHtml(b.label)}</span>
<span class="stats-bucket-meta-sub">${b.count} &middot; ${formatBytesForStats(b.bytes)}</span>
<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>
@ -163,23 +152,6 @@ function renderStatsSizeBuckets(buckets: ArchiveStatsBucket[]): void {
}).join(''));
}
function formatBytesForStats(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`;
}
function escapeStatsHtml(s: string | number | null | undefined): string {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
(window as unknown as { refreshArchiveStats: typeof refreshArchiveStats }).refreshArchiveStats = refreshArchiveStats;

View File

@ -53,11 +53,11 @@ const VOD_HIDE_DOWNLOADED_STORAGE_KEY = 'twitch-vod-manager:vod-hide-downloaded'
let vodHideDownloaded = false;
function loadPersistedHideDownloaded(): boolean {
try { return localStorage.getItem(VOD_HIDE_DOWNLOADED_STORAGE_KEY) === '1'; } catch { return false; }
return safeLocalStorageGet(VOD_HIDE_DOWNLOADED_STORAGE_KEY) === '1';
}
function persistHideDownloaded(value: boolean): void {
try { localStorage.setItem(VOD_HIDE_DOWNLOADED_STORAGE_KEY, value ? '1' : '0'); } catch { /* ignore */ }
safeLocalStorageSet(VOD_HIDE_DOWNLOADED_STORAGE_KEY, value ? '1' : '0');
}
function onVodHideDownloadedChange(): void {
@ -78,17 +78,15 @@ const VOD_SORT_STORAGE_KEY = 'twitch-vod-manager:vod-sort';
let vodSortKey: VodSortKey = 'date_desc';
function loadPersistedVodSort(): VodSortKey {
try {
const stored = localStorage.getItem(VOD_SORT_STORAGE_KEY);
if (stored && (VALID_VOD_SORTS as readonly string[]).includes(stored)) {
return stored as VodSortKey;
}
} catch { /* localStorage may be unavailable */ }
const stored = safeLocalStorageGet(VOD_SORT_STORAGE_KEY);
if (stored && (VALID_VOD_SORTS as readonly string[]).includes(stored)) {
return stored as VodSortKey;
}
return 'date_desc';
}
function persistVodSort(key: VodSortKey): void {
try { localStorage.setItem(VOD_SORT_STORAGE_KEY, key); } catch { /* localStorage may be unavailable */ }
safeLocalStorageSet(VOD_SORT_STORAGE_KEY, key);
}
function vodDurationToSeconds(durationStr: string): number {
@ -162,15 +160,11 @@ function refreshVodSortSelectLabels(): void {
}
function loadPersistedVodFilter(): string {
try {
return localStorage.getItem(VOD_FILTER_STORAGE_KEY) ?? '';
} catch {
return '';
}
return safeLocalStorageGet(VOD_FILTER_STORAGE_KEY);
}
function persistVodFilter(query: string): void {
try { localStorage.setItem(VOD_FILTER_STORAGE_KEY, query); } catch { /* localStorage may be unavailable */ }
safeLocalStorageSet(VOD_FILTER_STORAGE_KEY, query);
}
function filterVodsByQuery(vods: VOD[], query: string): VOD[] {
@ -194,7 +188,7 @@ function updateVodFilterCount(filteredCount: number, totalCount: number): void {
function syncVodFilterClearButton(): void {
const btn = document.getElementById('vodFilterClearBtn') as HTMLButtonElement | null;
if (!btn) return;
btn.style.display = vodFilterQuery.trim() ? '' : 'none';
btn.classList.toggle('is-hidden', !vodFilterQuery.trim());
}
function onVodFilterInput(): void {
@ -272,8 +266,8 @@ function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string
</div>
</div>
<div class="vod-actions">
<button class="vod-btn secondary" data-vod-action="trim">${escapeHtml(UI_TEXT.vods.trimButton)}</button>
<button class="vod-btn primary" data-vod-action="queue">${escapeHtml(UI_TEXT.vods.addQueue)}</button>
<button type="button" class="vod-btn secondary" data-vod-action="trim">${escapeHtml(UI_TEXT.vods.trimButton)}</button>
<button type="button" class="vod-btn primary" data-vod-action="queue">${escapeHtml(UI_TEXT.vods.addQueue)}</button>
</div>
</div>
`;
@ -427,9 +421,9 @@ function renderStreamers(): void {
const filterInput = document.getElementById('streamerListFilter') as HTMLInputElement | null;
const sectionTitle = document.getElementById('streamerSectionTitle');
const showFilter = all.length >= STREAMER_FILTER_THRESHOLD;
if (filterInput) filterInput.style.display = showFilter ? '' : 'none';
if (filterInput) filterInput.classList.toggle('is-hidden', !showFilter);
// Compact title margin when filter is shown — avoids double gap.
if (sectionTitle) sectionTitle.style.marginBottom = showFilter ? '4px' : '';
if (sectionTitle) sectionTitle.classList.toggle('compact', showFilter);
// Empty state — small hint inside the sidebar when no streamers have
// been added yet. Without this the user sees a heading + blank space
@ -442,7 +436,7 @@ function renderStreamers(): void {
const counter = document.getElementById('streamerSectionCounter');
if (counter) counter.textContent = '';
const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null;
if (bulkBtn) bulkBtn.style.display = 'none';
if (bulkBtn) bulkBtn.classList.add('is-hidden');
return;
}
@ -454,7 +448,7 @@ function renderStreamers(): void {
if (all.length === 0) {
counter.textContent = '';
} else if (liveCount > 0) {
counter.innerHTML = `${all.length} <span class="streamer-section-counter-divider">·</span> <span class="streamer-section-counter-live">${liveCount} live</span>`;
counter.innerHTML = `${all.length} <span class="streamer-section-counter-divider" aria-hidden="true">·</span> <span class="streamer-section-counter-live">${liveCount} live</span>`;
} else {
counter.textContent = String(all.length);
}
@ -485,7 +479,10 @@ function renderStreamers(): void {
if (isLive) {
const dot = document.createElement('span');
dot.className = 'streamer-live-dot';
dot.title = UI_TEXT.streamers.liveNowTooltip || 'Live now';
const liveLabel = UI_TEXT.streamers.liveNowTooltip || 'Live now';
dot.title = liveLabel;
dot.setAttribute('role', 'img');
dot.setAttribute('aria-label', liveLabel);
item.appendChild(dot);
}
@ -599,7 +596,7 @@ function renderStreamers(): void {
// Reveal bulk-remove button only above the filter threshold.
const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null;
if (bulkBtn) bulkBtn.style.display = all.length >= STREAMER_FILTER_THRESHOLD ? '' : 'none';
if (bulkBtn) bulkBtn.classList.toggle('is-hidden', all.length < STREAMER_FILTER_THRESHOLD);
initStreamerDragDrop();
}
@ -731,7 +728,7 @@ async function removeStreamer(name: string): Promise<void> {
if (typeof hide === 'function') hide();
byId('vodGrid').innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<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>${UI_TEXT.vods.noneTitle}</h3>
<p>${UI_TEXT.vods.noneText}</p>
</div>
@ -781,9 +778,9 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
<div class="vod-card vod-card-skeleton">
<div class="vod-skel-thumb"></div>
<div class="vod-info">
<div class="vod-skel-line" style="width: 85%;"></div>
<div class="vod-skel-line" style="width: 55%; margin-top: 8px; height: 10px;"></div>
<div class="vod-skel-line" style="width: 40%; margin-top: 6px; height: 10px;"></div>
<div class="vod-skel-line title"></div>
<div class="vod-skel-line meta-1"></div>
<div class="vod-skel-line meta-2"></div>
</div>
</div>
`).join('');
@ -927,15 +924,8 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
closeVodContextMenu();
const menu = document.createElement('div');
menu.className = 'vod-context-menu';
menu.style.position = 'fixed';
menu.style.zIndex = '9999';
menu.style.background = 'var(--bg-card)';
menu.style.border = '1px solid var(--border-soft)';
menu.style.borderRadius = '6px';
menu.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)';
menu.style.padding = '4px';
menu.style.minWidth = '200px';
menu.className = 'context-menu';
menu.setAttribute('role', 'menu');
const downloadedIds = new Set(
Array.isArray(config.downloaded_vod_ids)
@ -947,13 +937,8 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
const makeItem = (label: string, onClick: () => void): HTMLElement => {
const el = document.createElement('div');
el.textContent = label;
el.style.padding = '8px 12px';
el.style.cursor = 'pointer';
el.style.fontSize = '13px';
el.style.color = 'var(--text)';
el.style.borderRadius = '4px';
el.addEventListener('mouseenter', () => { el.style.background = 'rgba(145,70,255,0.15)'; });
el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; });
el.className = 'context-menu-item';
el.setAttribute('role', 'menuitem');
el.addEventListener('click', () => {
try { onClick(); } finally { closeVodContextMenu(); }
});
@ -1033,7 +1018,7 @@ function updateVodBulkBar(): void {
const bar = document.getElementById('vodBulkBar');
if (!bar) return;
const count = selectedVodUrls.size;
bar.style.display = count > 0 ? 'flex' : 'none';
bar.classList.toggle('is-hidden', count === 0);
const countEl = document.getElementById('vodBulkCount');
if (countEl) {
countEl.textContent = UI_TEXT.vods.bulkSelectedCount.replace('{count}', String(count));

View File

@ -42,6 +42,11 @@ function setTitle(id: string, value: string): void {
if (node) node.setAttribute('title', value);
}
function setAriaLabel(id: string, value: string): void {
const node = document.getElementById(id);
if (node) node.setAttribute('aria-label', value);
}
function setLanguage(lang: string): LanguageCode {
currentLanguage = lang === 'en' ? 'en' : 'de';
UI_TEXT = UI_TEXTS[currentLanguage];
@ -62,6 +67,7 @@ function applyLanguageToStaticUI(): void {
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;
@ -80,6 +86,8 @@ function applyLanguageToStaticUI(): void {
}
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);
@ -105,6 +113,9 @@ function applyLanguageToStaticUI(): void {
setText('clipDialogPartHint', UI_TEXT.clips.dialogPartHint);
setText('clipDialogFormatLabel', UI_TEXT.clips.dialogFormatLabel);
setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm);
setPlaceholder('clipUrl', UI_TEXT.clips.urlPlaceholder);
setPlaceholder('clipStartPart', UI_TEXT.clips.startPartPlaceholder);
setPlaceholder('cutterFilePath', UI_TEXT.cutter.filePathPlaceholder);
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration);
@ -175,7 +186,11 @@ function applyLanguageToStaticUI(): void {
setText('streamlinkQualityAudio', UI_TEXT.static.streamlinkQualityAudio);
setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle);
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
setAriaLabel('streamerListFilter', UI_TEXT.static.streamerListFilterAria);
setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
setAriaLabel('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
setAriaLabel('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
setTitle('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);
@ -280,8 +295,13 @@ function applyLanguageToStaticUI(): void {
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
setAriaLabel('newStreamer', UI_TEXT.static.streamerAddAriaLabel);
setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder);
setAriaLabel('vodFilterInput', UI_TEXT.vods.filterAria);
setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
setAriaLabel('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
setPlaceholder('chatViewerFilter', UI_TEXT.queue.chatViewerFilterPlaceholder);
setAriaLabel('chatViewerFilter', UI_TEXT.queue.chatViewerFilterAria);
setText('vodSortLabel', UI_TEXT.vods.sortLabel);
if (typeof refreshVodSortSelectLabels === 'function') {
refreshVodSortSelectLabels();

View File

@ -12,15 +12,15 @@ let shouldOpenUpdateModalOnAvailable = false;
const SKIPPED_UPDATE_VERSION_KEY = 'twitch-vod-manager:skipped-update-version';
function getSkippedUpdateVersion(): string {
try { return localStorage.getItem(SKIPPED_UPDATE_VERSION_KEY) || ''; } catch { return ''; }
return safeLocalStorageGet(SKIPPED_UPDATE_VERSION_KEY);
}
function persistSkippedUpdateVersion(version: string): void {
try { localStorage.setItem(SKIPPED_UPDATE_VERSION_KEY, version); } catch { /* localStorage may be unavailable */ }
safeLocalStorageSet(SKIPPED_UPDATE_VERSION_KEY, version);
}
function clearSkippedUpdateVersion(): void {
try { localStorage.removeItem(SKIPPED_UPDATE_VERSION_KEY); } catch { /* localStorage may be unavailable */ }
safeLocalStorageRemove(SKIPPED_UPDATE_VERSION_KEY);
}
function notifyUpdate(message: string, type: 'info' | 'warn' = 'info'): void {
@ -88,11 +88,11 @@ function setCheckButtonCheckingState(enabled: boolean): void {
}
function showUpdateBanner(): void {
byId('updateBanner').style.display = 'flex';
byId('updateBanner').classList.add('show');
}
function hideUpdateBanner(): void {
byId('updateBanner').style.display = 'none';
byId('updateBanner').classList.remove('show');
}
function setUpdateBannerAvailableUi(info: UpdateInfo): void {
@ -103,7 +103,7 @@ function setUpdateBannerAvailableUi(info: UpdateInfo): void {
updateBannerState = 'available';
showUpdateBanner();
byId('updateProgress').style.display = 'none';
byId('updateProgress').classList.add('is-hidden');
const bar = byId('updateProgressBar');
bar.classList.remove('downloading');
@ -123,7 +123,7 @@ function setDownloadPendingUi(): void {
const button = byId<HTMLButtonElement>('updateButton');
button.textContent = UI_TEXT.updates.downloading;
button.disabled = true;
byId('updateProgress').style.display = 'block';
byId('updateProgress').classList.remove('is-hidden');
const bar = byId('updateProgressBar');
bar.classList.add('downloading');
@ -149,7 +149,7 @@ function setDownloadReadyUi(info?: UpdateInfo): void {
bar.style.width = '100%';
byId('updateProgressGauge').setAttribute('aria-valuenow', '100');
byId('updateProgress').style.display = 'block';
byId('updateProgress').classList.remove('is-hidden');
byId('updateText').textContent = `Version ${activeInfo.version} ${UI_TEXT.updates.ready}`;
const button = byId<HTMLButtonElement>('updateButton');
button.textContent = UI_TEXT.updates.installNow;
@ -187,13 +187,13 @@ function renderUpdateChangelog(notes?: string): void {
empty.hidden = true;
if (!normalized) {
card.style.display = 'none';
card.classList.add('is-hidden');
panel.hidden = true;
updateChangelogExpanded = false;
return;
}
card.style.display = 'block';
card.classList.remove('is-hidden');
const fragment = document.createDocumentFragment();
let currentList: HTMLUListElement | null = null;
@ -273,7 +273,7 @@ function renderUpdateChangelog(notes?: string): void {
function refreshUpdateChangelogToggleText(): void {
const toggle = byId<HTMLButtonElement>('updateChangelogToggle');
const card = byId<HTMLElement>('updateChangelogCard');
if (card.style.display === 'none') {
if (card.classList.contains('is-hidden')) {
return;
}
@ -299,14 +299,14 @@ function refreshUpdateModalTexts(): void {
// already on disk and ready to install, hide the button.
const skipBtn = byId<HTMLButtonElement>('updateModalSkipBtn');
skipBtn.textContent = UI_TEXT.updates.modalSkipVersion;
skipBtn.style.display = isReady ? 'none' : '';
skipBtn.classList.toggle('is-hidden', isReady);
byId('updateChangelogLabel').textContent = UI_TEXT.updates.changelogLabel;
byId('updateChangelogEmpty').textContent = UI_TEXT.updates.noChangelog;
const metaText = getUpdateModalMetaText(info);
const meta = byId('updateModalMeta');
meta.textContent = metaText;
meta.style.display = metaText ? 'block' : 'none';
meta.classList.toggle('is-hidden', !metaText);
renderUpdateChangelog(info.releaseNotes);
refreshUpdateChangelogToggleText();
@ -349,7 +349,7 @@ function confirmUpdateModal(): void {
function toggleUpdateChangelog(): void {
const card = byId<HTMLElement>('updateChangelogCard');
if (card.style.display === 'none') {
if (card.classList.contains('is-hidden')) {
return;
}
@ -374,7 +374,7 @@ function refreshUpdateUiTexts(): void {
} else if (updateBannerState === 'downloading') {
button.textContent = UI_TEXT.updates.downloading;
button.disabled = true;
progress.style.display = 'block';
progress.classList.remove('is-hidden');
if (latestDownloadProgress) {
bar.classList.remove('downloading');
bar.style.width = `${latestDownloadProgress.percent}%`;
@ -388,7 +388,7 @@ function refreshUpdateUiTexts(): void {
setDownloadReadyUi(latestUpdateInfo);
} else {
hideUpdateBanner();
progress.style.display = 'none';
progress.classList.add('is-hidden');
bar.classList.remove('downloading');
bar.style.width = '0%';
byId('updateText').textContent = UI_TEXT.updates.bannerDefault;
@ -458,7 +458,7 @@ async function checkUpdate(): Promise<void> {
setCheckButtonCheckingState(false);
window.setTimeout(() => {
if (!manualUpdateOutcomeHandled && !updateReady && byId('updateBanner').style.display !== 'flex') {
if (!manualUpdateOutcomeHandled && !updateReady && !byId('updateBanner').classList.contains('show')) {
shouldOpenUpdateModalOnAvailable = false;
notifyUpdate(UI_TEXT.updates.latest, 'info');
}
@ -580,7 +580,7 @@ window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
byId('updateProgressGauge').setAttribute('aria-valuenow', String(Math.round(progress.percent)));
showUpdateBanner();
byId('updateProgress').style.display = 'block';
byId('updateProgress').classList.remove('is-hidden');
const mb = (progress.transferred / 1024 / 1024).toFixed(1);
const totalMb = (progress.total / 1024 / 1024).toFixed(1);

View File

@ -345,9 +345,7 @@ function renderEventsList(events: EventLogEntry[]): void {
list.replaceChildren();
if (events.length === 0) {
const empty = document.createElement('div');
empty.style.color = 'var(--text-secondary)';
empty.style.padding = '12px';
empty.style.textAlign = 'center';
empty.className = 'event-viewer-empty';
empty.textContent = UI_TEXT.queue.viewEventsEmpty;
list.appendChild(empty);
return;
@ -514,9 +512,9 @@ function renderChatViewerList(messages: ChatViewerMessage[]): void {
if (user) {
const uSpan = document.createElement('span');
uSpan.className = 'chat-viewer-user';
// Per-user IRC color is preserved; the class supplies weight.
// 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;
else uSpan.style.color = 'var(--accent)';
uSpan.textContent = `${user}:`;
row.appendChild(uSpan);
}
@ -836,16 +834,14 @@ function isKnownTab(value: string): value is typeof TAB_IDS[number] {
}
function loadPersistedActiveTab(): string {
try {
const stored = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY);
if (stored && isKnownTab(stored)) return stored;
} catch { /* localStorage may be unavailable in privacy modes */ }
const stored = safeLocalStorageGet(ACTIVE_TAB_STORAGE_KEY);
if (stored && isKnownTab(stored)) return stored;
return 'vods';
}
function persistActiveTab(tab: string): void {
if (!isKnownTab(tab)) return;
try { localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, tab); } catch { }
safeLocalStorageSet(ACTIVE_TAB_STORAGE_KEY, tab);
}
function showTab(tab: string): void {
@ -979,7 +975,7 @@ function getSelectedFilenameFormat(): 'simple' | 'timestamp' | 'template' | 'par
function updateFilenameTemplateVisibility(): void {
const selected = getSelectedFilenameFormat();
const wrap = byId('clipFilenameTemplateWrap');
wrap.style.display = selected === 'template' ? 'block' : 'none';
wrap.classList.toggle('shown', selected === 'template');
}
interface TemplatePreviewContext {
@ -1292,13 +1288,11 @@ function updateClipDuration(): void {
const duration = endSec - startSec;
const durationDisplay = byId('clipDurationDisplay');
if (duration > 0) {
durationDisplay.textContent = formatSecondsToTime(duration);
durationDisplay.style.color = '#00c853';
} else {
durationDisplay.textContent = UI_TEXT.clips.invalidDuration;
durationDisplay.style.color = '#ff4444';
}
const isValid = duration > 0;
durationDisplay.classList.toggle('invalid', !isValid);
durationDisplay.textContent = isValid
? formatSecondsToTime(duration)
: UI_TEXT.clips.invalidDuration;
updateFilenameExamples();
}
@ -1475,8 +1469,8 @@ async function loadCutterFromPath(filePath: string): Promise<void> {
cutterStartTime = 0;
cutterEndTime = info.duration;
byId('cutterInfo').style.display = 'flex';
byId('timelineContainer').style.display = 'block';
byId('cutterInfo').classList.add('shown');
byId('timelineContainer').classList.add('shown');
byId('btnCut').disabled = false;
byId('infoDuration').textContent = formatTime(info.duration);
@ -1563,15 +1557,15 @@ async function updatePreview(time: number): Promise<void> {
}
const preview = byId('cutterPreview');
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewLoading}</p></div>`;
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewLoading)}</p></div>`);
const frame = await window.api.extractFrame(cutterFile, time);
if (frame) {
preview.innerHTML = `<img src="${frame}" alt="Preview">`;
applyHtml(preview, `<img src="${escapeHtml(frame)}" alt="${escapeHtml(UI_TEXT.cutter.previewAlt)}">`);
return;
}
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewUnavailable}</p></div>`;
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewUnavailable)}</p></div>`);
}
async function startCutting(): Promise<void> {
@ -1641,9 +1635,9 @@ function renderMergeFiles(): void {
<div class="file-order">${index + 1}</div>
<div class="file-name" title="${file}">${name}</div>
<div class="file-actions">
<button class="file-btn" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>&#9650;</button>
<button class="file-btn" 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" aria-label="${escapeHtml(UI_TEXT.merge.moveUpAria)}" title="${escapeHtml(UI_TEXT.merge.moveUpAria)}" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>&#9650;</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 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>
`;

View File

@ -105,6 +105,27 @@ body {
color: var(--text-secondary);
padding: 15px 15px 8px;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
justify-content: space-between;
}
/* Inner label group inside .section-title keeps the title text and
the streamer-section-counter aligned on the text baseline as a single
unit, separate from the bulk-remove X button that pins to the right. */
.section-title-label {
display: flex;
align-items: baseline;
gap: 8px;
}
/* Compact spacing variant applied when the sidebar's streamer-list
filter input is visible directly below the title, so the default
padding-bottom + the filter's own margin don't double up into a
visually loose gap. */
.section-title.compact {
margin-bottom: 4px;
}
.streamers {
@ -186,6 +207,7 @@ body {
animation has somewhere to live and the styling stays consistent
with the rest of the action surfaces. */
.vod-bulk-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
@ -195,7 +217,8 @@ body {
margin-bottom: 12px;
flex-wrap: wrap;
box-shadow: 0 4px 16px rgba(145, 70, 255, 0.10);
/* Animation fires whenever the JS flips display:none -> display:flex,
/* Animation fires whenever the bar transitions from display:none
(.is-hidden present) back to display:flex (.is-hidden removed),
because Animation events restart on each display change. */
animation: vod-bulk-bar-slide 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
@ -297,12 +320,18 @@ body {
}
.clip-modal-duration-value {
color: #00c853;
color: var(--success);
font-weight: 600;
font-family: 'Consolas', 'Segoe UI Mono', monospace;
font-size: 14px;
}
/* updateClipDuration flips this class when end <= start so the value
reads as a clear "Ungueltig!" / error message in red. */
.clip-modal-duration-value.invalid {
color: var(--error);
}
.clip-modal-hint {
color: var(--text-secondary);
font-size: 12px;
@ -310,18 +339,20 @@ body {
line-height: 1.4;
}
/* .clip-template-lint was the old per-modal rule for the clip-cutter
template lint badge. Superseded by the shared .template-lint
class (with .ok / .warn modifiers driven from var(--success) /
var(--error)). Class kept as a no-op alias in case any external
reference still uses it. */
.clip-template-lint {
font-size: 12px;
margin-top: 4px;
.clip-template-wrap {
display: none;
margin-top: 10px;
}
.clip-template-wrap {
margin-top: 10px;
.clip-template-wrap.shown {
display: block;
}
/* Template-Guide button below the clip-template input small offset
from the lint badge that sits directly above it. Was a one-off
inline style on the button. */
.clip-template-wrap .btn-secondary {
margin-top: 8px;
}
.clip-radio-row {
@ -440,6 +471,10 @@ body {
color: var(--text);
font-size: 13px;
}
/* No .filter-input:hover here it's redundant with the global
input[type="text"]:hover rule added in 4.6.142 (same effect: soft
purple border on hover). The class is always applied to <input
type="text"> elements, so the global rule already covers them. */
.filter-input.compact {
width: calc(100% - 16px);
@ -552,6 +587,13 @@ body {
animation: skel-shimmer 1.5s linear infinite;
}
/* Three skeleton-line variants for the VOD card placeholder match
the visual rhythm of a real card (title line, then two shorter
meta lines). Replaces inline width/height/margin-top declarations. */
.vod-card-skeleton .vod-skel-line.title { width: 85%; }
.vod-card-skeleton .vod-skel-line.meta-1 { width: 55%; height: 10px; margin-top: 8px; }
.vod-card-skeleton .vod-skel-line.meta-2 { width: 40%; height: 10px; margin-top: 6px; }
@keyframes skel-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
@ -673,6 +715,21 @@ select:focus {
box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.18);
}
/* Soft mouseover affordance every text/search/number/etc. input + textarea
+ select picks up a half-tone accent border on hover, matching the
.select-compact + .filter-input hover pattern. :not(:focus) keeps the
focus ring (above) from competing, :not(:disabled) leaves disabled
inputs inert. */
input[type="text"]:hover:not(:focus):not(:disabled),
input[type="search"]:hover:not(:focus):not(:disabled),
input[type="number"]:hover:not(:focus):not(:disabled),
input[type="password"]:hover:not(:focus):not(:disabled),
input[type="email"]:hover:not(:focus):not(:disabled),
textarea:hover:not(:focus):not(:disabled),
select:hover:not(:focus):not(:disabled) {
border-color: rgba(145, 70, 255, 0.45);
}
/* ============================================
CUSTOM CHECKBOX modern Twitch-purple
============================================
@ -809,6 +866,19 @@ select option {
padding: 7px 10px;
color: var(--text);
font-size: 13px;
transition: border-color 0.15s, background 0.15s;
}
.select-compact:hover:not(:disabled) {
background: rgba(145, 70, 255, 0.08);
border-color: rgba(145, 70, 255, 0.45);
}
/* Wider variant used for the Archive-search streamer-name select
where short streamer names would collapse the dropdown to an
unhelpful 80-100px otherwise. Matches the .form-stack.size-md width. */
.select-compact.size-md {
min-width: 160px;
}
/* Queue Section */
@ -958,6 +1028,12 @@ select option {
transform: scale(0.92);
}
.queue-retry-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65);
border-color: rgba(145, 70, 255, 0.55);
}
.queue-main {
flex: 1;
min-width: 0;
@ -1057,12 +1133,17 @@ select option {
}
.queue-details {
display: none;
font-size: 10px;
color: var(--text-secondary);
padding: 4px 0;
word-break: break-all;
}
.queue-details.expanded {
display: block;
}
.queue-details div {
margin-bottom: 2px;
}
@ -1150,6 +1231,28 @@ select option {
transition: all 0.2s;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn:disabled:hover {
background: inherit;
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65);
}
.btn-start:focus-visible {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(0, 200, 83, 0.65);
}
.btn-start.downloading:focus-visible {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(229, 70, 70, 0.65);
}
.btn-retry {
background: #2a3344;
color: #d9e4f7;
@ -1249,6 +1352,17 @@ select option {
transform: scale(0.94);
}
.header-search button:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55);
}
.header-search input:focus-visible {
outline: none;
border-color: rgba(145, 70, 255, 0.6);
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.35);
}
.btn-icon {
background: var(--bg-card);
border: 1px solid var(--border-soft);
@ -1278,6 +1392,12 @@ select option {
transform: scale(0.96);
}
.btn-icon:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65);
border-color: rgba(145, 70, 255, 0.55);
}
@keyframes btn-icon-spin {
from { transform: rotate(0deg); }
to { transform: rotate(180deg); }
@ -1575,6 +1695,19 @@ select option {
background: rgba(255,255,255,0.15);
}
/* Focus-visible for the per-card action buttons (Trim, Queue, etc.). The
primary variant already has a purple background use the inner-white
+ outer-purple double ring so the focus indicator stays visible
against the button's own colour. */
.vod-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65);
}
.vod-btn.primary:focus-visible {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55);
}
/* Settings */
.settings-card {
background: var(--bg-card);
@ -1583,6 +1716,14 @@ select option {
margin-bottom: 20px;
}
/* Centred-narrow settings card used for the standalone Clips Info
card where the content (a short list of supported URL formats) reads
better at a constrained width than across the full main column. */
.settings-card.centered {
max-width: 600px;
margin: 20px auto;
}
.settings-card h3 {
font-size: 16px;
margin-bottom: 15px;
@ -1591,6 +1732,22 @@ select option {
gap: 8px;
}
/* Subsection heading inside a settings card used when a single card
bundles two logical groups (Storage Auto-Cleanup) and the second
needs its own miniature heading after a divider. */
.settings-card h4 {
margin: 0 0 8px 0;
font-size: 14px;
}
/* Horizontal divider inside settings cards soft single line, balanced
vertical breathing room, no default browser shading. */
.settings-card hr {
border: none;
border-top: 1px solid var(--border-soft);
margin: 16px 0;
}
.form-group {
margin-bottom: 15px;
}
@ -1656,11 +1813,24 @@ select option {
border-color: rgba(255,255,255,0.26);
}
.lang-option:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55);
}
.lang-option.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(145, 70, 255, 0.2);
}
/* Active + focused combine the pressed-state border with the
thicker focus halo so keyboard users still see which one was
focused even when it's also the currently-selected language. */
.lang-option.active:focus-visible {
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55);
}
.flag-icon {
width: 16px;
height: 12px;
@ -1694,6 +1864,51 @@ select option {
gap: 10px;
}
/* Settings-card header row: card title + right-aligned refresh button.
Used by System-Check, Storage and similar cards where an h3 lives in
a form-row with a button pinned to the far right. The descendant h3
margin reset kills the inline style="margin:0" that those headings
used to carry. */
.form-row.section-header {
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
flex-wrap: wrap;
}
.form-row.section-header h3 {
margin: 0;
}
/* Right-side action cluster inside a section-header keeps a label
and a button (or two) together as a single unit so the section-header
parent's justify-content:space-between can pin the cluster to the
right while the h3 stays at the left. */
.section-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* Plain centred form-row with bottom margin the most common
form-row shape in Settings (button + button + inline-toggle, or
number-input + sublabel). Replaces three duplicated inline copies
of the same align-items:center; margin-bottom:10px declaration. */
.form-row.aligned {
align-items: center;
margin-bottom: 10px;
}
/* Search/filter tool-row variant wraps on narrow widths so the
select / input cluster collapses gracefully. Used by the Archive
search row (input + 3 selects + button). */
.form-row.search-bar {
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
align-items: center;
}
.log-panel {
background: #11151c;
border: 1px solid rgba(255,255,255,0.12);
@ -1725,6 +1940,11 @@ select option {
background: var(--accent-hover);
}
.btn-primary:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55);
}
.btn-primary:disabled {
background: var(--text-secondary);
cursor: not-allowed;
@ -1737,6 +1957,22 @@ select option {
border-radius: 4px;
padding: 10px 20px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.22);
}
.btn-secondary:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55);
}
.btn-secondary:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ============================================
@ -1773,6 +2009,20 @@ select option {
transform: translateY(1px);
}
.btn-pill:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55);
}
.btn-pill.primary:focus-visible,
.btn-pill.success:focus-visible {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55);
}
.btn-pill.danger:focus-visible {
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.6);
}
.btn-pill:disabled {
opacity: 0.45;
cursor: not-allowed;
@ -1842,6 +2092,11 @@ select option {
color: #ff6b6b;
}
.btn-close:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.6);
}
/* .queue-detail-btn tiny chip-style action button used in queue item
detail rows AND in the archive search results list. Was previously
rendering with browser defaults (gray flat button). */
@ -1873,6 +2128,11 @@ select option {
transform: translateY(0);
}
.queue-detail-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.7);
}
/* Clips */
.clip-input {
max-width: 600px;
@ -2053,6 +2313,24 @@ select option {
flex: 1;
}
/* Min-width sizing modifiers let the row wrap to a new line before
the stack collapses below the named breakpoint. Replaces three inline
min-width declarations in the Auto-Cleanup 3-up row. */
.form-stack.size-sm {
min-width: 120px;
}
.form-stack.size-md {
min-width: 160px;
}
/* Compact-width input used for the Auto-VOD poll/age inputs where
the values are 2-3 digits and a full-width input would look odd
alongside their inline sublabels. */
.input-narrow {
width: 90px;
}
/* Block-level note text same colour as .form-sublabel but reserved
for full-row paragraphs like the cleanup report area. */
.form-note {
@ -2061,6 +2339,67 @@ select option {
line-height: 1.45;
}
/* Card intro paragraph the descriptive paragraph that sits below a
card heading and explains what the card does. Used identically on
the Archive, API-help, Storage, Cleanup, Discord, Auto-VOD and
Backup cards (was 7 duplicated inline style attributes). */
.card-intro {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.5;
margin-bottom: 12px;
}
/* Inline link inside a card intro picks up the accent colour so it
reads as actionable text rather than the default browser blue. The
underline + pointer cursor come from the browser's <a> defaults. */
.card-intro a {
color: var(--accent);
}
/* Multi-line info text preserves authored line breaks (white-space:
pre-line) so the Clips card can list URL formats one-per-line in
the HTML without separate <br>/<li> markup. */
.info-text {
color: var(--text-secondary);
line-height: 1.6;
white-space: pre-line;
}
/* Responsive KPI grid for the Stats Summary card fits as many 180px
tiles per row as the column allows, with equal-share growth. */
.stats-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
/* Flush variant: the intro sits flush against the next sibling block
(e.g. the stats summary grid) and gets its top breathing room from
the preceding section-header row rather than its own bottom margin. */
.card-intro.flush {
margin-top: 8px;
margin-bottom: 0;
}
/* Filename-templates 3-pair grid (VOD / Part / Clip template inputs).
Each row is a label above an input; the label gets the 13px secondary
styling that used to be inline on every label. */
.filename-template-grid {
display: grid;
gap: 8px;
margin-top: 8px;
}
.filename-template-grid label {
font-size: 13px;
color: var(--text-secondary);
}
.filename-template-grid label:not(:first-child) {
margin-top: 4px;
}
/* Settings toggle row label wraps an input[type=checkbox] + span.
Used 17 times across the Settings cards. Adjacent-sibling
combinator adds the gap between consecutive toggle rows so the
@ -2101,10 +2440,15 @@ select option {
template inputs and by the clip-cutter modal's custom template
row. Two states: green for OK, red for unknown-placeholder
warning. Pull the colours from --success / --error vars so the
lint always tracks the rest of the apps semantic palette. */
lint always tracks the rest of the apps semantic palette.
margin-top is part of the class so both usage sites pick up the
same rhythm the previous inline-style values diverged by 2px
between the two spots, an inconsistency that's not worth tracking. */
.template-lint {
font-size: 12px;
line-height: 1.4;
margin-top: 6px;
transition: color 0.15s;
}
@ -2648,13 +2992,26 @@ select option {
text-align: center;
}
.video-preview .placeholder svg {
opacity: 0.3;
}
.video-preview .placeholder p {
margin-top: 10px;
}
.timeline-container {
display: none;
background: var(--bg-card);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.timeline-container.shown {
display: block;
}
.timeline {
position: relative;
height: 60px;
@ -2733,15 +3090,19 @@ select option {
}
.cutter-info {
display: none;
background: var(--bg-card);
border-radius: 8px;
padding: 15px 20px;
margin-bottom: 20px;
display: flex;
justify-content: space-around;
text-align: center;
}
.cutter-info.shown {
display: flex;
}
.cutter-info-item {
display: flex;
flex-direction: column;
@ -2820,10 +3181,22 @@ select option {
color: var(--text);
}
.file-item .file-btn:focus-visible {
outline: none;
border-radius: 4px;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65);
color: var(--text);
}
.file-item .file-btn.remove:hover {
color: var(--error);
}
.file-item .file-btn.remove:focus-visible {
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.65);
color: var(--error);
}
.merge-actions {
display: flex;
gap: 10px;
@ -3105,6 +3478,12 @@ body.theme-light .modal {
border-color: rgba(255, 70, 70, 0.55);
}
.modal-close:focus-visible {
outline: none;
border-color: rgba(255, 70, 70, 0.6);
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.6);
}
.modal-close:active {
transform: scale(0.92);
}
@ -3408,11 +3787,6 @@ input[type="number"]::-webkit-outer-spin-button {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 167, 38, 0.25);
}
.app-toast.error {
border-left-color: var(--error);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 70, 70, 0.25);
}
/* ============================================
STREAMER SECTION COUNTER
============================================
@ -3522,6 +3896,7 @@ input[type="number"]::-webkit-outer-spin-button {
.chat-viewer-row .chat-viewer-user {
font-weight: 700;
margin-right: 4px;
color: var(--accent);
}
.chat-viewer-row .chat-viewer-tag {
@ -3564,6 +3939,14 @@ input[type="number"]::-webkit-outer-spin-button {
font-family: 'Consolas', 'Segoe UI Mono', monospace;
}
/* Empty state inside the events-viewer modal shown when an events
file exists but contains no parsed entries. */
.event-viewer-empty {
color: var(--text-secondary);
padding: 12px;
text-align: center;
}
.event-viewer-tag {
font-weight: 600;
margin-right: 8px;
@ -3911,7 +4294,28 @@ input[type="number"]::-webkit-outer-spin-button {
box-shadow: 0 4px 14px rgba(145, 70, 255, 0.4);
}
/* Skeleton loading state */
/* Focus-visible for the profile action buttons (Record now, Open on
Twitch, Refresh). Default variant gets a purple ring; the primary
variant already has a purple background so it gets the inner-white
+ outer-purple double ring used elsewhere for purple-bg buttons. */
.streamer-profile-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65);
border-color: rgba(145, 70, 255, 0.6);
}
.streamer-profile-btn.primary:focus-visible {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55);
}
/* Skeleton loading state switches the profile-header from its
regular block layout to a flex row so the avatar + body sit
side-by-side. The element itself was previously flipped via inline
.style.display='flex' in renderStreamerProfileSkeleton(). */
.streamer-profile-skeleton {
display: flex;
}
.streamer-profile-skeleton .streamer-profile-skel-block {
background: linear-gradient(90deg, var(--bg-elevated) 0%, rgba(255,255,255,0.06) 50%, var(--bg-elevated) 100%);
background-size: 200% 100%;
@ -3919,6 +4323,37 @@ input[type="number"]::-webkit-outer-spin-button {
border-radius: 4px;
}
/* Pre-shaped skeleton block variants each matches one of the
real-profile-card slots so the loading silhouette previews the
final layout. Replaces inline width/height/border-radius declarations. */
.streamer-profile-skel-block.avatar {
width: 88px;
height: 88px;
border-radius: 50%;
flex-shrink: 0;
}
.streamer-profile-skel-block.name {
width: 180px;
height: 24px;
}
.streamer-profile-skel-block.badge {
width: 90px;
height: 18px;
border-radius: 10px;
}
.streamer-profile-skel-block.subtitle {
width: 60%;
height: 14px;
margin-top: 6px;
}
.streamer-profile-skel-stats {
margin-top: 8px;
}
@keyframes profile-skel-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
@ -4090,3 +4525,95 @@ input[type="number"]::-webkit-outer-spin-button {
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 70%, rgba(0, 0, 0, 0.18) 100%);
pointer-events: none;
}
/* ============================================
REDUCED MOTION respect OS-level user preference
============================================
Users who set "Reduce motion" in their OS accessibility settings
(Windows: Settings > Accessibility > Visual Effects > Animation
effects; macOS: System Settings > Accessibility > Display > Reduce
motion) get animations and transitions effectively disabled.
Suppresses things like the empty-state-float loop, the btn-icon-spin
on Refresh hover, the vod-bulk-bar slide-in, the storyboard fade-in,
and the multitude of transition: all 0.2s declarations anything
that involves motion. Critical for users with vestibular disorders
and a baseline accessibility expectation in 2025. */
/* Generic hide utility. Use when an element's visible-state display
differs (button = inline-block, bulk-bar = flex, etc.) so a single
class can hide any of them without per-element .shown modifiers.
The !important wins over the base class's display declaration. */
.is-hidden {
display: none !important;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ============================================
CONTEXT MENU generic right-click menu base
============================================
Used by both the queue row context menu (renderer-queue.ts) and the
VOD card context menu (renderer-streamers.ts). left/top stay inline
on the container (set per-click); everything else lives here. */
.context-menu {
position: fixed;
z-index: 9999;
background: var(--bg-card);
border: 1px solid var(--border-soft);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
padding: 4px;
min-width: 200px;
}
.context-menu-item {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
color: var(--text);
border-radius: 4px;
transition: background 0.12s;
}
.context-menu-item:hover:not(.disabled) {
background: rgba(145, 70, 255, 0.15);
}
.context-menu-item.disabled {
color: var(--text-secondary);
opacity: 0.55;
cursor: not-allowed;
}
.context-menu-separator {
height: 1px;
margin: 4px 6px;
background: var(--border-soft);
}
/* Output-row appended to the queue-item detail panel when a job
completed. Lists the file actions (Open file / Show in folder /
View chat / View events) followed by a tiny secondary-colour file
label. */
.queue-output-row {
display: flex;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
align-items: center;
}
.queue-output-label {
color: var(--text-secondary);
font-size: 11px;
word-break: break-all;
}