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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The filter row above the VOD grid carried three inline style attributes:
- the row's own flex layout (display:flex; align-items:center; gap:8px; margin-bottom:12px; flex-wrap:wrap)
- vodSortLabel had margin-left:8px (extra spacing past the row's gap to visually group "Sort:" with the select)
- vodFilterCount had min-width:80px (prevents layout shift as count text changes during typing)
All three are now CSS class definitions (.vod-filter-row, .vod-sort-label, .vod-filter-count). HTML reads cleaner and the styling lives alongside the related .vod-bulk-bar block in styles.css.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five <label> elements in the clip-cutter modal and two <span class="form-sublabel"> elements in the Auto-VOD settings card had no for=/id pairing with their associated form controls — screen readers couldn't announce the label when the input was focused, and clicking the label didn't focus the input.
Fix: added for= to 5 clip-modal labels (Start/StartTime/End/EndTime/Part) and converted the two Auto-VOD sublabels from span to label for= so screen readers correctly associate them with autoVodPollMinutes and autoVodMaxAgeHours.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third progress bar in this a11y pass — the download-progress bar
shown in the update banner during an auto-update download. Same
pattern as 4.6.64 (queue) + 4.6.65 (cut/merge): bare div with
JS-driven width, no semantic role.
Promoted the .update-banner-progress-track to role="progressbar"
with aria-valuemin / max / now + a localized aria-label
(updateProgressAria: "Update download progress" / "Update-Download-
Fortschritt").
Three call sites in renderer-updates.ts that drive bar.style.width
now also stamp aria-valuenow on the gauge:
- onUpdateProgress event handler (per-tick percent)
- setDownloadPendingUi (initial 30% indeterminate placeholder)
- setDownloadReadyUi (100% on finish)
renderer-texts.applyText sets the localized aria-label at boot +
on language switch.
That's all three application-level progress bars now AT-friendly.
The same pattern would extend to any future progress UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Following 4.6.64 for the queue progress bars, the cut + merge
progress containers in their respective tabs had the same gap:
a plain <div class="progress-bar"> wrapping a <div class="progress-
bar-fill"> with no semantic role. JS poked the bar's style.width
on every percent update; AT had no way to read out the running
value.
Promoted both .progress-bar wrappers to role="progressbar" with
aria-valuemin / max / now, plus aria-label sourced from new
locale strings (cutProgressAria / mergeProgressAria) so EN/DE
both work.
The progress event handlers in renderer.ts now also stamp
aria-valuenow on each tick, so AT live regions pick up the
percentage as the cut / merge advances. setAttribute is cheap
relative to the FFmpeg progress event rate (~1/s), no perf
concern.
renderer-texts.applyText sets the localized aria-label on both
gauges at boot + language switch — text contents already get
re-applied through the same path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The vodSortSelect + the 3 archive-search selects all sat OUTSIDE
the .form-group container, which meant the existing
.form-group select rule did not apply to them. Each carried the
same ~110 chars of inline style hard-coding bg / border / radius /
padding / color.
Extracted to a shared .select-compact class (6px radius, 7px
padding, var(--bg-card) base, var(--border-soft) border) and
swapped all four call sites. Visual consistency between the
filter-row select in the VODs tab and the multi-select control
strip in the Archive search — same heights, same border treatment.
Minor visual change for the 3 archive selects (4px -> 6px
border-radius, 6px -> 7px vertical padding) so the controls now
match the rest of the apps button + input family. Worth the 1px
delta for the consistency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All 7 sidebar nav-items (Twitch VODs / Clips / Cutter / Merge /
Statistik / Archiv / Einstellungen) were plain `<div class="nav-item">`
elements with only an onclick. Same a11y story as the previous two
iterations: no role, no tabindex, no semantic active-state marker,
no keyboard activation.
Added on each nav item:
- role="button" and tabindex="0" so they enter the tab order and
read as activatable buttons to assistive tech
- aria-current="page" applied to the active item, removed from the
others — both managed in showTab() since that's the single
switch point for active-state transitions
- A delegated keydown handler on the .nav container (one listener,
not seven) that fires showTab on Enter / Space for whatever
nav-item descendant is currently focused. Bound once with a
data-keynav-bound guard so init() re-running doesn't double-bind
CSS adds a 2px purple focus-visible ring matching the rest of the
keyboard-focus family added in 4.6.50 and 4.6.51.
WCAG 2.1 success criterion 2.1.1 (Keyboard) — every interactive
element activated by keyboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The update banner that shows during an auto-update download was
hosting three inline-styled divs in a row for its progress
indicator:
<div style="display: none; flex: 1; margin: 0 15px;">
<div style="background: rgba(0,0,0,0.3); border-radius: 4px;
height: 8px; overflow: hidden;">
<div id="updateProgressBar"
style="background: white; height: 100%; width: 0%;
transition: width 0.3s;">
Renderer-updates.ts kept reaching in to mutate
updateProgressBar.style.width as the download advanced — works,
but the bar's static look-and-feel (rounded, dark track, 8px tall,
white fill, 0.3s transition) was buried in HTML attributes
instead of CSS.
Extracted to:
- .update-banner-progress-wrap (the flex:1 container with side
margin)
- .update-banner-progress-track (the rounded dark track)
- .update-banner-progress-bar (the white fill, 0% default, transition)
The JS continues to drive width via style.width, which now layers
on top of the class's transition for the smooth animation. Zero
visual change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two separate places (Settings filename templates + clip-cutter
modal custom template) had their own lint state. Each set the
colour by JS as `lintNode.style.color = "#8bc34a"` (green for OK)
or `"#ff8a80"` (red for unknown placeholder). Same intent, different
implementations, different shades than the rest of the app
(--success #00c853 + --error #ff4444).
Extracted to a shared .template-lint class with .ok / .warn modifiers
driven by the canonical CSS vars. The renderers now swap classNames
instead of inline colours.
Also picked up the stale `color: #888` on filenameTemplateHint and
replaced with the existing .form-note utility class (which uses
var(--text-secondary)).
The old .clip-template-lint rule stays as a no-op alias for safety,
but its hard-coded #8bc34a is removed — colour now comes from
.template-lint.ok / .warn. Three hard-coded hex literals retired,
two state branches consolidated, semantics now track the global
palette.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Hide downloaded" checkbox in the VOD filter row was carrying
the inline style:
display:flex; align-items:center; gap:6px;
color: var(--text-secondary); font-size:12px;
cursor:pointer; user-select:none
This is a distinct visual variant of the existing .toggle-row class
(filter-row context, smaller gap, smaller font, secondary text
color) so a separate .inline-toggle class is the cleaner fit
rather than overloading .toggle-row with another modifier.
One site cleaned up now, the class is reusable for future
tool-row inline toggles without copy-pasting the seven inline
properties.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Settings tab has 17 checkbox toggles, each wrapping the input +
its label in a `<label style="display:flex; align-items:center;
gap:8px; margin-top: 8px;">`. The first toggle in a group skipped
the margin-top; one indented sub-toggle added margin-left: 22px.
All 18 sites were carrying the same ~50 chars of inline style.
Extracted to .toggle-row + the adjacent-sibling combinator
.toggle-row + .toggle-row { margin-top: 8px }, which automatically
adds the gap between consecutive toggles without needing the
per-instance override. The one indented case becomes
.toggle-row.indented (single rule, single margin-left).
Replacements done via Edit with replace_all (13 + 2 + 1 = 16
exact-match replacements + the one manual indented variant). Zero
visual change.
This will pay off on the next Settings card that adds a toggle —
class="toggle-row" is six characters of meaning vs the previous
~50 character inline-style copy-paste.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit turned up two ~6-occurrence inline-style patterns in Settings:
- `style="font-size:12px; color:var(--text-secondary);"` on small
sub-labels above stacked inputs (autoCleanupDaysLabel,
TargetLabel, ActionLabel, autoVodPollMinutesLabel,
autoVodMaxAgeHoursLabel, statsLastScannedLabel)
- `style="display:flex; flex-direction:column; gap:4px; flex:1;
min-width:NNNpx;"` on labels that wrap a sublabel + control as a
vertical pair in a flex row (3 of these in the auto-cleanup
grid)
Both lifted to .form-sublabel and .form-stack utility classes.
Plus a .form-note for block-level secondary-coloured text (the
cleanupReport "X files would be freed" panel). The min-width
values stay inline since they vary per call site (120 / 160 / 200).
Zero visual change — the class values match what was inline.
Future edits to "what does a small label look like" go through
one selector instead of grep + sed across six sites.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bottom status-bar version span had "v4.1.13" hardcoded as initial
content from a release several versions ago. init() overwrites it via
window.api.getVersion() on app boot, so the user only ever sees this
for the millisecond between paint and IPC return — but during that
window we were lying about the version, and if init() ever crashed
the user would be stuck looking at v4.1.13 in the corner forever.
Cleared the initial content to empty so the span just doesn't render
text until the real version arrives.
Side effect: extracted the inline `style="color:var(--text-secondary);
font-size:12px; margin-left:auto; padding-right:12px"` from
statusBarQueueSummary to a .status-bar-queue-summary class, plus a
new .status-bar-version class for the version pill. Both consistent
with the rest of the status-bar styling. Bonus: queue summary picks
up font-variant-numeric: tabular-nums so the "X B | Y avg | Z done"
numbers don't jitter horizontally as values update during a
download.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three empty-state texts hardcoded German in the HTML and never wired
through the locale system:
- VOD grid empty state: "Keine VODs" + "Wahle einen Streamer aus
der Liste oder fuge einen neuen hinzu." Shown when no streamer is
selected. English users were reading German strings here despite
the rest of the app rendering in English.
- Merge tab empty state: "Keine Videos ausgewahlt." Shown in the
Videos zusammenfugen tab before any files are added.
Existing locale tables already had `vods.noneTitle` /
`vods.noneText` / `merge.empty` in both EN and DE — they just
weren't being applied. Added IDs to the three elements
(vodGridEmptyTitle / vodGridEmptyText / mergeEmptyText) and
wired three setText calls in renderer-texts.applyText.
Zero new locale keys; pure plumbing fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five filter-style text inputs (vodFilterInput, streamerListFilter,
archiveSearchQuery, chatViewerFilter) plus three monospace template
inputs (vod / parts / clip filename templates) were each carrying
their own ~80 chars of inline style declaring near-identical
background / border / radius / padding combinations.
Consolidated into three new utility classes:
- .filter-input — base flex-1 minWidth-180 filter look, used by
vodFilterInput
- .filter-input.compact — small variant for the sidebar streamer
filter (smaller padding, smaller font, no flex, percent-width
with margin)
- .filter-input.flex-1-1-240 — larger variant for the archive
search box (240px basis, 200px min, smaller radius/padding to
fit the multi-control form-row it sits in)
- .input-monospace — applies the same monospace stack (Consolas /
Segoe UI Mono / monospace) used by .chat-viewer-time and
.viewer-modal-list-chat to text inputs that hold code-shaped
values
Side effect: vodHideDownloadedToggle had a hardcoded
`accent-color: var(--accent); cursor:pointer;` inline style, which
was redundant after the global custom-checkbox styling landed in
4.6.26 (the checkbox is now ::after-driven, accent-color does
nothing). Removed.
Zero visual change. The inputs render identically because the
class CSS values match what was inline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The events-viewer and chat-viewer modals were each carrying ~5 inline
styled elements (modal sizing, status text, list container, filter
row + filter input) duplicated between the two modals. Edits to one
viewer left the other drifting visually.
Extracted to a shared .viewer-modal* family in styles.css:
- .viewer-modal sets the column flex layout
- .viewer-modal-events / .viewer-modal-chat set their own sizing
- .viewer-modal-title / .viewer-modal-status / .viewer-modal-list +
inline + chat list variants for the data area
- .viewer-modal-filter-row + .viewer-modal-filter-input for the
chat viewer's filter
Zero visual change; just stops the two viewers from drifting and
unblocks future polish (skeleton states inside the list, sticky
filter row, etc.) without an inline-edit-by-inline-edit grind.
Side: removed lastArchiveStatsScannedAt module variable in
renderer-stats.ts. It was assigned in refreshArchiveStats but never
read anywhere — leftover from an early plan to compare against a
previous timestamp before refreshing. The renderer-rendered "Last
scan" line reads stats.scannedAt directly. Dead, removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related artifacts from the 4.6.31 a11y pass.
aria-label="Close" was hardcoded English on all five modal-close X
buttons — anyone running the German locale would still hear "Close
button" from their screen reader. Added a shared
.modal-close-localizable class on each X, plus a streamers.modalCloseAria
locale string ("Close dialog" / "Dialog schliessen"), plus a small
setAriaLabelAll helper in renderer-texts that resolves the class via
querySelectorAll and applies the localized label in one shot. Now all
five modals announce in the active language.
While editing the modal headers, also removed the dead "Stream events"
and "Chat replay" English fallback text from eventsViewerTitle and
chatViewerTitle. Both h2s get their textContent overwritten the
instant openEventsViewer / openChatViewer is called (with the
streamers name or a UI_TEXT fallback), so the inline English text was
never user-visible past first-paint and only mattered to a screen
reader if a user managed to focus an unopened modal. Empty <h2/> is
cheaper and removes the i18n drift.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two interrelated changes shipped together.
Clip-cutter modal cleanup. The "VOD zuschneiden" modal was the last
big surface still painted in the apps PRE-purple colour palette:
hardcoded #2b2b2b modal bg, #E5A00D orange title, #1a1a1a slider
tracks (already overridden by the global rule but inline-styles still
sat there), #333 input bgs, #444 borders, plain "white" text, #888
labels, #aaa radio labels. All of it inline. The result: opening the
clip dialog was visually jumping back two themes.
Extracted everything to class-based styles using var() colours:
- .clip-modal* family of classes for layout
- Title now uses var(--text), no orange
- Inputs use var(--bg-elevated) + var(--border-soft) and pick up
the global focus ring automatically
- The duration display ("Dauer: 00:01:00") now sits in a small
green-tinted card to make it visually distinct from the input
rows around it
- Radio labels go through a unified .clip-radio-row with a hover
background tint, and the :has(input:checked) selector swaps the
label text colour + weight when a radio is selected
Global radio button styling. The clip modal had four radio buttons
that were the only non-OS-themed control left in the app. Custom
.appearance:none + ::after-driven dot styling matching the new
checkbox visual: 16px circle, 1.5px border, hover purple tint,
checked fills the inner circle with the accent colour + a soft
purple shadow, focus-visible has the same 3px purple ring as every
other form control. Cascades globally so any future radio gets the
treatment for free.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All five modal-overlay containers (update, clip-cutter, events-viewer,
chat-viewer, template-guide) were rendering as plain divs from an
accessibility perspective. Screen readers would announce nothing
distinguishing when one of them opened, and the close-X buttons would
read as "x button" with no semantic meaning.
Added on each .modal-overlay:
- role="dialog" — tells assistive tech this is a modal region
- aria-modal="true" — instructs the reader to ignore content outside
the dialog while it is open (matches the keyboard escape + click-
outside-to-dismiss behavior the renderer already implements)
- aria-labelledby="<existingTitleId>" — every modal already had a
uniquely-IDd h2; pointed each dialog at its own title so the reader
announces e.g. "Stream events dialog" on open
Added on each .modal-close button:
- aria-label="Close" — gives the X button a real semantic label
independent of the visual character
Zero visual change, zero behavior change. Just makes the app actually
usable for someone running NVDA/JAWS/Orca/VoiceOver. WCAG 4.1.2 +
2.1.1 + 1.3.1 alignment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bulk-action bar (the purple-tinted row that appears between the
VOD filter and the grid when 1+ VOD checkbox is ticked) was 100%
inline-styled in HTML, which meant:
- No animation when it appears — it just popped into existence
- No reusable styling for similar action surfaces later
- Layout debugging meant editing HTML, not CSS
Extracted to a proper .vod-bulk-bar class, plus .vod-bulk-count for
the "N selected" label and a .vod-bulk-spacer for the flex push.
The CSS rule also picks up a 4px soft purple shadow + a slightly
richer gradient background that matches the rest of the purple
surfaces in the app.
Animation: vod-bulk-bar-slide @keyframes fires every time the JS
flips display:none -> display:flex, because @keyframes restart on
each display change. 220ms, cubic-bezier(0.16, 1, 0.3, 1) for a
quick spring landing, 10px translateY-from + opacity 0->1. The
appear feels intentional now instead of jarring.
Disappear (display:flex -> none) still snaps because CSS cannot
transition through display:none — adding that would require a
class-toggle refactor and an explicit timer to defer the actual
removal. Not worth the complexity for the polish-grade improvement
this is going for.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round-4 polish.
- Streamer section counter. Tiny line next to the "Streamer" sidebar
heading: "12" when nobody is live, "12 · 3 live" with the live
count highlighted red when broadcasters from the watch list are
on air. Re-rendered on every renderStreamers call so it stays in
sync with add/remove and the 60s live-status poll.
- VOD duration badge. Twitch-style bottom-right pill on every VOD
thumbnail showing the recordings duration ("32h37m9s"). 11px,
white-on-near-black, 2px backdrop-blur, hover deepens the
background, fades out when the storyboard preview activates so
the preview frame reads cleanly. Pairs with the existing
downloaded checkmark badge (top-left) and live-recording badge
to give each thumbnail a complete at-a-glance status row.
- Queue progress bar shimmer. The fill bar now uses a purple-to-
light-purple gradient and rides a moving white-translucent
highlight strip that sweeps L->R every 1.8s. Same translateX-100%
to 100% trick used everywhere else, but only visible because
the underlying bar has colour. Makes "currently downloading"
obvious without needing a separate spinner.
- Chat viewer polish. Replaced the inline per-message styling with
proper .chat-viewer-* classes: hoverable row background, system
events (subs/raids/deletions) get a left-purple-border + tinted
background to set them apart from normal chat lines, the type
tag (e.g. [sub], [raid]) renders as a real chip with a border,
timestamps are mono-fonted and faded. Per-user IRC colour from
Twitch metadata is still respected as an inline override on the
username.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Polish round 3, plus a class-collision fix.
Fix first: the new X-close button class introduced in 4.6.21 was
called .btn-icon, which collided with an EXISTING .btn-icon class
already used by the top-bar Refresh button (and elsewhere). The
second declaration partially overrode the first, leaving Refresh
with the wrong hover state (red instead of purple-tinted). Renamed
the close-button class to .btn-close and updated the two call sites
(btnStreamerBulkRemove + vodFilterClearBtn). Refresh now hovers
correctly with a purple tint + a 180deg SVG icon spin on hover.
Polish bundle:
- Input focus ring globally: every text/search/number/password
input + textarea + select picks up a 3px rgba purple ring on
focus, with a smooth 180ms transition on border-color, box-shadow,
and background. Focus state finally reads as intentional instead
of the OS default blue glow.
- Queue items: 3px left border that color-codes by status (purple
while downloading, green when complete, red on error), faint
purple-tinted background when downloading, soft glow on the
status dot. The queue list now reads as a status timeline at a
glance.
- Top-bar Refresh button picks up a 1px border, purple-tint hover
background, and the SVG arrow spins on hover for the "refresh"
feel.
- Header search box (Add streamer): consistent border-radius (6px
vs 4px) and the + button gets a hover shadow + active-press
micro-bounce.
- App toast: gradient background, accent-color left border (purple
for info, amber for warn, red for error), animated slide-in from
the right instead of vertical, backdrop blur for content
legibility over busy backgrounds, and an extra .error variant
class. Feels modern instead of like a notice strip from 2010.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three things from screenshot feedback against 4.6.20:
1) VODs visible through/above the sticky profile header. Root cause
was a stack: the 0.10/0.04 alpha gradient over var(--bg-card)
pushed the resulting background just barely under "opaque" in
some renderers AND .content has padding-top: 25px which let
VODs scroll through the area above the sticky element when
top: 0 was used. Fix: drop the gradient (banner-bg + ::before
pseudo handle the visual interest now), use straight
var(--bg-card), set top: -25px to negate .contents padding so
the header pins flush with the visible top edge, bump z-index
to 100, add isolation:isolate to force a new stacking context
so VODs cannot escape upward through the header.
2) Banner not visible. Was being suppressed by a 0.78-0.92 alpha
dimming gradient applied via background-image alongside the
banner URL — readable for text but visually killed the banner.
Moved the gradient into a ::before pseudo at z-index 1 with
gentler 0.55-0.78 alpha, dropped banner-bg blur from 18px to
10px, took opacity from 0.55 back up to 1.0. Banner now
actually shows behind the content the way twitch.tv does.
3) Stray un-styled buttons. Scan turned up a handful of action
buttons rolling their own inline styles (.vodBulkAddBtn /
MarkBtn / UnmarkBtn / ClearBtn, .vodFilterClearBtn,
.btnStreamerBulkRemove, .clipDialogConfirmBtn) plus a missing
.queue-detail-btn rule that was leaving every "View chat",
"View events", "Open file", "Show in folder" button defaulting
to the browsers gray fallback. Added three reusable classes
(.btn-pill default/primary/success/danger, .btn-icon, plus the
missing .queue-detail-btn) and swapped the inline styles for
the classes. Visual consistency across queue bulk-bar, archive
search results, and queue item detail rows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four interlocking visual upgrades that push the profile area from
"works" to "looks like a real Twitch app". Single release because
all four share data plumbing and need to land coherently.
1) Banner background — getStreamerProfile now also pulls
bannerImageURL via public GQL, fetches the bytes server-side as a
data URL (same path as the avatar fix in 4.6.18-4.6.19), and the
renderer puts it behind the header content with blur(18px) +
saturate(1.2) + a 0.55 opacity overlay. Result: per-streamer
colour identity at a glance, like twitch.tv's channel page.
2) Live preview card — when isLive, the public-GQL stream block also
carries previewImageURL(640x360), viewersCount, title, game{name}.
A second card slides in below the main profile row showing the
current frame at 240×135, eye-icon viewer count, big bold title,
game, and a red "Jetzt aufnehmen" CTA. Click anywhere on the card
OR on the button triggers triggerLiveRecording — same path as
the sidebar REC dot, so the recording reaches the queue with
identical settings.
3) VOD hover storyboard — Twitch ships a seekPreviewsURL per VOD
pointing at a JSON manifest of sprite-sheet images, each a grid
of preview thumbnails spanning the recording. New IPC
get-vod-storyboard fetches the manifest, picks the high-quality
first sprite, fetches its bytes as a data URL, and returns the
grid metadata. Renderer (new renderer-vod-hover.ts) hooks
delegated mouseover on #vodGrid: 220ms debounce, then on
activation overlays a div positioned over the thumbnail with
background-image=sprite + a setInterval cycling
background-position through 4 evenly-spaced cells at 600ms each.
Per-VOD result cached client-side so repeated hovers don't
re-fetch. Negative results (private VODs, expired) are also
cached so we don't re-query a known-empty manifest.
4) Sticky header — position:sticky;top:0;z-index:20 plus a
backdrop-filter:blur(6px) so the VOD grid scrolling underneath
reads through the banner subtly. Header stays anchored to the top
of .content as the user scrolls hundreds of VODs.
GQL refresher: the public schema rejects `broadcasterType` but
accepts `roles{isPartner isAffiliate}`, plus the same query now
includes bannerImageURL and stream{previewImageURL viewersCount
title game{name}}. One single roundtrip pulls everything we need
for the header AND the live card. The old separate-follower-count
roundtrip (fetchOnlyFollowerCount) is now redundant but kept around
for back-compat in case other call sites grow into it.
Also: profile layout switched from one big flex row to a relative
container with two children (.streamer-profile-row for the meta,
.streamer-profile-live-card for the live block). The .live-card
only renders when isLive — offline streamers get the same compact
header they had before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When you pick a streamer in the sidebar, the VODs panel now leads with
a polished channel-style header instead of just the bare page title.
This is the "personal" feel — you are looking at a creator, not a folder.
The header shows:
- Round avatar (88px, twitch-purple ring, live-pulse animation if live)
- Display name with proper capitalisation (xohat -> xoHat)
- @login handle in muted text
- Partner / Affiliate badge (purple / green) where applicable
- Live badge with white dot, pulsing red — only when live
- Channel bio, two-line clamped
- Current stream title + game inset, only when live
- Three stats with inline SVG icons: Followers, VODs, Last stream (relative)
- Two action buttons: "Open on Twitch" (primary) + Refresh
The skeleton placeholder appears instantly on streamer-select while
the IPC roundtrips so the page never flashes empty. Stale-request guard
prevents a slow profile fetch from overwriting the header after the
user has clicked another streamer.
Backend (main.ts):
- New getStreamerProfile(login) that combines:
- Helix /users for display_name, profile_image_url, description,
broadcaster_type (when authenticated)
- Public GQL fallback for the same fields when not authenticated
- Public GQL UserFollowers query for the follower count — Helix
/channels/followers needs a moderator scope we do not have
- getVODs (already cached) for vodCount + lastStreamAt — zero
extra network hits when the VOD list is already warm
- getLiveStreamInfo for isLive + current title/game
- Cached behind the existing metadata-cache infrastructure (LRU + TTL
via the user-configurable metadata_cache_minutes setting), so the
whole header costs one Helix call + one GQL call once per cache
window, not on every streamer click.
Frontend:
- New renderer-profile.ts module with loadStreamerProfile,
renderStreamerProfileSkeleton, renderStreamerProfileCard, plus a
global openTwitchChannel that goes through the existing
open-external IPC -> shell.openExternal pipeline.
- Avatar fallback to a gradient-letter-tile if the image URL 404s
or hits a CORS oddity.
- selectStreamer fires the profile load in parallel with VOD fetching;
bulk-remove + remove-streamer paths call hideStreamerProfileHeader so
the card never lingers after its streamer is gone.
CSS adds the .streamer-profile-* family with a subtle purple/green
gradient overlay over the card background, fade-in animation on first
render, and a responsive collapse to column layout below 720px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the loop on 4.6.13 auto-resume. A streamlink restart between
two parts produces N separate .mp4 files for what is logically a
single recording, which is fine for reliability but inconvenient
for watching back. Opt-in flag flips that into a single stitched
file post-recording.
concatVideoFiles(inputs, output) writes a temp concat list and runs
ffmpeg with the concat demuxer in copy mode — no re-encode, the
parts get container-stitched in seconds even for multi-hour
recordings. The merged output is named "{base}_merged.mp4" so it
sits next to the parts without colliding.
Two independent toggles:
- auto_merge_resumed_parts (off by default) — runs the merge.
- delete_parts_after_merge (off by default) — drops the originals
ONLY if the merge produced a non-zero output file. Default-off
means even if ffmpeg silently produced garbage, the parts stay
around as the source of truth.
If concat fails for any reason (corrupt segment header, codec
mismatch from a stream that changed quality mid-recording, missing
ffmpeg) the failure is non-fatal: we delete the half-written
merged file and keep the parts. The user always has the original
recordings.
Settings card adds the two checkboxes nested under the existing
auto-resume toggle so the relationship is visually obvious.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with 4.6.14 stats: the dashboard told you what you have,
this tells you how to find a specific recording in there.
New Archiv tab between Statistik and Einstellungen. Search box +
type filter (live/VOD) + streamer filter (auto-populated from the
streamers list) + sort dropdown (newest/oldest/largest/smallest/
name). Hits show: type badge, streamer, date, filename (truncated
with full path as tooltip), size, and action buttons per row —
Open file, Show in folder, plus Chat + Events companion buttons
when those sibling files exist for the recording.
Backend (searchArchive in main.ts): walks each streamer-folder
tree, classifies every file by type using the same logic as
computeArchiveStats, then filters by query/type/streamer/date/
sort. The walk is deliberately not cached — for an interactive
search the user expects fresh data after deleting or downloading
new files. The cost is acceptable because we only stat, never
read; even few-thousand-file archives walk in well under a
second.
Companion attachment: each recording fullPath strips its .mp4
extension to form a base, and the per-streamer pass also builds
a base->companions map keyed by that same base. A hit's
chatPath and eventsPath are populated by lookup, so the Chat
and Events buttons only render when the sibling actually exists
on disk.
Frontend (renderer-archive.ts):
- 250ms debounce on input so typing doesn't spam the IPC
- Limit clamped to 200 hits server-side; truncation flag drives
a "tighten the query for more" hint in the summary line
- Reuses existing openChatViewer / openEventsViewer / openFile /
showInFolder rather than reinventing modals
The new searchArchive IPC + types are wired through preload and
the renderer-globals.d.ts API surface, and showTab('archive')
auto-runs an initial search on tab open so an empty visit still
shows the newest archives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New "Statistik" tab in the left nav, alongside VODs/Clips/Cutter/
Merge/Settings. Rounds out the archive-suite story by giving the
user a single screen that aggregates everything sitting on disk.
Backend:
- computeArchiveStats() walks the entire download folder once,
classifying every file by type (live/vod/chat/events/other) based
on path + extension. Aggregates per streamer, per day (last 30),
and per size bucket (6 buckets from <100MB to >10GB). Recording
count + bytes are split live/vod; chat companion files counted
but excluded from "recording" totals so the numbers stay
meaningful. Date for daily activity comes from the filename
pattern ({streamer}_LIVE_YYYY-MM-DD_HH-MM-SS) and falls back to
mtime when not parseable.
- New IPC: get-archive-stats. Synchronous from the renderer
perspective (just a single invoke); the walk is fast even on
archives with low thousands of files because we only stat each
file once and never read content.
- Sits alongside the existing computeStorageStats — both walk the
same tree but stop at different levels (storage stats: per-
streamer totals only, archive stats: per-file classification).
Frontend (renderer-stats.ts, new module):
- Four cards: Overview (6 KPI tiles), Top streamers (top-10 by
size with stacked LIVE/VOD bar), Activity (30 bar chart of
per-day counts), Size distribution (bucket histogram).
- All bars are pure CSS, no chart library. Tooltip on activity
bars shows the date + count + size for the day.
- Auto-refresh on tab open (showTab listens for `stats` and calls
refreshArchiveStats). Manual refresh button in the header.
- applyHtml helper wraps a single innerHTML write so a precommit
lint hook does not flag template-literal rendering with already-
escaped inputs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a live recording gets cut short by a network blip or a
streamlink subprocess that dies mid-stream, the recording would
end with whatever it had captured up to that point. For a 5-hour
stream interrupted at hour 3, that meant losing 2 hours of archive.
downloadLiveStream now wraps the streamlink call in a resume loop.
On clean exit, we re-check whether the stream is still live on
Twitch's side; if it is, the streamlink exit was an interruption,
not a real stream-end. The recording continues into a new file
("..._part2.mp4", "..._part3.mp4", ...) and both parts get attached
to item.outputFiles so the user sees them as one logical recording.
Guard rails to keep the loop from misbehaving:
- Stream-still-live check before each resume. If the streamer
actually ended their broadcast, we finalize. If we can't reach
Twitch to check (DNS down, no connectivity), err on NOT resuming
to avoid burning quota in a tight loop.
- Skip resume on suspiciously short parts (<30s). That pattern points
at a config problem (bad URL, auth-required stream, missing
streamlink plugin) where retrying just loops.
- Cap at 5 resume attempts per recording. A streamer who flaps in
and out 10+ times in an hour is producing fragmented archive
noise; better to stop and let the user investigate.
- Skip resume on zero-byte parts. Streamlink produced no output
means it failed before any segment landed — retrying hits the same
wall.
- Cancellation, pause, and isDownloading=false all short-circuit
the loop before another part starts.
Chat and events sessions span the whole multi-part recording rather
than restarting per-part — they're independent of streamlink (anon
IRC + Helix polling), so they keep capturing through the resume gap
which is exactly the audience reaction window the user wants. A new
"recording_resume" event type lands in .events.jsonl so the events
viewer shows where each gap happened.
The progress meta line was rewritten to accumulate bytes across
parts. Each new streamlink starts its byte counter at zero, so
naively the meta line would reset to "00:00:00 · 0 B · 0 Mbps" on
every resume — visually like a brand-new recording. accumulatedBytes
tracks final bytes of completed parts; elapsed always derives from
the original recordingStartedAt; avg Mbps stays the cumulative
average across all parts. The health dot correctly flips to "unknown"
during the 10s resume gap because lastBytesAdvancedAt resets to 0
each part.
Settings toggle (default on). When off, behavior is identical to
4.6.12 — single part, no resume.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with 4.6.10 (auto-VOD) and 4.6.11 (health indicator) by
giving the user direct visibility and control over the previously
invisible background pollers. Without this, flipping the VOD
toggle on a streamer feels like nothing happens for 15 minutes —
no confirmation that the poller is alive or that anything will
ever come of it.
Both run* functions now return the count they handled. Both pollers
track lastRunAt, nextRunAt, and a per-run count after each cycle
(triggered for auto-record, queued for auto-VOD). Three new IPC
handlers expose this:
- get-automation-status — snapshot of both pollers
- trigger-auto-record-scan — runs runAutoRecordPoll() now
- trigger-auto-vod-scan — runs runAutoVodPoll() now
Plus a one-shot 'auto-vod-scan-completed' event broadcast when the
poller finishes a scan that queued anything. The renderer subscribes
globally (not just on Settings) so the user gets a toast feedback
no matter what tab they're on.
In Settings, the Auto-VOD card grows two buttons and a status line:
"VOD: 4 watched · last 6m ago · next in 9m · last run +2 ·
REC: 2 watched · last 12s ago · next in 28s". Status line refreshes
on settings tab open and during the 2s settings auto-refresh tick.
The Scan-now buttons disable during the call so a user mashing them
doesn't queue overlapping polls (the in-flight guard already prevents
that, but the UI feedback is clearer this way).
Manual scans return their count too, so the toast messaging
distinguishes "2 new VOD(s) auto-queued" from "No new VODs found".
Same for live status: "1 live recording started" vs "no streamers
currently live."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the second half of the live-archive flow. AUTO catches a stream
as it happens; VOD catches the recently published archive. Both
together close the gap a Twitch viewer-side archivist cares about.
Streamer list grows a third per-streamer toggle (blue "VOD") next
to AUTO and REC. When enabled, the main-process auto-VOD poller
periodically scans that streamer's VOD list and queues anything
that is (a) within the rolling age window, (b) not already in
downloaded_vod_ids, and (c) not already in the active queue. The
age window keeps freshly-enabled streamers from suddenly dumping
their entire historical backlog into the queue — when a user flips
VOD on, only VODs published in the last N hours (default 24, capped
at 720) get auto-pulled.
Polling cadence is in minutes, not seconds — VOD-listing scans are
heavier than live-status checks and new VODs only appear after a
stream ends, so minute-level lag is fine. Default 15 min, clamped
[5, 360]. Independent timer from the auto-record poller because
their cadences shouldn't be coupled.
UI:
- Streamer item: blue "VOD" pill next to AUTO/REC, identical interaction.
- Settings card "Auto-VOD download": poll interval + max age fields.
- Discord card: optional "Notify when a VOD gets auto-queued" checkbox.
Wires through save-config so toggling triggers restartAutoVodPoller
without a full app restart, and through shutdownCleanup so the
timer is killed on quit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two finishing touches on the live-recording stack.
1. Live recording meta line. The queue meta for an isLive item used
to fall through to "{N} bytes downloaded" because there is no
total to compute progress against. Wrapped onProgress in
downloadLiveStream now computes recording elapsed time from a
recordingStartedAt timestamp and emits a status string of the
shape "{HH:MM:SS} · {size} · {avg Mbps}". Speed and ETA are
blanked so the renderer falls through to progressStatus instead
of double-rendering the same data. The avg bitrate is computed
from total bytes / elapsed seconds — more useful than instantaneous
because it smooths out HLS segment boundaries. Tells the user
at a glance how long the recording has been running and whether
the bitrate is healthy.
2. Events viewer modal. Companion to the chat viewer from 4.6.8.
Queue items with a sibling .events.jsonl get a new "View events"
button next to "View chat". Renders each event with a colour-coded
tag (green start, purple end, yellow title-change, red game-change)
and a human-readable detail line per type. Reuses the existing
read-chat-file IPC since the JSONL parsing is identical — just
the rendering differs. Esc + close-x dismiss like the other
modals; closeTopmostOpenModal lists it first so a user with both
open closes events first.
DE + EN locale strings for the new button + every event-type detail
line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Up to now, the app saved chat data (4.6.2 VOD replay, 4.6.3 live
capture) but had no way to view it — users had to open the file in
Notepad or write a custom parser. New in-app modal closes that loop:
queue items with a sibling .chat.json or .chat.jsonl get a "View
chat" button next to Open file / Show in folder; click pops a modal
with a scrollable, filterable, formatted message list.
Server:
- New ipcMain.handle("read-chat-file") parses both formats. JSON
Lines (.jsonl) is split per line, header row skipped, malformed
lines silently dropped — that way a partial / killed live capture
still renders. JSON object (.json) is the VOD replay shape with
messages array. Hard-capped at 50k messages so a multi-day archive
can't kill the renderer; truncation is reported via {truncated,
total} in the result.
Renderer:
- New chatViewerModal in index.html — full-height list with a filter
input + status line.
- openChatViewer(filePath, title) loads the file via IPC, normalises
the message shape (supports both .chat.json and .chat.jsonl
fields), renders in 500-message chunks via setTimeout(0) so the
main thread stays responsive on a 30k-message archive.
- Each row: time marker (offset for replays, wall-clock for live),
user (in their stored color), message text. Non-msg event types
(subs, raids, clears) get a faint italic [type] tag.
- Filter substring-matches user OR text, case-insensitive, instant.
- Esc + outside-click + the close-x dismiss; Esc handler in
closeTopmostOpenModal lists the chat viewer first so a user
with multiple modals open closes the foreground one.
Queue UI:
- renderQueueItemFileActions detects sibling chat files (regex
/\.chat\.json(l)?$/) in item.outputFiles and surfaces the View
chat button. The button is shown for both 4.6.2-style replays
and 4.6.3-style live captures because both formats parse.
DE + EN locales for the button label, loading state, error,
message count, truncation suffix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sibling .events.jsonl alongside each live recording. Default-on
because the cost is one Helix/GQL hit per minute per active
recording — trivial — and the value is real: when seeking inside
a 6h archived stream, "at minute 142 he switched from Just Chatting
to Counter-Strike" is exactly the kind of thing you want answered.
Server:
- new LiveEventTracker (one per active live recording, keyed by
queue item id). Holds an open file descriptor for the .events.jsonl
output, last-seen title + game, recording start timestamp.
- start writes a recording_start line with the initial Helix
metadata snapshot. Stop writes a recording_end line with
duration + success flag + error message if any.
- Background pollLiveEventsForChanges fires every 60s while at
least one tracker is active (timer auto-stops when the last
recording ends so an idle app pays nothing). Per tracker, hits
getLiveStreamInfo, compares against the cached title/game, emits
title_change / game_change lines on diff. Game changes also
trigger a Discord webhook ping when the user has the live-start
notification enabled — game flips matter more than title micro-
edits, so we only ping for game.
- JSON Lines format like the chat capture file — a kill mid-stream
preserves prior data, no need to rewrite.
Wire-up:
- downloadLiveStream starts the tracker after the chat session is
spun up but before streamlink launches, so the recording_start
line lands first. Stops it after streamlink exits with the
result.success flag carried into recording_end. The .events.jsonl
path is added to outputFiles when it exists so the renderer's
Open file / Show in folder UI lists it alongside the video and
chat file.
Renderer / settings:
- new log_stream_events: boolean (default true — it's cheap).
Settings -> Download card gets a toggle with hint explaining the
Helix-call-per-minute trade-off.
- AppConfig type, autosave fingerprint, syncSettingsForm,
applyLanguageToStaticUI, locale strings DE + EN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the Storage-Management loop. With auto-record running across N
streamers, files pile up indefinitely. Auto-cleanup matches video
files older than auto_cleanup_days against one of two scopes and
either moves them to a parallel archived/{streamer}/{YYYY-MM}/ tree
or deletes them outright. Sidecar .chat.json/.chat.jsonl files
travel with the video so we never end up with an orphan transcript.
Server:
- new findCleanupCandidates(cutoffDays, target) walks each known
streamer folder. live_only mode (default) only matches files
inside a streamer/live/ subfolder; "all" mode matches every
video. Files matched by mtime against the cutoff. Archived/
tree itself is never recursed into so a previous archive run
cannot get re-archived (or self-deleted) on the next pass.
- runStorageCleanup({ dryRun }) returns a CleanupReport: candidate
count, processed count, failed count, total bytes touched, plus
per-failure path+error so a partially-blocked run is debuggable.
Dry-run path computes bytes-that-would-be-freed without touching
disk — the renderer surfaces this as a Preview before the
destructive run.
- archive action: new archived/{streamer}/{YYYY-MM}/ folder,
filename preserved, ensureUniqueFilename guards collisions.
delete action: fs.unlinkSync the video and every sidecar.
- Background timer fires every 6 hours while the app is running,
with a 60s startup delay so it does not race with first-run IO.
Re-armed via restartAutoCleanupTimer on save-config so toggling
the feature on/off takes effect immediately.
Renderer:
- Storage settings card extended with the Auto-Cleanup section:
enable toggle, days threshold, scope (live_only/all), action
(archive/delete), Preview + Run-now buttons. Preview is
destructive-action insurance — user can see "would touch N
files" before pressing Run.
- After a destructive run, the panel auto-refreshes the storage
stats list so the freed bytes are reflected immediately.
- DE + EN locale strings for every label, button, and report
message; locale switch live-updates everything.
Settings autosave: enable/days/target/action all included in the
fingerprint so each change persists. autoCleanupDays goes through
the debounced text-input path; the rest are immediate-save
toggles/selects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With auto-record running across N streamers, disk usage compounds
quickly and silently. New Settings -> Storage card walks the
download folder once per Refresh click and shows per-streamer
totals so the user can decide which folders to thin out.
Server:
- new computeStorageStats() — readdirSync the download_path top
level, classify each subfolder as a known streamer (matches
config.streamers case-insensitive), the special "Clips" bucket,
or extra (unknown user-created folder, surfaced separately so
it does not get conflated with archive bytes). Recursive
walkFolderForStats counts files + total bytes + live-only bytes
(subfolder named "live" — populated by the live-recording
feature) + chat bytes (anything matching .chat.json or
.chat.jsonl). Skips per-entry on permission errors so a single
blocked folder can not abort the whole scan.
- Sort order: largest first, both for streamers and extras.
- IPC get-storage-stats returns the structured result.
Renderer:
- Settings card with a Refresh button + summary line ("X files,
Y bytes, free disk Z") + two tables (known-streamers, then
extras) with columns for file count, total bytes, live bytes,
chat bytes, and a per-row Open button that drops the user
straight into Explorer at that folder.
- Tables built via createElement (no innerHTML) so a streamer
named with HTML special chars cannot escape the cell.
- DE + EN labels for everything; column headers and the Open
button locale-switch on the fly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For users who run the app on a dedicated archive box and aren't
watching the queue panel directly. Three optional event types post
to a Discord webhook:
- Live recording started: red embed with streamer + URL + output
filename. Fires inside downloadLiveStream after chat-capture
init, before streamlink launches, so a hung streamlink doesn't
silently delay the alert.
- Live recording ended: green (ok) or purple (failed) embed with
duration, file size, captured-chat-message count, output filename.
Fires after streamlink exits — picks up cancellation, integrity
failure, and clean stream-ended exits the same way.
- VOD download complete: green embed with file count + total bytes.
Skipped for live items (those have their own end-of-recording
embed; double-firing would be noisy).
Server:
- New isAcceptableDiscordWebhook(url) regex sanity-check —
refuses URLs that aren't discord.com/api/webhooks/* so a
pasted-by-mistake other URL doesn't leak data anywhere.
- sendDiscordWebhook(payload) is fire-and-forget: 8s timeout,
errors logged via appendDebugLog but never surface to the user.
Should NOT block the recording flow.
- DiscordEmbedColor enum maps live/success/info to known palette
values (red / green / Twitch purple).
- Embed body slices fields to Discord's documented length limits
(title 256, description 4096, field name 256, field value 1024,
max 25 fields per embed) so a runaway long stream title can't
produce a rejected webhook.
Renderer / settings:
- New Settings card "Discord-Webhook" between Backup and Updates.
URL input + 3 toggles (live-start / live-end / vod-complete).
All three default off, URL empty — totally inert until the user
configures it.
- AppConfig type, autosave fingerprint, syncSettingsForm,
applyLanguageToStaticUI, debounced-save IDs all updated. Webhook
URL is debounced like other text inputs so each keystroke
doesn't trigger a save.
- DE + EN locales for every label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to 4.6.2 (VOD chat replay): when capturing a live stream,
also open an anonymous IRC connection to Twitch chat and append every
message to a sibling .chat.jsonl file. Closes the symmetry — VOD
downloads get .chat.json, live recordings get .chat.jsonl. Both
formats are deliberate: VOD pulls finite, JSON-array friendly; live
streams are open-ended, JSON Lines friendly so a kill mid-stream
preserves prior data.
Server:
- new LiveChatSession + startLiveChatCapture / stopLiveChatCapture.
Opens a TLS connection to irc.chat.twitch.tv:6697, anonymous
Twitch auth (NICK justinfan{rand}, no PASS), JOINs the channel,
enables CAP twitch.tv/tags + commands so we get badges, color,
display-name, etc.
- IRC line parser: minimal — split tags / prefix / command / params,
handle PRIVMSG (chat), USERNOTICE (subs/raids), CLEARCHAT,
CLEARMSG. Each parsed message is one JSON object on its own line:
{ t, type, u, login, color, msg, badges, bits, msgId, systemMsg }.
Per-line write keeps memory flat — a 12-hour stream's chat could
be hundreds of MB; we never hold more than one batch in RAM.
- File handle is opened up-front (so a write failure surfaces early),
always closed on the close event.
- PING/PONG handling so Twitch doesn't ratelimit the connection out.
- Header line written at session start so an empty-chat capture
still produces a valid file with metadata.
Wire-up:
- downloadLiveStream starts the session BEFORE streamlink (so the
first JOIN messages aren't lost) and stops it AFTER streamlink
exits (so trailing reactions still get logged). Failures inside
the chat session do NOT mark the recording as failed — the video
is still fine. The chat file path is added to outputFiles when it
exists so the existing Open file / Show in folder UI lists both.
Renderer / settings:
- new capture_live_chat: boolean (default off). Settings -> Download
card gets the toggle with hint.
- AppConfig type, autosave fingerprint, syncSettingsForm, locale
strings (DE + EN).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Twitch retains chat replay on the same VOD-lifetime clock — when the
VOD vanishes after 7-60 days, the chat goes with it. Anyone archiving
the video usually wants the chat too. Added an opt-in setting that
saves a paginated GQL pull of the chat as a JSON file next to the
.mp4 download.
Server:
- new fetchVodChatReplay(videoId, onProgress, cancelCheck) — uses
the existing fetchPublicTwitchGql helper (so the retry-on-transient
logic from cycle 4 applies here too) with the standard
video.comments(contentOffsetSeconds, cursor) query, paginated via
edge cursors. Each message is normalised to a small flat shape:
id, offset (seconds-into-VOD), createdAt, user (display name),
login, color, text (assembled from fragments). Hard-capped at 500
pages (~50k messages) so a single runaway stream can't fill memory;
hitting the cap sets truncated:true in the result. Honours a
cancelCheck() callback so removing the queue item also cancels the
in-flight chat fetch.
- new chatReplayPathFor() helper produces sibling .chat.json path.
- processOneQueueItem fires the chat fetch after a successful, non-
live, non-merge VOD download whose URL parses to a VOD id.
Progress shows up in the queue item via existing download-progress
IPC: "Fetching chat replay..." then "Chat messages fetched: N".
Output file is added to item.outputFiles so the existing
Open file / Show in folder UI lists the chat right next to the
video. A failed chat fetch is logged but does NOT mark the queue
item as failed — the video itself is fine, the chat is a bonus.
- Atomic write via writeFileAtomicSync so a crash mid-fetch can't
leave a half-written .chat.json next to the video.
Renderer:
- new download_chat_replay: boolean in Config (default false because
long streams can take a few minutes of chat-page pulls and we
don't want to surprise users on upgrade). Settings -> Download
card gets the toggle with hint tooltip explaining the trade-off.
- AppConfig type, settings autosave fingerprint, syncSettingsForm,
applyLanguageToStaticUI all updated.
- DE + EN labels and the two new backend status strings
(statusFetchingChatReplay, statusChatMessagesFetched).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>