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>
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>
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>
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>
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>
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>
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>
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>
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>
document.title was stamped once during app boot with the static
"Twitch VOD Manager vX.Y.Z" string. After that, the H1 page-title
in the header updated as the user navigated tabs and selected
streamers, but the OS-level window title — the string shown in the
taskbar, Alt+Tab switcher, and OS notifications — never changed.
Multitasking suffered: a user with three Electron windows pinned
to taskbar all read identical "Twitch VOD Manager v4.6.x", with
no clue which window had what tab or streamer loaded.
Added a setPageTitle(text) helper in renderer.ts that:
- Updates the H1 #pageTitle textContent (the visible header)
- Updates document.title with `${text} - ${appName} v${version}`
for non-default text, or just `${appName} v${version}` for the
default app-name fallback
- Exposed on window so the renderer-streamers.ts and
renderer-settings.ts modules can reach it without crossing the
module-vs-bundle boundary
Three call sites updated to use the helper:
- showTab → uses for tab-derived titles
- selectStreamer → uses for "xrohat" style streamer titles
- the renderer-settings language-switch refresh path
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ctrl+F was wired to focus the VOD filter input — but only when the
VODs tab was active. On the Archive tab (added in 4.6.15) Ctrl+F
did nothing useful: the browser default find bar was suppressed
(Electron renderer doesn't have one anyway) and the app handler
didn't have a branch for the archive context.
Now Ctrl+F also targets the archiveSearchQuery input when the
Archive tab is the active tab. Other tabs (Clips / Cutter / Merge /
Stats / Settings) let the shortcut fall through to no-op since
they don't have a primary search/filter input.
Same input-focus convention as the VODs tab: focus + select-all so
the user can immediately type to replace or append.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the Statistik (4.6.14) and Archiv (4.6.15) tabs were added to
the sidebar nav, the TAB_IDS const never got extended past its
original five entries:
const TAB_IDS = ["vods", "clips", "cutter", "merge", "settings"]
Two consequences:
1) Ctrl+1..5 keyboard shortcut was hard-capped at five tabs (the
guard `tabIndex < TAB_IDS.length` filtered Ctrl+6 and Ctrl+7 out).
Even though there were 7 visible tabs.
2) persistActiveTab(tab) called isKnownTab(tab) before localStorage
write. For 'stats' or 'archive' that returned false, so the tab
was silently NOT persisted. Open the app on the Archiv tab,
close it, reopen — it'd boot on VODs because the persisted value
was the previous non-stats/archive selection.
Extended TAB_IDS to all seven nav-items + bumped the keyboard
shortcut range from 1-5 to 1-7. Ctrl+5 now maps to Statistik,
Ctrl+6 to Archiv, Ctrl+7 to Einstellungen. Persistence works for
the full set.
Added a comment to TAB_IDS pointing out the failure modes when
this is out of sync with the HTML nav, so the next nav addition
doesn't repeat the bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
showAppToast spawns / reuses a single floating toast at the bottom-
right of the window for transient status (e.g. "1 new VOD auto-queued",
"Cannot start recording", etc). The toast had no a11y semantics —
screen readers never announced it, so the entire transient-feedback
channel was silent for AT users.
Promoted the toast container to a live region:
- role="status" for info toasts + aria-live="polite" so the reader
waits for a natural break in current speech before announcing
- role="alert" for warn toasts + aria-live="assertive" so the reader
interrupts whatever it was saying (matches the visual amber-left-
border meaning — warn IS urgent)
- aria-atomic="true" so the reader announces the whole message at
once instead of attempting to diff against the previous toast
Critical detail: aria-live attributes have to be in place BEFORE the
text changes for AT to register the change as a live-region update.
The current implementation now sets role / aria-live first and only
then writes the new textContent.
WCAG 4.1.3 — Status Messages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
confirmClipDialog (the handler behind the clip-cutter modals "Add
to queue" button) opens an alert with a hardcoded English message
when the parsed start / end / duration values come back as NaN —
which can happen if the user types non-numeric characters or
otherwise breaks the time-input pattern. German-locale users got
an English alert on a German UI.
Added clips.invalidTime to both locales ("Invalid time values" /
"Ungueltige Zeitangaben") and swapped the inline string for the
locale lookup. All the other alerts in that handler already go
through UI_TEXT.clips.* — this was the one outlier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When mergeFiles is empty, the renderer dropped an inline-styled
innerHTML template into #mergeFileList:
<div class="empty-state" style="padding: 40px 20px;">
<svg style="opacity:0.3" ...><path ...></svg>
<p style="margin-top:10px">${UI_TEXT.merge.empty}</p>
</div>
Three issues:
- innerHTML interpolating a locale string (lint hook flags pattern
even though locale strings are app-controlled)
- Inline styles for padding / opacity / margin
- The same SVG icon as the static HTML, duplicated
Built via createElement + createElementNS for the SVG namespace, so
the renderer never touches innerHTML for this branch. Styling moved
to a .merge-empty-state class that scopes the padding override
(needed because the merge file-list sits in a settings-card with
its own padding) without leaking into the global .empty-state used
by the VOD grid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
renderEventsList was painting every timeline row with ~7 inline
style props per element (padding, border-bottom, font-size on the
row; margin-right, color on each span; padding-top on detail) AND
keeping a JS-side colour map per event type. For a chatty recording
with 100+ events the inline-style noise added up, and adding a new
event type meant editing the renderer to extend the map.
Extracted:
- .event-viewer-row picks up the padding + bottom border + font-size
- .event-viewer-time gets the secondary colour + monospace stack
- .event-viewer-tag becomes an actual pill (uppercase, letter-
spacing, rounded background tint, bordered) — visually consistent
with the chat viewer's [type] chip tag
- .event-viewer-detail handles the row-detail line spacing
Per-type colour is now driven by CSS [data-type="..."] attribute
selectors (recording_start = green, recording_end = purple,
recording_resume = blue, title_change = amber, game_change = red).
Each variant overrides background + border + text colour to give
each tag a contained "pill" look. The renderer just stamps
ev.type onto data-type and the CSS handles the rest.
Adding a new event type now means one new selector here, not a JS
map edit. Lint, focus, future polish all stay near the styling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The killer-feature of this pass is the live indicator: red pulsing
dot next to every streamer in the sidebar that is currently
broadcasting on Twitch. Suddenly the sidebar conveys real-time
state at a glance — you know who to click before clicking.
How it works:
- New live-status batch poller (main.ts) fires every 60s, packs
every streamer in the user's watch list into a single GQL query
using aliased user lookups (`u0:user(login:$l0){stream{type}} ...`),
chunked at 50 logins per request. One roundtrip for the whole
list — far cheaper than per-streamer polling.
- Updates a liveStatusByLogin Map on the main side, emits an IPC
`live-status-batch-update` event with only the entries that
flipped (plus a full snapshot for the renderer to keep in sync).
- Renderer subscribes once at boot via initLiveStatusSubscription,
keeps a parallel Map, and re-renders the streamer list on
change. Stamps a .streamer-live-dot before the name. Bold name
for live streamers so they pop in scannability.
- Restart triggers: app boot, streamer-list change (added/removed
via save-config) so a freshly added streamer gets their dot in
seconds without waiting for the next 60s tick.
Polish bundled in the same release:
- VOD card hover gets a more substantial lift: 12px shadow + faint
purple border-glow on hover. Subtle but enough to feel
"tactile". Border-color transitions alongside the shadow.
- Empty states get a floating animation and a bigger SVG icon
with accent-colored tint. "No VODs / select a streamer" now
feels intentional instead of an oversight.
- Streamer-name span dedicated class (.streamer-name +
.streamer-name.is-live) so a live streamer's name itself bolds,
not just gets a dot beside it.
Locale strings: liveNowTooltip ("Currently live on Twitch" / "Aktuell
live auf Twitch") for the dot's tooltip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
In-flight live recordings now show a small coloured dot before the
title indicating whether bytes are still flowing.
The health state is derived from byte-progress liveness: each time
the byte counter advances, we stamp lastBytesAdvancedAt; if more
than 30s pass without an advance we flip the badge to amber to tell
the user the streamlink subprocess has gone quiet (dropped segments,
network blip, or the stream just ended). Until the first segment
arrives we report "unknown" so we don't claim health prematurely on
a streamlink that's still negotiating playlists.
Critical wrinkle: streamlink emits progress events on byte boundaries,
so a hung process emits NO events at all. A pure event-driven badge
would never update from "ok" to "stale" — it'd stay frozen at the
last known good state. To avoid that, downloadLiveStream now runs a
10s health-tick interval that re-emits the most recent progress
event with a fresh health computation. The interval is killed in a
finally block so process termination doesn't leak it.
DownloadProgress + QueueItem in both src/types.ts and the renderer
declaration shadow get the new optional recordingHealth field. The
renderer queue handler copies it onto the item; the queue render
function shows a coloured dot before the title for in-flight live
items only (status === 'downloading' && isLive). Three states:
green pulsing (ok), amber flashing (stale), grey static (unknown).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Three Phase-13 wins.
1. Stats bar polling pauses while document.hidden. Previously
setInterval(updateStatsBar, 5000) ran forever, including while
the user had a different tab focused or the window minimised.
Now wraps start/stopStatsBarPolling and listens to
visibilitychange. When the page becomes visible the interval
restarts; while hidden it sleeps. Saves an IPC round-trip every
5s when nobody's looking.
2. Bulk mark / unmark "as downloaded" on the VOD bulk-bar. Companion
to the per-card right-click context menu's mark/unmark items —
when the user has 5 VODs selected they now get one click to
toggle the green check on all of them instead of right-clicking
each. Uses the existing markVodDownloaded IPC, refreshes the
local config copy + re-renders the grid so badges update live.
3. VOD card title tooltip. The card title is text-overflow:ellipsis
so longer titles get cut off. Adding title="${full title}"
surfaces the full text on hover via the native browser tooltip
— no custom UI needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Phase-10 wins.
1. Streamer-list filter + bulk-remove. Above 6 streamers (the magic
number where the list starts to feel cluttered) a search input
appears below the section title and a small bulk-remove "x"
button next to it. Filter is title-substring, case-insensitive.
Bulk-remove honours the active filter — when the input is empty
it confirms removing the entire list, when filled it confirms
removing only the matching subset. Used a confirm() dialog with
the matching count interpolated into the locale string.
2. Cutter drag-and-drop. Dragging a video file from Explorer onto
the cutter tab now loads it directly — no separate Browse click.
Uses Electron's File.path extension on the dropped File object
(works through contextIsolation:true). selectCutterVideo was
refactored into loadCutterFromPath + a thin wrapper so the drop
handler reuses the same loading logic. dragenter/dragleave count
adds visual outline on #cutterPreview while a Files drag is over
the tab. Falls back gracefully if the dropped file lacks .path.
3. Per-streamer VOD scroll position. Switching streamers used to
reset scroll-to-top, painful when cycling between archives.
vodScrollPositions Record<streamer, scrollY> persisted to
localStorage, capped to 32 entries to bound storage. Save fires
on a 250ms scroll-debounce timer + on every selectStreamer
transition. Restore happens 80ms after renderVODs paints (lets
the first chunk settle) so scrollTop has somewhere to land.
Plus: bounded the persistence table at 32 entries, locale strings
DE/EN, all wired through applyLanguageToStaticUI for live language
switch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three companion features around the 4.5.22 already-downloaded badge.
1. "Hide downloaded" toggle in the VOD filter row. Persisted to
localStorage so power users who keep it on across sessions don't
re-flip it on every launch. Filter applies before the title-search
filter so the match counter stays consistent.
2. "Reset downloaded list" button in a new Backup & Maintenance
settings card. Confirm-dialog before clearing, IPC returns the
removed count for a "cleared N entries" toast. Renderer refreshes
its config copy + re-renders the VOD grid so badges disappear
immediately. No files are touched.
3. Config export / import via dialog.show*Dialog. Export strips
client_secret (should never travel as plain text via cloud sync),
tags the file with __exportVersion + __exportedAt. Import runs
the JSON through normalizeConfigTemplates so out-of-range fields
fall back to defaults; if the imported file lacks client_secret,
the existing value is preserved. After import the renderer reloads
config + relocalizes if language changed + re-renders streamers /
settings form / VOD grid.
DE + EN locale strings for every label, button, toast, and confirm
dialog. New backupCardTitle / backupCardIntro section header in
Settings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two real UX wins.
1. Auto-resume queue on startup. New checkbox in Settings -> Download
("Queue beim Start automatisch fortsetzen"). When enabled and the
persisted queue has pending items, processQueue() fires ~5 seconds
after did-finish-load — long enough for the user to see the queue
and pause if they did not actually want this. Default off so the
existing behaviour (explicit Start click) is preserved on upgrade.
The Settings auto-save fingerprint includes the new flag and
syncSettingsFormFromConfig restores it. Tooltip explains the
timing on hover.
2. Already-downloaded indicator on VOD cards. Config gains
downloaded_vod_ids: string[] (bounded to 4096 latest entries).
Every successful queue-item download appends its parsed VOD ID
(or every component ID for merge groups). On the VOD grid each
card whose vod.id is in the set gets a small green checkmark
badge in the top-right plus a slightly dimmed thumbnail, with a
localized "Already downloaded" / "Bereits heruntergeladen"
tooltip. The lookup builds a Set once per render so it stays
O(1) per card. The renderer refreshes its local config copy on
every "newly completed" queue update so the badge appears live
without waiting for a settings save.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Phase-6 wins.
1. Cutter & Merge tab labels were the same i18n gap as the trim-VOD
dialog before 4.5.20: Dauer / Aufloesung / FPS / Auswahl / Start: /
Ende: / Schneiden / Zusammenfuegen were hardcoded German in
index.html. Each got an id + setText wiring + DE/EN locale strings
(cutter.infoDuration / .infoResolution / .infoFps / .infoSelection
/ .startLabel / .endLabel; cutter.cut + merge.merge already existed
for dynamic state, now also used as initial text on btnCut /
btnMerge).
2. Per-item retry button on failed queue entries. The existing
"retry failed" queue-action retried ALL failed items at once;
when only one specific item should be retried (e.g. transient
network blip on one URL), the user had to remove every other
failed item first. New ipcMain.handle("retry-queue-item", id)
resets that single item to status: pending and triggers
processQueue if idle. A small ↻ icon now sits next to the
remove (x) button on items in the error state.
3. Status bar queue summary. The footer previously showed only the
connection status + version. With longer queues the user had to
scroll the queue panel to see how many downloads were active
versus pending. New span between the status indicator and the
version reads "{downloading} dl, {pending} queued" (locale-aware,
hidden when queue is empty). Updated on onQueueUpdated and
onDownloadProgress so it stays live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small UX wins.
1. Trim-VOD dialog: every inner label was hardcoded German in
index.html (Start:, Ende:, Startzeit (HH:MM:SS):, Dauer:, Start
Part-Nummer..., Leer lassen = Teil 1, Dateinamen-Format:, Zur
Queue hinzufuegen). EN-mode users had a German dialog. Each
element now has an id + setText wiring + DE/EN locale strings.
2. Settings -> Twitch API card now opens with a help line + link
to dev.twitch.tv/console/apps. Uses window.api.openExternal so
the link opens in the user's default browser instead of the
Electron renderer (which has nodeIntegration off / no native
navigation). Fixes the "no idea how to set this up" first-run
friction.
3. Settings -> Live Debug Log gets an "Open log file" button next
to Refresh. Uses a new ipcMain handle (open-debug-log-file ->
shell.showItemInFolder on DEBUG_LOG_FILE) so users no longer
have to navigate manually to ProgramData. As a small defensive
bundle:
- get-debug-log: lines parameter capped at [1, 5000] so a
misbehaving renderer (or future feature) cannot ask main to
slice millions of lines.
- export-runtime-metrics: now uses writeFileAtomicSync (the
fsync+rename helper from cycle 1) instead of plain
writeFileSync so a power loss mid-export cannot leave a
half-written metrics file at the user-chosen path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related Phase-4 changes.
1. main.ts: tBackend(key, params) helper with DE/EN tables for every
user-visible error / status string produced server-side. Previously
every backend message was hardcoded German, so EN-mode users saw
German errors in the queue (last_error), in download progress
status, in clip-download responses, and in the preflight panel.
~30 keys covered: invalidVodUrl, streamlinkMissing, fileTooSmall,
integrity*, downloadCancelled / downloadPaused, attemptFailed,
retryingIn, statusBytesDownloaded, mergeGroupFileMissing,
notAllPartsDownloaded / notAllClipPartsDownloaded, ffmpegMerge/
SplitFailed, diskSpaceShortFor, all preflight* messages, etc.
classifyDownloadError extended to recognize EN equivalents
(streamlink not found, no video stream, folder) so the retry
classification still works correctly when the language is EN.
The hand-rolled translation table in renderer.ts:downloadClip is
gone — backend strings are already locale-correct.
2. styles.css: --border-soft CSS var added to :root and the
theme-light override. Inline styles in index.html for the VOD
filter input / sort select / bulk bar were referencing
--bg-secondary / --text-primary / --border-color (which don't
exist) and falling through to dark hex fallbacks (#222 / #fff /
#444), producing a dark patch in light theme. Now uses
var(--bg-card) / var(--text) / var(--border-soft) which both
themes define.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Trim-Clip filename-format radio group only offered three presets
(simple, timestamp, custom template). Users who organise their archive
with the global filename_template_parts pattern (e.g.
08.05.2026_Part07.mp4) had to switch to "custom template" and retype
{date}_Part{part_padded}.mp4 every time.
New "parts" preset:
- index.html: 4th radio option, span#formatParts for the live preview
- types.ts + renderer-globals.d.ts: filenameFormat union extended
- main.ts: makeClipFilename branch produces ${dateStr}_Part${padded}.mp4;
sanitizeCustomClip whitelists "parts" so persisted queue items with
the new format survive a restart
- renderer.ts: getSelectedFilenameFormat returns "parts"; live preview
via partNum.padStart(2, "0")
- DE/EN locales: clips.formatParts label
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a sort selector next to the existing filter input. Five modes:
newest first (default), oldest first, most viewed, longest first,
shortest first. Concrete user pain — long archives previously had no
way to find the longest stream, the most-watched, or to scroll back
to the start chronologically.
- vodSortKey state persisted to localStorage as
twitch-vod-manager:vod-sort and validated against an enum on load,
so an unknown stored value falls back to date_desc
- renderVodGridFromCurrentState now applies sortVods before
filterVodsByQuery so the filter sees the sort and the match counter
is consistent
- sortVods uses created_at timestamps for date sorts, view_count for
views, and a tiny vodDurationToSeconds parser (XhYmZs) for duration
- DE + EN labels for both the "Sort:" prefix and the five option
texts; refreshVodSortSelectLabels re-runs on language switch
- syncVodSortSelect on init preselects the persisted value before
any VOD load so the dropdown reflects state immediately
Browser-default keyboard nav (arrows, type-ahead) covers keyboard
access for the select.
docs/IMPROVEMENT_LOG.md: Cycle 4 dated section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filter row above the VOD grid lets the user search the loaded archive
by title. Concrete user pain: streamers commonly have hundreds of VODs
and the current UI only supported scrolling.
- vodFilterInput / vodFilterClearBtn / vodFilterCount in index.html
- localized placeholder + clear-button title (DE + EN)
- vodFilterQuery state persisted to localStorage as
twitch-vod-manager:vod-filter so the search bar survives reloads
- renderVODs split: it now caches lastLoadedVods + lastLoadedStreamer
and delegates to renderVodGridFromCurrentState which applies
filterVodsByQuery on every input event (no re-fetch)
- empty-state DOM is now built with createElement + textContent (via
setVodGridEmptyState) instead of an innerHTML template, even for
locale-only strings — defence in depth
- keyboard: Ctrl/Cmd+F focuses the filter when the VODs tab is active
(Electron has no native find bar, so the default is suppressed). Esc
clears the filter when the input has focus and content. Esc still
closes modals first if any are open.
docs/IMPROVEMENT_LOG.md: Cycle 3 dated section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renderer-side polish bundle.
- updateQueueItemProgress now looks up items by [data-id] selector instead
of array index. Resilient against queue/DOM divergence between renders.
Determinate vs indeterminate progress logic tightened.
- Active tab persisted to localStorage on every showTab; restored on init
via loadPersistedActiveTab (whitelisted to known tab IDs so a future
rename cannot strand the user on a missing tab). Page title now only
shows the streamer name on the VODs tab — it no longer leaks into
Settings / Cutter / Merge.
- Escape closes the topmost open modal regardless of focus (clip dialog,
template guide, update modal — in that priority order).
- Ctrl+1..5 (Cmd+1..5 on macOS) jumps directly to a tab. The existing Del
(delete selected) and S (start/pause) shortcuts still work and remain
blocked while typing in inputs.
Adds docs/IMPROVEMENT_LOG.md (new, single dated section for this cycle).
Build: tsc clean. Full smoke suite green (failures: [], runtimeIssues: []).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>