Compare commits

..

No commits in common. "main" and "v4.6.19" have entirely different histories.

20 changed files with 832 additions and 4060 deletions

4
package-lock.json generated
View File

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

View File

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

View File

@ -10,23 +10,23 @@
<body class="theme-twitch">
<div class="update-banner" id="updateBanner">
<span id="updateText">Neue Version verfügbar!</span>
<div id="updateProgress" class="update-banner-progress-wrap is-hidden">
<div class="update-banner-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Update download" id="updateProgressGauge">
<div id="updateProgressBar" class="update-banner-progress-bar"></div>
<div id="updateProgress" style="display: none; flex: 1; margin: 0 15px;">
<div style="background: rgba(0,0,0,0.3); border-radius: 4px; height: 8px; overflow: hidden;">
<div id="updateProgressBar" style="background: white; height: 100%; width: 0%; transition: width 0.3s;"></div>
</div>
</div>
<button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
<button id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
</div>
<div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)">
<div class="modal-overlay" id="updateModal" onclick="handleUpdateModalOverlayClick(event)">
<div class="modal update-modal">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button>
<button class="modal-close" onclick="dismissUpdateModal()">x</button>
<div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div>
<h2 id="updateModalTitle">Update verfugbar</h2>
<p class="update-modal-message" id="updateModalMessage">Version 0.0.0 ist verfugbar. Jetzt herunterladen?</p>
<div class="update-modal-meta is-hidden" id="updateModalMeta"></div>
<div class="update-modal-meta" id="updateModalMeta" style="display:none;"></div>
<div class="update-changelog-card is-hidden" id="updateChangelogCard">
<div class="update-changelog-card" id="updateChangelogCard" style="display:none;">
<div class="update-changelog-header">
<span class="update-changelog-label" id="updateChangelogLabel">Changelog</span>
<button type="button" class="update-changelog-toggle" id="updateChangelogToggle" onclick="toggleUpdateChangelog()">Changelog anzeigen</button>
@ -46,110 +46,133 @@
</div>
<!-- Clip Dialog Modal -->
<div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-labelledby="clipDialogTitle">
<div class="modal clip-modal">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeClipDialog()">x</button>
<h2 class="clip-modal-title" id="clipDialogTitle">VOD zuschneiden</h2>
<div class="modal-overlay" id="clipModal">
<div class="modal" style="background: #2b2b2b; max-width: 500px;">
<button class="modal-close" onclick="closeClipDialog()">x</button>
<h2 style="color: #E5A00D; text-align: center; margin-bottom: 20px;" id="clipDialogTitle">VOD zuschneiden</h2>
<div class="clip-modal-field">
<label class="clip-modal-label" id="clipDialogStartLabel" for="clipStartSlider">Start:</label>
<input type="range" id="clipStartSlider" min="0" max="100" value="0" oninput="updateFromSlider('start')">
<div class="clip-modal-time-row">
<label class="clip-modal-meta" id="clipDialogStartTimeLabel" for="clipStartTime">Startzeit (HH:MM:SS):</label>
<input type="text" id="clipStartTime" value="00:00:00" class="clip-modal-time-input" onchange="updateFromInput('start')">
<!-- Start Zeit mit Slider -->
<div style="margin-bottom: 15px;">
<label id="clipDialogStartLabel" style="display: block; margin-bottom: 5px;">Start:</label>
<input type="range" id="clipStartSlider" min="0" max="100" value="0"
style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;"
oninput="updateFromSlider('start')">
<div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;">
<label id="clipDialogStartTimeLabel" style="color: #888;">Startzeit (HH:MM:SS):</label>
<input type="text" id="clipStartTime" value="00:00:00"
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 6px 10px; color: white; font-family: monospace; text-align: center;"
onchange="updateFromInput('start')">
</div>
</div>
<div class="clip-modal-field">
<label class="clip-modal-label" id="clipDialogEndLabel" for="clipEndSlider">Ende:</label>
<input type="range" id="clipEndSlider" min="0" max="100" value="60" oninput="updateFromSlider('end')">
<div class="clip-modal-time-row">
<label class="clip-modal-meta" id="clipDialogEndTimeLabel" for="clipEndTime">Endzeit (HH:MM:SS):</label>
<input type="text" id="clipEndTime" value="00:01:00" class="clip-modal-time-input" onchange="updateFromInput('end')">
<!-- End Zeit mit Slider -->
<div style="margin-bottom: 15px;">
<label id="clipDialogEndLabel" style="display: block; margin-bottom: 5px;">Ende:</label>
<input type="range" id="clipEndSlider" min="0" max="100" value="60"
style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;"
oninput="updateFromSlider('end')">
<div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;">
<label id="clipDialogEndTimeLabel" style="color: #888;">Endzeit (HH:MM:SS):</label>
<input type="text" id="clipEndTime" value="00:01:00"
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 6px 10px; color: white; font-family: monospace; text-align: center;"
onchange="updateFromInput('end')">
</div>
</div>
<div class="clip-modal-duration">
<span id="clipDialogDurationLabel" class="clip-modal-meta">Dauer: </span>
<span id="clipDurationDisplay" class="clip-modal-duration-value">00:01:00</span>
<!-- Dauer Anzeige -->
<div style="text-align: center; margin-bottom: 20px;">
<span id="clipDialogDurationLabel" style="color: #888;">Dauer: </span>
<span id="clipDurationDisplay" style="color: #00c853;">00:01:00</span>
</div>
<div class="clip-modal-field">
<label class="clip-modal-label" id="clipDialogPartLabel" for="clipStartPart">Start Part-Nummer (optional, fur Fortsetzung):</label>
<input type="text" id="clipStartPart" placeholder="z.B. 42" class="clip-modal-part-input" oninput="updateFilenameExamples()">
<div id="clipDialogPartHint" class="clip-modal-hint">Leer lassen = Teil 1</div>
<!-- Teil Nummer -->
<div style="margin-bottom: 15px;">
<label id="clipDialogPartLabel" style="display: block; margin-bottom: 8px;">Start Part-Nummer (optional, fur Fortsetzung):</label>
<input type="text" id="clipStartPart" placeholder="z.B. 42"
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white;"
oninput="updateFilenameExamples()">
<div id="clipDialogPartHint" style="color: #888; font-size: 12px; margin-top: 5px;">Leer lassen = Teil 1</div>
</div>
<div class="clip-modal-field">
<label class="clip-modal-label" id="clipDialogFormatLabel">Dateinamen-Format:</label>
<label class="clip-radio-row">
<input type="radio" name="filenameFormat" value="simple" checked onchange="updateFilenameExamples()">
<span id="formatSimple" class="clip-radio-label">01.02.2026_1.mp4 (Standard)</span>
<!-- Dateinamen Format -->
<div style="margin-bottom: 20px;">
<label id="clipDialogFormatLabel" style="display: block; margin-bottom: 10px;">Dateinamen-Format:</label>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
<input type="radio" name="filenameFormat" value="simple" checked onchange="updateFilenameExamples()"
style="width: 18px; height: 18px; accent-color: #9146FF;">
<span id="formatSimple" style="color: #aaa;">01.02.2026_1.mp4 (Standard)</span>
</label>
<label class="clip-radio-row">
<input type="radio" name="filenameFormat" value="timestamp" onchange="updateFilenameExamples()">
<span id="formatTimestamp" class="clip-radio-label">01.02.2026_CLIP_00-00-00_1.mp4 (mit Zeitstempel)</span>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
<input type="radio" name="filenameFormat" value="timestamp" onchange="updateFilenameExamples()"
style="width: 18px; height: 18px; accent-color: #9146FF;">
<span id="formatTimestamp" style="color: #aaa;">01.02.2026_CLIP_00-00-00_1.mp4 (mit Zeitstempel)</span>
</label>
<label class="clip-radio-row">
<input type="radio" name="filenameFormat" value="parts" onchange="updateFilenameExamples()">
<span id="formatParts" class="clip-radio-label">01.02.2026_Part01.mp4 (Parts-Format)</span>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
<input type="radio" name="filenameFormat" value="parts" onchange="updateFilenameExamples()"
style="width: 18px; height: 18px; accent-color: #9146FF;">
<span id="formatParts" style="color: #aaa;">01.02.2026_Part01.mp4 (Parts-Format)</span>
</label>
<label class="clip-radio-row">
<input type="radio" name="filenameFormat" value="template" onchange="updateFilenameExamples()">
<span id="formatTemplate" class="clip-radio-label">{date}_{part}.mp4 (benutzerdefiniert)</span>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 10px;">
<input type="radio" name="filenameFormat" value="template" onchange="updateFilenameExamples()"
style="width: 18px; height: 18px; accent-color: #9146FF;">
<span id="formatTemplate" style="color: #aaa;">{date}_{part}.mp4 (benutzerdefiniert)</span>
</label>
<div id="clipFilenameTemplateWrap" class="clip-template-wrap">
<input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4" placeholder="{date}_{part}.mp4" class="clip-modal-template-input" oninput="updateFilenameExamples()">
<div id="clipTemplateHelp" class="clip-modal-hint">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<div id="clipTemplateLint" class="template-lint ok">Template-Check: OK</div>
<button type="button" class="btn-secondary" id="clipTemplateGuideBtn" onclick="openTemplateGuide('clip')">Template Guide</button>
<div id="clipFilenameTemplateWrap" style="display:none; margin-top: 10px;">
<input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4"
placeholder="{date}_{part}.mp4"
style="width: 100%; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white; font-family: monospace;"
oninput="updateFilenameExamples()">
<div id="clipTemplateHelp" style="color: #888; font-size: 12px; margin-top: 6px;">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<div id="clipTemplateLint" style="color: #8bc34a; font-size: 12px; margin-top: 4px;">Template-Check: OK</div>
<button class="btn-secondary" id="clipTemplateGuideBtn" style="margin-top: 8px;" onclick="openTemplateGuide('clip')">Template Guide</button>
</div>
</div>
<div class="clip-modal-actions">
<button type="button" class="btn-pill success" id="clipDialogConfirmBtn" style="padding: 12px 30px;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
<!-- Button -->
<div style="text-align: center;">
<button class="btn-primary" id="clipDialogConfirmBtn" style="background: #00c853; padding: 12px 30px; border: none; border-radius: 4px; color: white; font-weight: 600; cursor: pointer;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
</div>
</div>
</div>
<!-- Events Viewer Modal -->
<div class="modal-overlay" id="eventsViewerModal" role="dialog" aria-modal="true" aria-labelledby="eventsViewerTitle">
<div class="modal viewer-modal viewer-modal-events">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeEventsViewer()">x</button>
<h2 id="eventsViewerTitle" class="viewer-modal-title"></h2>
<div id="eventsViewerStatus" class="viewer-modal-status" role="status" aria-live="polite"></div>
<div id="eventsViewerList" class="viewer-modal-list"></div>
<div class="modal-overlay" id="eventsViewerModal">
<div class="modal" style="max-width: 700px; max-height: 80vh; display:flex; flex-direction:column;">
<button class="modal-close" onclick="closeEventsViewer()">x</button>
<h2 id="eventsViewerTitle" style="margin-top:0;">Stream events</h2>
<div id="eventsViewerStatus" style="color:var(--text-secondary); font-size:12px; margin-bottom:8px;"></div>
<div id="eventsViewerList" style="flex:1; overflow-y:auto; background: var(--bg-main); border:1px solid var(--border-soft); border-radius:6px; padding:8px;"></div>
</div>
</div>
<!-- Chat Replay Viewer Modal -->
<div class="modal-overlay" id="chatViewerModal" role="dialog" aria-modal="true" aria-labelledby="chatViewerTitle">
<div class="modal viewer-modal viewer-modal-chat">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeChatViewer()">x</button>
<h2 id="chatViewerTitle" class="viewer-modal-title"></h2>
<div class="viewer-modal-filter-row">
<input type="text" id="chatViewerFilter" class="viewer-modal-filter-input" placeholder="Filter..." oninput="onChatViewerFilterChange()">
<span id="chatViewerStatus" class="viewer-modal-status viewer-modal-status-inline" role="status" aria-live="polite"></span>
<div class="modal-overlay" id="chatViewerModal">
<div class="modal" style="max-width: 800px; height: 80vh; display:flex; flex-direction:column;">
<button class="modal-close" onclick="closeChatViewer()">x</button>
<h2 id="chatViewerTitle" style="margin-top:0;">Chat replay</h2>
<div class="form-row" style="margin-bottom:8px; gap:8px; flex-wrap:wrap; align-items:center;">
<input type="text" id="chatViewerFilter" placeholder="Filter..." oninput="onChatViewerFilterChange()" style="flex:1; min-width:160px; background: var(--bg-card); border:1px solid var(--border-soft); border-radius:6px; padding:6px 10px; color:var(--text); font-size:13px;">
<span id="chatViewerStatus" style="color:var(--text-secondary); font-size:12px;"></span>
</div>
<div id="chatViewerList" class="viewer-modal-list viewer-modal-list-chat"></div>
<div id="chatViewerList" style="flex:1; overflow-y:auto; background: var(--bg-main); border:1px solid var(--border-soft); border-radius:6px; padding:8px; font-family: 'Consolas', monospace; font-size: 12px;"></div>
</div>
</div>
<!-- Template Guide Modal -->
<div class="modal-overlay" id="templateGuideModal" role="dialog" aria-modal="true" aria-labelledby="templateGuideTitle">
<div class="modal-overlay" id="templateGuideModal">
<div class="modal template-guide-modal">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button>
<button class="modal-close" onclick="closeTemplateGuide()">x</button>
<h2 id="templateGuideTitle">Template Guide</h2>
<p id="templateGuideIntro" class="template-guide-intro">Nutze Variablen fur Dateinamen und prufe das Ergebnis als Live-Vorschau.</p>
<div class="template-guide-actions">
<button type="button" class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button>
<button type="button" class="btn-secondary" id="templateGuideUseParts" onclick="setTemplateGuidePreset('parts')">VOD Part Template</button>
<button type="button" class="btn-secondary" id="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button>
<button class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button>
<button class="btn-secondary" id="templateGuideUseParts" onclick="setTemplateGuidePreset('parts')">VOD Part Template</button>
<button class="btn-secondary" id="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button>
</div>
<label id="templateGuideTemplateLabel" for="templateGuideInput" class="template-guide-label">Template</label>
<label id="templateGuideTemplateLabel" class="template-guide-label">Template</label>
<input type="text" id="templateGuideInput" class="template-guide-input" oninput="updateTemplateGuidePreview()" placeholder="{title}.mp4">
<div class="template-guide-preview-box">
@ -160,12 +183,12 @@
<h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfugbare Variablen</h3>
<div class="template-guide-table-wrap">
<table class="template-guide-table" aria-labelledby="templateGuideVarsTitle">
<table class="template-guide-table">
<thead>
<tr>
<th id="templateGuideVarCol" scope="col">Variable</th>
<th id="templateGuideDescCol" scope="col">Beschreibung</th>
<th id="templateGuideExampleCol" scope="col">Beispiel</th>
<th id="templateGuideVarCol">Variable</th>
<th id="templateGuideDescCol">Beschreibung</th>
<th id="templateGuideExampleCol">Beispiel</th>
</tr>
</thead>
<tbody id="templateGuideBody"></tbody>
@ -173,7 +196,7 @@
</div>
<div class="template-guide-footer">
<button type="button" class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button>
<button class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button>
</div>
</div>
</div>
@ -181,49 +204,46 @@
<div class="app">
<aside class="sidebar">
<div class="logo">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
<svg viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
<span id="logoText">Twitch VOD Manager</span>
</div>
<nav class="nav">
<div class="nav-item active" role="button" tabindex="0" aria-current="page" data-tab="vods" onclick="showTab('vods')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
<div class="nav-item active" data-tab="vods" onclick="showTab('vods')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
<span id="navVodsText">Twitch VODs</span>
</div>
<div class="nav-item" role="button" tabindex="0" data-tab="clips" onclick="showTab('clips')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
<div class="nav-item" data-tab="clips" onclick="showTab('clips')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
<span id="navClipsText">Twitch Clips</span>
</div>
<div class="nav-item" role="button" tabindex="0" data-tab="cutter" onclick="showTab('cutter')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg>
<div class="nav-item" data-tab="cutter" onclick="showTab('cutter')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg>
<span id="navCutterText">Video schneiden</span>
</div>
<div class="nav-item" role="button" tabindex="0" data-tab="merge" onclick="showTab('merge')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg>
<div class="nav-item" data-tab="merge" onclick="showTab('merge')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg>
<span id="navMergeText">Videos zusammenfugen</span>
</div>
<div class="nav-item" role="button" tabindex="0" data-tab="stats" onclick="showTab('stats')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v8H3zm4-7h2v15H7zm4 4h2v11h-2zm4 4h2v7h-2zm4-8h2v15h-2z"/></svg>
<div class="nav-item" data-tab="stats" onclick="showTab('stats')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v8H3zm4-7h2v15H7zm4 4h2v11h-2zm4 4h2v7h-2zm4-8h2v15h-2z"/></svg>
<span id="navStatsText">Statistik</span>
</div>
<div class="nav-item" role="button" tabindex="0" data-tab="archive" onclick="showTab('archive')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<div class="nav-item" data-tab="archive" onclick="showTab('archive')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<span id="navArchiveText">Archiv</span>
</div>
<div class="nav-item" role="button" tabindex="0" data-tab="settings" onclick="showTab('settings')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
<div class="nav-item" data-tab="settings" onclick="showTab('settings')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
<span id="navSettingsText">Einstellungen</span>
</div>
</nav>
<div class="section-title" id="streamerSectionTitle">
<span class="section-title-label">
<div class="section-title" id="streamerSectionTitle" style="display:flex; align-items:center; gap:6px; justify-content:space-between;">
<span id="streamerSectionTitleText">Streamer</span>
<span id="streamerSectionCounter" class="streamer-section-counter"></span>
</span>
<button id="btnStreamerBulkRemove" class="btn-close is-hidden" type="button" onclick="bulkRemoveStreamers()" title="Bulk remove">x</button>
<button id="btnStreamerBulkRemove" type="button" onclick="bulkRemoveStreamers()" title="Bulk remove" style="display:none; background:transparent; border:1px solid var(--border-soft); border-radius:4px; padding:2px 8px; color:var(--text-secondary); font-size:11px; cursor:pointer;">x</button>
</div>
<input type="text" id="streamerListFilter" class="filter-input compact is-hidden" placeholder="Filter..." oninput="onStreamerListFilterChange()">
<input type="text" id="streamerListFilter" placeholder="Filter..." oninput="onStreamerListFilterChange()" style="display:none; width:calc(100% - 16px); margin:0 8px 8px; background:var(--bg-card); border:1px solid var(--border-soft); border-radius:4px; padding:4px 8px; color:var(--text); font-size:12px;">
<div class="streamers" id="streamerList"></div>
<div class="queue-section">
@ -233,10 +253,10 @@
</div>
<div class="queue-list" id="queueList"></div>
<div class="queue-actions">
<button type="button" class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
<button type="button" class="btn btn-merge-group is-hidden" id="btnMergeGroup" onclick="createMergeGroupFromSelection()">Merge &amp; Split</button>
<button type="button" class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
<button type="button" class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
<button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
<button class="btn btn-merge-group" id="btnMergeGroup" onclick="createMergeGroupFromSelection()" style="display:none">Merge &amp; Split</button>
<button class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
<button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
</div>
</div>
<div class="stats-bar" id="statsBar"></div>
@ -248,10 +268,10 @@
<div class="header-actions">
<div class="header-search">
<input type="text" id="newStreamer" placeholder="Streamer hinzufugen..." onkeypress="if(event.key==='Enter')addStreamer()">
<button id="btnAddStreamer" type="button" onclick="addStreamer()" aria-label="Add streamer" title="Add streamer">+</button>
<button onclick="addStreamer()">+</button>
</div>
<button type="button" class="btn-icon" onclick="refreshVODs()">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
<button class="btn-icon" onclick="refreshVODs()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
<span id="refreshText">Aktualisieren</span>
</button>
</div>
@ -260,37 +280,37 @@
<div class="content">
<!-- VODs Tab -->
<div class="tab-content active" id="vodsTab">
<div id="streamerProfileHeader" class="streamer-profile-header is-hidden"></div>
<div class="vod-filter-row">
<input type="text" id="vodFilterInput" class="filter-input" placeholder="Filter VODs..." oninput="onVodFilterInput()">
<button type="button" id="vodFilterClearBtn" class="btn-close is-hidden" onclick="clearVodFilter()" title="Clear filter">x</button>
<label id="vodSortLabel" for="vodSortSelect" class="form-sublabel vod-sort-label">Sort:</label>
<select id="vodSortSelect" class="select-compact" onchange="onVodSortChange()">
<div id="streamerProfileHeader" class="streamer-profile-header" style="display:none;"></div>
<div class="vod-filter-row" style="display:flex; align-items:center; gap:8px; margin-bottom:12px; flex-wrap:wrap;">
<input type="text" id="vodFilterInput" placeholder="Filter VODs..." oninput="onVodFilterInput()" style="flex:1; min-width:180px; background: var(--bg-card); border:1px solid var(--border-soft); border-radius:6px; padding:8px 12px; color: var(--text); font-size:13px;">
<button id="vodFilterClearBtn" onclick="clearVodFilter()" title="Clear filter" style="display:none; background:transparent; border:1px solid var(--border-soft); border-radius:6px; padding:8px 12px; color: var(--text-secondary); cursor:pointer;">x</button>
<label id="vodSortLabel" for="vodSortSelect" style="color: var(--text-secondary); font-size:12px; margin-left:8px;">Sort:</label>
<select id="vodSortSelect" onchange="onVodSortChange()" style="background: var(--bg-card); border:1px solid var(--border-soft); border-radius:6px; padding:7px 10px; color: var(--text); font-size:13px;">
<option value="date_desc">Newest first</option>
<option value="date_asc">Oldest first</option>
<option value="views_desc">Most viewed</option>
<option value="duration_desc">Longest first</option>
<option value="duration_asc">Shortest first</option>
</select>
<span id="vodFilterCount" class="form-sublabel vod-filter-count"></span>
<label id="vodHideDownloadedLabel" class="inline-toggle" title="">
<input type="checkbox" id="vodHideDownloadedToggle" onchange="onVodHideDownloadedChange()">
<span id="vodFilterCount" style="color: var(--text-secondary); font-size:12px; min-width:80px;"></span>
<label id="vodHideDownloadedLabel" style="display:flex; align-items:center; gap:6px; color: var(--text-secondary); font-size:12px; cursor:pointer; user-select:none;" title="">
<input type="checkbox" id="vodHideDownloadedToggle" onchange="onVodHideDownloadedChange()" style="accent-color: var(--accent); cursor:pointer;">
<span id="vodHideDownloadedText">Hide downloaded</span>
</label>
</div>
<div id="vodBulkBar" class="vod-bulk-bar is-hidden">
<span id="vodBulkCount" class="vod-bulk-count">0 selected</span>
<span class="vod-bulk-spacer"></span>
<button id="vodBulkAddBtn" class="btn-pill primary" type="button" onclick="bulkAddSelectedVodsToQueue()">+ Queue</button>
<button id="vodBulkMarkBtn" class="btn-pill" type="button" onclick="bulkMarkSelectedDownloaded(true)">Mark as downloaded</button>
<button id="vodBulkUnmarkBtn" class="btn-pill" type="button" onclick="bulkMarkSelectedDownloaded(false)">Unmark</button>
<button id="vodBulkClearBtn" class="btn-pill" type="button" onclick="clearVodSelection()">Clear</button>
<div id="vodBulkBar" class="vod-bulk-bar" style="display:none; align-items:center; gap:10px; padding:8px 12px; background: rgba(145, 70, 255, 0.12); border:1px solid rgba(145, 70, 255, 0.4); border-radius:6px; margin-bottom:12px; flex-wrap:wrap;">
<span id="vodBulkCount" style="color: var(--text); font-size:13px; font-weight:600;">0 selected</span>
<span style="flex:1;"></span>
<button id="vodBulkAddBtn" type="button" onclick="bulkAddSelectedVodsToQueue()" style="background:var(--accent); border:none; border-radius:6px; padding:6px 14px; color:#fff; font-size:13px; font-weight:600; cursor:pointer;">+ Queue</button>
<button id="vodBulkMarkBtn" type="button" onclick="bulkMarkSelectedDownloaded(true)" style="background:transparent; border:1px solid var(--border-soft); border-radius:6px; padding:6px 12px; color:var(--text-secondary); font-size:13px; cursor:pointer;">Mark as downloaded</button>
<button id="vodBulkUnmarkBtn" type="button" onclick="bulkMarkSelectedDownloaded(false)" style="background:transparent; border:1px solid var(--border-soft); border-radius:6px; padding:6px 12px; color:var(--text-secondary); font-size:13px; cursor:pointer;">Unmark</button>
<button id="vodBulkClearBtn" type="button" onclick="clearVodSelection()" style="background:transparent; border:1px solid var(--border-soft); border-radius:6px; padding:6px 12px; color:var(--text-secondary); font-size:13px; cursor:pointer;">Clear</button>
</div>
<div class="vod-grid" id="vodGrid">
<div class="empty-state">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<h3 id="vodGridEmptyTitle">Keine VODs</h3>
<p id="vodGridEmptyText">Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<h3>Keine VODs</h3>
<p>Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
</div>
</div>
</div>
@ -300,13 +320,13 @@
<div class="clip-input">
<h2 id="clipsHeading">Twitch Clip-Download</h2>
<input type="text" id="clipUrl" placeholder="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...">
<button type="button" class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
<div class="clip-status" id="clipStatus" role="status" aria-live="polite"></div>
<button class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
<div class="clip-status" id="clipStatus"></div>
</div>
<div class="settings-card centered">
<div class="settings-card" style="max-width: 600px; margin: 20px auto;">
<h3 id="clipsInfoTitle">Info</h3>
<p id="clipsInfoText" class="info-text">
<p style="color: var(--text-secondary); line-height: 1.6; white-space: pre-line;" id="clipsInfoText">
Unterstutzte Formate:
- https://clips.twitch.tv/ClipName
- https://www.twitch.tv/streamer/clip/ClipName
@ -323,18 +343,18 @@
<h3 id="cutterSelectTitle">Video auswahlen</h3>
<div class="form-row">
<input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt...">
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
<button class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
</div>
</div>
<div class="video-preview" id="cutterPreview">
<div class="placeholder">
<svg aria-hidden="true" width="64" height="64" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
<p>Video auswahlen um Vorschau zu sehen</p>
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
<p style="margin-top:10px">Video auswahlen um Vorschau zu sehen</p>
</div>
</div>
<div class="cutter-info" id="cutterInfo">
<div class="cutter-info" id="cutterInfo" style="display:none">
<div class="cutter-info-item">
<span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span>
<span class="cutter-info-value" id="infoDuration">--:--:--</span>
@ -353,7 +373,7 @@
</div>
</div>
<div class="timeline-container" id="timelineContainer">
<div class="timeline-container" id="timelineContainer" style="display:none">
<div class="timeline" id="timeline" onclick="seekTimeline(event)">
<div class="timeline-selection" id="timelineSelection"></div>
<div class="timeline-current" id="timelineCurrent"></div>
@ -361,25 +381,25 @@
<div class="time-inputs">
<div class="time-input-group">
<label id="cutterStartLabel" for="startTime">Start:</label>
<label id="cutterStartLabel">Start:</label>
<input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()">
</div>
<div class="time-input-group">
<label id="cutterEndLabel" for="endTime">Ende:</label>
<label id="cutterEndLabel">Ende:</label>
<input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()">
</div>
</div>
</div>
<div class="progress-container" id="cutProgress">
<div class="progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Cut progress" id="cutProgressGauge">
<div class="progress-bar">
<div class="progress-bar-fill" id="cutProgressBar"></div>
</div>
<div class="progress-text" id="cutProgressText">0%</div>
</div>
<div class="cutter-actions">
<button type="button" class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
<button class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
</div>
</div>
</div>
@ -389,29 +409,29 @@
<div class="merge-container">
<div class="settings-card">
<h3 id="mergeTitle">Videos zusammenfugen</h3>
<p id="mergeDesc" class="card-intro">
<p style="color: var(--text-secondary); margin-bottom: 15px;" id="mergeDesc">
Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen.
Die Reihenfolge kann per Drag & Drop geandert werden.
</p>
<button type="button" class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button>
<button class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button>
</div>
<div class="file-list" id="mergeFileList">
<div class="empty-state merge-empty-state">
<svg aria-hidden="true" width="48" height="48" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<p id="mergeEmptyText">Keine Videos ausgewahlt</p>
<div class="empty-state" style="padding: 40px 20px;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<p style="margin-top:10px">Keine Videos ausgewahlt</p>
</div>
</div>
<div class="progress-container" id="mergeProgress">
<div class="progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Merge progress" id="mergeProgressGauge">
<div class="progress-bar">
<div class="progress-bar-fill" id="mergeProgressBar"></div>
</div>
<div class="progress-text" id="mergeProgressText">0%</div>
</div>
<div class="merge-actions">
<button type="button" class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button>
<button class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button>
</div>
</div>
</div>
@ -419,19 +439,19 @@
<!-- Statistics Tab -->
<div class="tab-content" id="statsTab">
<div class="settings-card">
<div class="form-row section-header">
<h3 id="statsTitle">Archiv-Statistik</h3>
<div class="section-header-actions">
<span id="statsLastScannedLabel" class="form-sublabel" role="status" aria-live="polite"></span>
<button type="button" class="btn-secondary" id="btnStatsRefresh" onclick="refreshArchiveStats()">Aktualisieren</button>
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;">
<h3 id="statsTitle" style="margin:0;">Archiv-Statistik</h3>
<div style="display:flex; gap:8px; align-items:center;">
<span id="statsLastScannedLabel" style="font-size:12px; color:var(--text-secondary);"></span>
<button class="btn-secondary" id="btnStatsRefresh" onclick="refreshArchiveStats()">Aktualisieren</button>
</div>
</div>
<p id="statsIntro" class="card-intro flush">Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.</p>
<p id="statsIntro" style="color: var(--text-secondary); font-size:13px; margin-top:8px; margin-bottom:0; line-height:1.5;">Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.</p>
</div>
<div class="settings-card">
<h3 id="statsSummaryTitle">Uebersicht</h3>
<div id="statsSummaryGrid" class="stats-summary-grid"></div>
<div id="statsSummaryGrid" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px;"></div>
</div>
<div class="settings-card">
@ -453,28 +473,28 @@
<!-- Archive Search Tab -->
<div class="tab-content" id="archiveTab">
<div class="settings-card">
<h3 id="archiveTitle">Archiv durchsuchen</h3>
<p id="archiveIntro" class="card-intro">Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.</p>
<div class="form-row search-bar">
<input type="text" id="archiveSearchQuery" class="filter-input flex-1-1-240" placeholder="Suche...">
<select id="archiveSearchType" class="select-compact">
<h3 id="archiveTitle" style="margin-top:0;">Archiv durchsuchen</h3>
<p id="archiveIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.</p>
<div class="form-row" style="gap:8px; margin-bottom: 8px; flex-wrap: wrap; align-items:center;">
<input type="text" id="archiveSearchQuery" placeholder="Suche..." style="flex: 1 1 240px; min-width: 200px; background: var(--bg-card); border: 1px solid var(--border-soft); border-radius: 4px; padding: 6px 10px; color: var(--text);">
<select id="archiveSearchType" style="background: var(--bg-card); border: 1px solid var(--border-soft); border-radius: 4px; padding: 6px 10px; color: var(--text);">
<option value="all">Alle Typen</option>
<option value="live">Live-Aufnahmen</option>
<option value="vod">VOD-Downloads</option>
</select>
<select id="archiveSearchStreamer" class="select-compact size-md">
<select id="archiveSearchStreamer" style="background: var(--bg-card); border: 1px solid var(--border-soft); border-radius: 4px; padding: 6px 10px; color: var(--text); min-width: 160px;">
<option value="">Alle Streamer</option>
</select>
<select id="archiveSearchSort" class="select-compact">
<select id="archiveSearchSort" style="background: var(--bg-card); border: 1px solid var(--border-soft); border-radius: 4px; padding: 6px 10px; color: var(--text);">
<option value="date_desc">Neueste zuerst</option>
<option value="date_asc">Aelteste zuerst</option>
<option value="size_desc">Groesste zuerst</option>
<option value="size_asc">Kleinste zuerst</option>
<option value="name_asc">Name (A-Z)</option>
</select>
<button type="button" class="btn-secondary" id="btnArchiveSearch" onclick="performArchiveSearch()">Suchen</button>
<button class="btn-secondary" id="btnArchiveSearch" onclick="performArchiveSearch()">Suchen</button>
</div>
<div id="archiveSearchSummary" class="form-sublabel" role="status" aria-live="polite"></div>
<div id="archiveSearchSummary" style="font-size: 12px; color: var(--text-secondary);"></div>
</div>
<div class="settings-card">
<div id="archiveSearchResults"></div>
@ -486,7 +506,7 @@
<div class="settings-card">
<h3 id="designTitle">Design</h3>
<div class="form-group">
<label id="themeLabel" for="themeSelect">Theme</label>
<label id="themeLabel">Theme</label>
<select id="themeSelect" onchange="changeTheme(this.value)">
<option value="twitch">Twitch</option>
<option value="discord">Discord</option>
@ -497,7 +517,7 @@
</div>
<div class="form-group">
<label id="languageLabel">Sprache</label>
<div class="language-picker" id="languagePicker" role="group" aria-labelledby="languageLabel">
<div class="language-picker" id="languagePicker">
<button type="button" class="lang-option" id="langOptionDe" onclick="selectLanguageOption('de')" aria-pressed="false">
<span class="flag-icon flag-de" aria-hidden="true"></span>
<span id="languageDeText">Deutsch</span>
@ -516,51 +536,51 @@
<div class="settings-card">
<h3 id="apiTitle">Twitch API</h3>
<p id="apiHelpText" class="card-intro">
<p id="apiHelpText" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">
<span id="apiHelpIntro">Du brauchst eine Client-ID und ein Client-Secret von Twitch.</span>
<a href="#" id="apiHelpLink" onclick="event.preventDefault(); openTwitchDevConsole()">dev.twitch.tv/console/apps</a>
<a href="#" id="apiHelpLink" onclick="event.preventDefault(); openTwitchDevConsole()" style="color: var(--accent); text-decoration: underline; cursor: pointer;">dev.twitch.tv/console/apps</a>
</p>
<div class="form-group">
<label id="clientIdLabel" for="clientId">Client ID</label>
<label id="clientIdLabel">Client ID</label>
<input type="text" id="clientId" placeholder="Twitch Client ID">
</div>
<div class="form-group">
<label id="clientSecretLabel" for="clientSecret">Client Secret</label>
<label id="clientSecretLabel">Client Secret</label>
<input type="password" id="clientSecret" placeholder="Twitch Client Secret">
</div>
<button type="button" class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
<button class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
</div>
<div class="settings-card">
<h3 id="downloadSettingsTitle">Download-Einstellungen</h3>
<div class="form-group">
<label id="storageLabel" for="downloadPath">Speicherort</label>
<label id="storageLabel">Speicherort</label>
<div class="form-row">
<input type="text" id="downloadPath" readonly>
<button type="button" class="btn-secondary" onclick="selectFolder()">Ordner</button>
<button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
<button class="btn-secondary" onclick="selectFolder()">Ordner</button>
<button class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
</div>
</div>
<div class="form-group">
<label id="modeLabel" for="downloadMode">Download-Modus</label>
<label id="modeLabel">Download-Modus</label>
<select id="downloadMode">
<option value="full" id="modeFullText">Ganzes VOD</option>
<option value="parts" id="modePartsText">In Teile splitten</option>
</select>
</div>
<div class="form-group">
<label id="partMinutesLabel" for="partMinutes">Teil-Lange (Minuten)</label>
<label id="partMinutesLabel">Teil-Lange (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480">
</div>
<div class="form-group">
<label id="parallelDownloadsLabel" for="parallelDownloads">Parallele Downloads</label>
<label id="parallelDownloadsLabel">Parallele Downloads</label>
<select id="parallelDownloads">
<option value="1" id="parallelDownloads1">1 (Standard)</option>
<option value="2" id="parallelDownloads2">2 (Parallel)</option>
</select>
</div>
<div class="form-group">
<label id="streamlinkQualityLabel" for="streamlinkQuality">Stream-Qualitaet</label>
<label id="streamlinkQualityLabel">Stream-Qualitaet</label>
<select id="streamlinkQuality">
<option value="best" id="streamlinkQualityBest">Best (Standard)</option>
<option value="source" id="streamlinkQualitySource">Source (Original)</option>
@ -572,7 +592,7 @@
</select>
</div>
<div class="form-group">
<label id="performanceModeLabel" for="performanceMode">Performance-Profil</label>
<label id="performanceModeLabel">Performance-Profil</label>
<select id="performanceMode">
<option value="stability" id="performanceModeStability">Max Stabilitat</option>
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
@ -580,62 +600,62 @@
</select>
</div>
<div class="form-group">
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="smartSchedulerToggle" checked>
<span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span>
</label>
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="duplicatePreventionToggle" checked>
<span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span>
</label>
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="persistQueueToggle" checked>
<span id="persistQueueLabel">Queue zwischen App-Starts speichern</span>
</label>
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="autoResumeQueueToggle">
<span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span>
</label>
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="notifyEachCompletionToggle">
<span id="notifyEachCompletionLabel">Benachrichtigung bei jedem fertigen Download</span>
</label>
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="streamlinkDisableAdsToggle" checked>
<span id="streamlinkDisableAdsLabel">Twitch-Ads beim Download ueberspringen</span>
</label>
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="downloadChatReplayToggle">
<span id="downloadChatReplayLabel">Chat-Replay parallel zum VOD speichern (.chat.json)</span>
</label>
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="captureLiveChatToggle">
<span id="captureLiveChatLabel">Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)</span>
</label>
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="logStreamEventsToggle" checked>
<span id="logStreamEventsLabel">Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)</span>
</label>
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="autoResumeLiveRecordingToggle" checked>
<span id="autoResumeLiveRecordingLabel">Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)</span>
</label>
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="autoMergeResumedPartsToggle">
<span id="autoMergeResumedPartsLabel">Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat)</span>
</label>
<label class="toggle-row indented">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px; margin-left: 22px;">
<input type="checkbox" id="deletePartsAfterMergeToggle">
<span id="deletePartsAfterMergeLabel">Einzelne Parts nach erfolgreichem Merge loeschen</span>
</label>
</div>
<div class="form-group">
<label id="metadataCacheMinutesLabel" for="metadataCacheMinutes">Metadata-Cache (Minuten)</label>
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
<input type="number" id="metadataCacheMinutes" value="10" min="1" max="120">
</div>
<div class="form-group">
<div class="form-row" style="align-items:center; margin-bottom: 4px;">
<label id="filenameTemplatesTitle">Dateinamen-Templates</label>
<label id="filenameTemplatesTitle" style="margin: 0;">Dateinamen-Templates</label>
<button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
</div>
<div class="form-row" style="gap: 8px; margin: 8px 0 6px;">
@ -643,45 +663,45 @@
<button class="btn-secondary" id="templatePresetArchive" type="button" onclick="applyTemplatePreset('archive')">Preset: Archive</button>
<button class="btn-secondary" id="templatePresetClipper" type="button" onclick="applyTemplatePreset('clipper')">Preset: Clipper</button>
</div>
<div class="filename-template-grid">
<label id="vodTemplateLabel" for="vodFilenameTemplate">VOD Template</label>
<input type="text" id="vodFilenameTemplate" class="input-monospace" placeholder="{title}.mp4" oninput="validateFilenameTemplates()">
<div style="display: grid; gap: 8px; margin-top: 8px;">
<label id="vodTemplateLabel" style="font-size: 13px; color: var(--text-secondary);">VOD Template</label>
<input type="text" id="vodFilenameTemplate" placeholder="{title}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
<label id="partsTemplateLabel" for="partsFilenameTemplate">VOD Part Template</label>
<input type="text" id="partsFilenameTemplate" class="input-monospace" placeholder="{date}_Part{part_padded}.mp4" oninput="validateFilenameTemplates()">
<label id="partsTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">VOD Part Template</label>
<input type="text" id="partsFilenameTemplate" placeholder="{date}_Part{part_padded}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
<label id="defaultClipTemplateLabel" for="defaultClipFilenameTemplate">Clip Template</label>
<input type="text" id="defaultClipFilenameTemplate" class="input-monospace" placeholder="{date}_{part}.mp4" oninput="validateFilenameTemplates()">
<label id="defaultClipTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">Clip Template</label>
<input type="text" id="defaultClipFilenameTemplate" placeholder="{date}_{part}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
</div>
<div id="filenameTemplateHint" class="form-note" style="margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<div id="filenameTemplateLint" class="template-lint ok">Template-Check: OK</div>
<div id="filenameTemplateHint" style="color: #888; font-size: 12px; margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<div id="filenameTemplateLint" style="font-size: 12px; margin-top: 6px; color: #8bc34a;">Template-Check: OK</div>
</div>
</div>
<div class="settings-card">
<h3 id="updateTitle">Updates</h3>
<p id="versionInfo" class="card-intro">Version: v4.1.13</p>
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.13</p>
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
<div class="settings-card">
<div class="form-row section-header">
<h3 id="preflightTitle">System-Check</h3>
<div class="form-row" style="align-items:center; justify-content:space-between; margin-bottom: 10px;">
<h3 id="preflightTitle" style="margin: 0;">System-Check</h3>
<span class="health-badge unknown" id="healthBadge">System: Unbekannt</span>
</div>
<div class="form-row" style="margin-bottom: 10px;">
<button type="button" class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button>
<button type="button" class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
<button class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button>
<button class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
</div>
<pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre>
</div>
<div class="settings-card">
<h3 id="debugLogTitle">Live Debug-Log</h3>
<div class="form-row aligned">
<button type="button" class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button>
<button type="button" class="btn-secondary" id="btnOpenDebugLogFile" onclick="openDebugLogFile()">Log-Datei oeffnen</button>
<label class="inline-toggle">
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
<button class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button>
<button class="btn-secondary" id="btnOpenDebugLogFile" onclick="openDebugLogFile()">Log-Datei oeffnen</button>
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
<input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)">
<span id="autoRefreshText">Auto-Refresh</span>
</label>
@ -690,35 +710,35 @@
</div>
<div class="settings-card">
<div class="form-row section-header">
<h3 id="storageCardTitle">Storage</h3>
<button type="button" class="btn-secondary" id="btnRefreshStorage" onclick="refreshStorageStats()">Aktualisieren</button>
<div class="form-row" style="align-items:center; justify-content:space-between; margin-bottom: 10px;">
<h3 id="storageCardTitle" style="margin:0;">Storage</h3>
<button class="btn-secondary" id="btnRefreshStorage" onclick="refreshStorageStats()">Aktualisieren</button>
</div>
<p id="storageCardIntro" class="card-intro">Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.</p>
<div id="storageSummary" class="form-sublabel" style="margin-bottom:8px;" role="status" aria-live="polite"></div>
<p id="storageCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.</p>
<div id="storageSummary" style="color: var(--text-secondary); font-size:12px; margin-bottom:8px;"></div>
<div id="storageList"></div>
<hr>
<h4 id="cleanupTitle">Auto-Cleanup</h4>
<p id="cleanupIntro" class="card-intro">Aufnahmen aelter als X Tage automatisch archivieren oder loeschen. Schiebt Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) mit der Aufnahme.</p>
<label class="toggle-row" style="margin-bottom: 8px;">
<hr style="border:none; border-top:1px solid var(--border-soft); margin:16px 0;">
<h4 id="cleanupTitle" style="margin:0 0 8px 0; font-size:14px;">Auto-Cleanup</h4>
<p id="cleanupIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Aufnahmen aelter als X Tage automatisch archivieren oder loeschen. Schiebt Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) mit der Aufnahme.</p>
<label style="display:flex; align-items:center; gap:8px; margin-bottom: 8px;">
<input type="checkbox" id="autoCleanupEnabledToggle">
<span id="autoCleanupEnabledLabel">Auto-Cleanup aktivieren</span>
</label>
<div class="form-row" style="gap:12px; flex-wrap:wrap; margin-bottom: 8px;">
<label class="form-stack size-sm">
<span id="autoCleanupDaysLabel" class="form-sublabel">Tage-Schwelle</span>
<label style="display:flex; flex-direction:column; gap:4px; flex:1; min-width:120px;">
<span id="autoCleanupDaysLabel" style="font-size:12px; color:var(--text-secondary);">Tage-Schwelle</span>
<input type="number" id="autoCleanupDays" min="1" max="3650" value="30">
</label>
<label class="form-stack size-md">
<span id="autoCleanupTargetLabel" class="form-sublabel">Bereich</span>
<label style="display:flex; flex-direction:column; gap:4px; flex:1; min-width:160px;">
<span id="autoCleanupTargetLabel" style="font-size:12px; color:var(--text-secondary);">Bereich</span>
<select id="autoCleanupTarget">
<option value="live_only" id="autoCleanupTargetLive">Nur Live-Aufnahmen</option>
<option value="all" id="autoCleanupTargetAll">Alle Aufnahmen</option>
</select>
</label>
<label class="form-stack size-md">
<span id="autoCleanupActionLabel" class="form-sublabel">Aktion</span>
<label style="display:flex; flex-direction:column; gap:4px; flex:1; min-width:160px;">
<span id="autoCleanupActionLabel" style="font-size:12px; color:var(--text-secondary);">Aktion</span>
<select id="autoCleanupAction">
<option value="archive" id="autoCleanupActionArchive">In Archiv verschieben</option>
<option value="delete" id="autoCleanupActionDelete">Loeschen</option>
@ -726,33 +746,33 @@
</label>
</div>
<div class="form-row" style="margin-bottom: 8px; gap: 8px;">
<button type="button" class="btn-secondary" id="btnCleanupDryRun" onclick="runCleanupDryRun()">Vorschau</button>
<button type="button" class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausfuehren</button>
<button class="btn-secondary" id="btnCleanupDryRun" onclick="runCleanupDryRun()">Vorschau</button>
<button class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausfuehren</button>
</div>
<div id="cleanupReport" class="form-note" role="status" aria-live="polite"></div>
<div id="cleanupReport" style="color: var(--text-secondary); font-size:12px;"></div>
</div>
<div class="settings-card">
<h3 id="discordCardTitle">Discord-Webhook</h3>
<p id="discordCardIntro" class="card-intro">Sende Benachrichtigungen an einen Discord-Channel via Webhook — nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.</p>
<p id="discordCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Sende Benachrichtigungen an einen Discord-Channel via Webhook — nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.</p>
<div class="form-group">
<label id="discordWebhookUrlLabel" for="discordWebhookUrl">Webhook-URL</label>
<label id="discordWebhookUrlLabel">Webhook-URL</label>
<input type="text" id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/...">
</div>
<div class="form-group">
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="discordNotifyLiveStartToggle">
<span id="discordNotifyLiveStartLabel">Bei Live-Aufnahme-Start benachrichtigen</span>
</label>
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="discordNotifyLiveEndToggle">
<span id="discordNotifyLiveEndLabel">Bei Live-Aufnahme-Ende benachrichtigen</span>
</label>
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="discordNotifyVodCompleteToggle">
<span id="discordNotifyVodCompleteLabel">Bei abgeschlossenem VOD-Download benachrichtigen</span>
</label>
<label class="toggle-row">
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="discordNotifyVodAutoQueuedToggle">
<span id="discordNotifyVodAutoQueuedLabel">Bei automatisch eingereihten VODs benachrichtigen</span>
</label>
@ -761,36 +781,36 @@
<div class="settings-card">
<h3 id="autoVodCardTitle">Auto-VOD-Download</h3>
<p id="autoVodCardIntro" class="card-intro">Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.</p>
<div class="form-row aligned">
<label id="autoVodPollMinutesLabel" class="form-sublabel" for="autoVodPollMinutes">Poll-Intervall (Minuten)</label>
<input type="number" id="autoVodPollMinutes" min="5" max="360" value="15" class="input-narrow">
<label id="autoVodMaxAgeHoursLabel" class="form-sublabel" for="autoVodMaxAgeHours" style="margin-left:12px;">Max. Alter (Stunden)</label>
<input type="number" id="autoVodMaxAgeHours" min="1" max="720" value="24" class="input-narrow">
<p id="autoVodCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.</p>
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
<span id="autoVodPollMinutesLabel" style="font-size:12px; color:var(--text-secondary);">Poll-Intervall (Minuten)</span>
<input type="number" id="autoVodPollMinutes" min="5" max="360" value="15" style="width:90px;">
<span id="autoVodMaxAgeHoursLabel" style="font-size:12px; color:var(--text-secondary); margin-left:12px;">Max. Alter (Stunden)</span>
<input type="number" id="autoVodMaxAgeHours" min="1" max="720" value="24" style="width:90px;">
</div>
<div class="form-row" style="align-items: center; gap: 12px; flex-wrap: wrap;">
<button type="button" class="btn-secondary" id="btnAutoVodScanNow" onclick="triggerManualAutoVodScan()">Jetzt scannen</button>
<button type="button" class="btn-secondary" id="btnAutoRecordScanNow" onclick="triggerManualAutoRecordScan()">Live-Status pruefen</button>
<span id="autoVodStatusLine" class="form-sublabel"></span>
<button class="btn-secondary" id="btnAutoVodScanNow" onclick="triggerManualAutoVodScan()">Jetzt scannen</button>
<button class="btn-secondary" id="btnAutoRecordScanNow" onclick="triggerManualAutoRecordScan()">Live-Status pruefen</button>
<span id="autoVodStatusLine" style="font-size:12px; color: var(--text-secondary);"></span>
</div>
</div>
<div class="settings-card">
<h3 id="backupCardTitle">Sicherung &amp; Wartung</h3>
<p id="backupCardIntro" class="card-intro">Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.</p>
<p id="backupCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.</p>
<div class="form-row" style="margin-bottom: 10px; flex-wrap: wrap;">
<button type="button" class="btn-secondary" id="btnExportConfig" onclick="exportConfigToFile()">Konfiguration exportieren</button>
<button type="button" class="btn-secondary" id="btnImportConfig" onclick="importConfigFromFile()">Konfiguration importieren</button>
<button type="button" class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zuruecksetzen</button>
<button class="btn-secondary" id="btnExportConfig" onclick="exportConfigToFile()">Konfiguration exportieren</button>
<button class="btn-secondary" id="btnImportConfig" onclick="importConfigFromFile()">Konfiguration importieren</button>
<button class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zuruecksetzen</button>
</div>
</div>
<div class="settings-card">
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
<div class="form-row aligned">
<button type="button" class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
<button type="button" class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button>
<label class="inline-toggle">
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
<button class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
<button class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button>
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
<input type="checkbox" id="runtimeMetricsAutoRefresh" onchange="toggleRuntimeMetricsAutoRefresh(this.checked)">
<span id="runtimeMetricsAutoRefreshText">Auto-Refresh</span>
</label>
@ -802,11 +822,11 @@
<div class="status-bar">
<div class="status-indicator">
<div class="status-dot" id="statusDot" aria-hidden="true"></div>
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span>
</div>
<span id="statusBarQueueSummary" class="status-bar-queue-summary"></span>
<span id="versionText" class="status-bar-version"></span>
<span id="statusBarQueueSummary" style="color: var(--text-secondary); font-size:12px; margin-left:auto; padding-right:12px;"></span>
<span id="versionText">v4.1.13</span>
</div>
</main>
</div>
@ -822,7 +842,6 @@
<script src="../dist/renderer-stats.js"></script>
<script src="../dist/renderer-archive.js"></script>
<script src="../dist/renderer-profile.js"></script>
<script src="../dist/renderer-vod-hover.js"></script>
<script src="../dist/renderer.js"></script>
</body>
</html>

View File

@ -2303,7 +2303,6 @@ interface StreamerProfile {
login: string;
displayName: string;
avatarUrl: string;
bannerUrl: string;
description: string;
broadcasterType: '' | 'partner' | 'affiliate';
followerCount: number | null;
@ -2312,8 +2311,6 @@ interface StreamerProfile {
isLive: boolean;
currentTitle: string | null;
currentGame: string | null;
currentStreamPreviewUrl: string;
currentStreamViewers: number | null;
twitchUrl: string;
fetchedAt: number;
}
@ -2396,40 +2393,25 @@ interface PublicProfileQueryResult {
displayName: string;
description: string | null;
profileImageURL: string | null;
bannerImageURL: string | null;
roles?: { isPartner: boolean; isAffiliate: boolean } | null;
followers?: { totalCount: number } | null;
stream?: {
id: string;
type: string;
title: string | null;
viewersCount: number | null;
previewImageURL: string | null;
game: { name: string } | null;
} | null;
} | null;
}
interface PublicStreamerProfileResult {
async function fetchPublicStreamerProfile(login: string): Promise<{
displayName: string;
avatarUrl: string;
bannerUrl: string;
description: string;
broadcasterType: '' | 'partner' | 'affiliate';
followerCount: number | null;
stream: PublicStreamInfo | null;
}
interface PublicStreamInfo {
previewUrl: string;
viewers: number | null;
title: string | null;
game: string | null;
}
async function fetchPublicStreamerProfile(login: string): Promise<PublicStreamerProfileResult | null> {
// Same query also pulls bannerImageURL and the current stream's
// preview + viewer count when live — saves a separate roundtrip.
} | null> {
// The public (unauthenticated) GQL schema does NOT expose
// `broadcasterType` directly — querying it returns an errors[] response
// which the upstream helper treats as a complete failure (null data),
// which in turn left the avatar empty and the user's whole profile
// fell through to the fallback letter tile. Use `roles{isPartner
// isAffiliate}` instead, which the public schema does expose, and
// derive broadcasterType locally.
const data = await fetchPublicTwitchGql<PublicProfileQueryResult>(
`query($login: String!) {
user(login: $login) {
@ -2438,17 +2420,8 @@ async function fetchPublicStreamerProfile(login: string): Promise<PublicStreamer
displayName
description
profileImageURL(width: 150)
bannerImageURL
roles { isPartner isAffiliate }
followers { totalCount }
stream {
id
type
title
viewersCount
previewImageURL(width: 640, height: 360)
game { name }
}
}
}`,
{ login }
@ -2458,24 +2431,24 @@ async function fetchPublicStreamerProfile(login: string): Promise<PublicStreamer
const broadcasterType: '' | 'partner' | 'affiliate' = roles?.isPartner
? 'partner'
: (roles?.isAffiliate ? 'affiliate' : '');
const s = data.user.stream;
const stream = (s && s.type === 'live') ? {
previewUrl: s.previewImageURL || '',
viewers: typeof s.viewersCount === 'number' ? s.viewersCount : null,
title: s.title || null,
game: s.game?.name || null
} : null;
return {
displayName: data.user.displayName || login,
avatarUrl: data.user.profileImageURL || '',
bannerUrl: data.user.bannerImageURL || '',
description: data.user.description || '',
broadcasterType,
followerCount: typeof data.user.followers?.totalCount === 'number' ? data.user.followers.totalCount : null,
stream
followerCount: typeof data.user.followers?.totalCount === 'number' ? data.user.followers.totalCount : null
};
}
async function fetchOnlyFollowerCount(login: string): Promise<number | null> {
const data = await fetchPublicTwitchGql<PublicProfileQueryResult>(
`query($login: String!) { user(login: $login) { followers { totalCount } } }`,
{ login }
);
const cnt = data?.user?.followers?.totalCount;
return typeof cnt === 'number' ? cnt : null;
}
async function getStreamerProfile(login: string, forceRefresh = false): Promise<StreamerProfile | null> {
const normalized = normalizeLogin(login);
if (!normalized) return null;
@ -2491,43 +2464,37 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
return await withInFlightDedup(inFlightProfileRequests, normalized, async () => {
runtimeMetrics.cacheMisses += 1;
// Public GQL is now the SOURCE for everything except some of the
// core text fields when Helix is authenticated — because public
// GQL is the only route that gives us the banner image + current
// stream preview in one shot, and skipping it would mean two
// extra roundtrips. Helix takes precedence for displayName /
// description (those fields are sometimes richer there).
// Prefer Helix for the core fields when we have credentials (richer
// bio, faster, less likely to rate-limit), then fill in followers
// via public GQL since Helix /channels/followers needs a moderator
// token we don't have. Fall back fully to public GQL when not
// authenticated.
let displayName = normalized;
let avatarUrl = '';
let bannerUrl = '';
let description = '';
let broadcasterType: '' | 'partner' | 'affiliate' = '';
let streamFromPublic: PublicStreamInfo | null = null;
let followerCountFromPublic: number | null = null;
const helixUser = await fetchHelixUserInfo(normalized);
if (helixUser) {
displayName = helixUser.display_name || normalized;
avatarUrl = helixUser.profile_image_url || '';
description = helixUser.description || '';
const bt = (helixUser.broadcaster_type || '').toLowerCase();
broadcasterType = (bt === 'partner' || bt === 'affiliate') ? bt : '';
} else {
const publicProfile = await fetchPublicStreamerProfile(normalized);
if (publicProfile) {
displayName = publicProfile.displayName;
avatarUrl = publicProfile.avatarUrl;
bannerUrl = publicProfile.bannerUrl;
description = publicProfile.description;
broadcasterType = publicProfile.broadcasterType;
followerCountFromPublic = publicProfile.followerCount;
streamFromPublic = publicProfile.stream;
}
}
const helixUser = await fetchHelixUserInfo(normalized);
if (helixUser) {
displayName = helixUser.display_name || displayName;
if (helixUser.profile_image_url) avatarUrl = helixUser.profile_image_url;
if (helixUser.description) description = helixUser.description;
const bt = (helixUser.broadcaster_type || '').toLowerCase();
if (bt === 'partner' || bt === 'affiliate') broadcasterType = bt;
}
// followerCountFromPublic comes from the public profile query
// above — no separate follower roundtrip needed.
const followerCount = followerCountFromPublic;
// Follower count always from public GQL — even when Helix
// succeeded for the rest. One extra ~50ms call, but it's the
// only reliable way without scoped tokens.
const followerCount = await fetchOnlyFollowerCount(normalized);
// Derive vod count + last stream from the already-cached VOD list
// when we have an id. No extra network hit.
@ -2549,19 +2516,6 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
let isLive = false;
let currentTitle: string | null = null;
let currentGame: string | null = null;
let currentStreamPreviewRemoteUrl = '';
let currentStreamViewers: number | null = null;
if (streamFromPublic) {
// Public-GQL already told us this user is live and gave us a
// preview frame URL + viewer count + game/title. Don't double-
// call getLiveStreamInfo when we already have a fresh answer.
isLive = true;
currentTitle = streamFromPublic.title;
currentGame = streamFromPublic.game;
currentStreamPreviewRemoteUrl = streamFromPublic.previewUrl;
currentStreamViewers = streamFromPublic.viewers;
} else {
try {
const live = await getLiveStreamInfo(normalized);
if (live) {
@ -2570,28 +2524,16 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
currentGame = live.gameName || null;
}
} catch (_) { /* best-effort */ }
}
// Embed the avatar AND banner bytes as data URLs in parallel.
// Renderer can't reliably fetch Twitch CDN images directly from
// an Electron renderer process, plus the data URL approach skips
// any CSP/referer/CORS quirks. Live preview also goes through
// this path — adds a cache-busting query string so a returning
// user gets a fresh frame each time the profile refreshes.
const livePreviewUrlForFetch = currentStreamPreviewRemoteUrl
? `${currentStreamPreviewRemoteUrl}${currentStreamPreviewRemoteUrl.includes('?') ? '&' : '?'}_=${Date.now()}`
: '';
const [avatarDataUrl, bannerDataUrl, livePreviewDataUrl] = await Promise.all([
avatarUrl ? fetchAvatarAsDataUrl(avatarUrl) : Promise.resolve(''),
bannerUrl ? fetchAvatarAsDataUrl(bannerUrl) : Promise.resolve(''),
livePreviewUrlForFetch ? fetchAvatarAsDataUrl(livePreviewUrlForFetch) : Promise.resolve('')
]);
// Embed the avatar bytes as a data URL so the renderer doesn't
// have to make its own HTTPS request. Bypasses any CSP, CORS,
// referrer-policy, or Electron renderer image-loading quirks.
const avatarDataUrl = avatarUrl ? await fetchAvatarAsDataUrl(avatarUrl) : '';
const profile: StreamerProfile = {
login: normalized,
displayName,
avatarUrl: avatarDataUrl || avatarUrl,
bannerUrl: bannerDataUrl || bannerUrl,
description,
broadcasterType,
followerCount,
@ -2600,8 +2542,6 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
isLive,
currentTitle,
currentGame,
currentStreamPreviewUrl: livePreviewDataUrl || currentStreamPreviewRemoteUrl,
currentStreamViewers,
twitchUrl: `https://www.twitch.tv/${normalized}`,
fetchedAt: Date.now()
};
@ -2611,122 +2551,6 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
});
}
// ==========================================
// VOD STORYBOARD — animated hover preview
// ==========================================
// Twitch publishes a "storyboard" JSON per VOD with sprite-sheet URLs
// containing N preview thumbnails covering the full length of the
// recording. We pull the JSON via public GQL (seekPreviewsURL), then
// hand the renderer the first high-quality sprite as a data URL plus
// the grid metadata. The renderer animates background-position across
// 4 cells to produce a scrub-preview effect on hover, twitch.tv-style.
interface VodStoryboard {
vodId: string;
spriteDataUrl: string;
cols: number;
rows: number;
cellWidth: number;
cellHeight: number;
framesInSprite: number;
}
const MAX_VOD_STORYBOARD_CACHE_ENTRIES = 1024;
const vodStoryboardCache = new Map<string, CacheEntry<VodStoryboard | null>>();
const inFlightStoryboardRequests = new Map<string, Promise<VodStoryboard | null>>();
interface StoryboardManifestEntry {
count: number;
width: number;
height: number;
cols: number;
rows: number;
images: string[];
quality: string;
interval: number;
}
async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
if (!vodId) return null;
const cached = getCachedValue(vodStoryboardCache, vodId);
if (cached !== undefined) {
runtimeMetrics.cacheHits += 1;
return cached;
}
return await withInFlightDedup(inFlightStoryboardRequests, vodId, async () => {
runtimeMetrics.cacheMisses += 1;
// Step 1: GQL gives us the seekPreviewsURL pointing at a JSON
// manifest. The manifest lists sprite images at multiple quality
// levels; we pick the high-quality first sprite (covers the
// beginning of the VOD with the most detail).
const data = await fetchPublicTwitchGql<{ video: { seekPreviewsURL: string | null } | null }>(
`query($id: ID!) { video(id: $id) { seekPreviewsURL } }`,
{ id: vodId }
);
const manifestUrl = data?.video?.seekPreviewsURL;
if (!manifestUrl) {
// Cache the negative result so a VOD without a storyboard
// (private/unlisted/expired) doesn't get re-queried on every
// subsequent hover.
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
return null;
}
let manifest: StoryboardManifestEntry[] | null = null;
try {
const manifestResp = await axios.get<StoryboardManifestEntry[]>(manifestUrl, {
timeout: 6000,
responseType: 'json',
headers: { 'User-Agent': 'TwitchVODManager/1.0' }
});
manifest = manifestResp.data;
} catch (e) {
appendDebugLog('storyboard-manifest-failed', { vodId, error: String(e) });
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
return null;
}
if (!Array.isArray(manifest) || manifest.length === 0) {
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
return null;
}
// Prefer the "high" quality entry — Twitch ships both "low" and
// "high" alongside each other. Falls back to whichever is present.
const entry = manifest.find((m) => m.quality === 'high') || manifest[0];
if (!entry?.images?.length) {
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
return null;
}
// The manifest URL points at e.g. .../storyboards/2767872722-info.json
// and sprite filenames are relative (e.g. "2767872722-high-0.jpg").
// Strip the JSON filename to get the base, then append the sprite.
const baseUrl = manifestUrl.replace(/\/[^/]+$/, '/');
const firstSpriteUrl = baseUrl + entry.images[0];
const spriteDataUrl = await fetchAvatarAsDataUrl(firstSpriteUrl);
if (!spriteDataUrl) {
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
return null;
}
const storyboard: VodStoryboard = {
vodId,
spriteDataUrl,
cols: entry.cols,
rows: entry.rows,
cellWidth: entry.width,
cellHeight: entry.height,
framesInSprite: entry.cols * entry.rows
};
setCachedValue(vodStoryboardCache, vodId, storyboard, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
return storyboard;
});
}
async function getClipInfo(clipId: string): Promise<any | null> {
const cachedClip = getCachedValue(clipInfoCache, clipId);
if (cachedClip !== undefined) {
@ -3293,8 +3117,7 @@ function downloadVODPart(
args.push('--hls-duration', endTime);
}
// download-part-start in the debug log captures the same info
// for support / forensics — no need to flood stdout too.
console.log('Starting download:', streamlinkCmd.command, args);
appendDebugLog('download-part-start', { itemId, command: streamlinkCmd.command, filename, args });
const proc = spawn(streamlinkCmd.command, args, { windowsHide: true });
@ -3361,11 +3184,7 @@ function downloadVODPart(
proc.stdout?.on('data', (data: Buffer) => {
const line = data.toString();
// No per-line stdout — streamlink emits 10-100 lines/sec during
// an active download, which floods the terminal in dev and the
// electron-launched console in prod. Progress + tag parsing
// below extracts everything we need; failures get logged via
// appendDebugLog from the consumer side.
console.log('Streamlink:', line);
// Parse progress
const match = line.match(/(\d+\.\d+)%/);
@ -3699,117 +3518,6 @@ async function runAutoVodPoll(): Promise<number> {
return queuedCount;
}
// ==========================================
// LIVE STATUS BATCH POLLER — for the sidebar live indicators
// ==========================================
// Background poller that asks "which of these streamers are live right
// now?" for every streamer in the user's list, in a single GQL roundtrip
// (per chunk of 50). Results are stamped into liveStatusByLogin and
// pushed to the renderer so the sidebar gets a red pulsing dot next to
// anyone currently broadcasting. Independent from the auto-record
// poller — that one only watches a small subset and needs title/game,
// this one just needs the boolean and covers everyone.
const liveStatusByLogin = new Map<string, boolean>();
let liveStatusPollTimer: NodeJS.Timeout | null = null;
let liveStatusPollInFlight = false;
const LIVE_STATUS_POLL_INTERVAL_MS = 60_000;
const LIVE_STATUS_BATCH_CHUNK_SIZE = 50;
async function fetchLiveStatusBatch(logins: string[]): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>();
if (logins.length === 0) return result;
for (let i = 0; i < logins.length; i += LIVE_STATUS_BATCH_CHUNK_SIZE) {
const chunk = logins.slice(i, i + LIVE_STATUS_BATCH_CHUNK_SIZE);
const vars: Record<string, string> = {};
const varDecls: string[] = [];
const aliases: string[] = [];
chunk.forEach((login, idx) => {
const varName = `l${idx}`;
vars[varName] = login;
varDecls.push(`$${varName}:String!`);
aliases.push(`u${idx}:user(login:$${varName}){login stream{type}}`);
});
const query = `query(${varDecls.join(',')}){${aliases.join(' ')}}`;
try {
const data = await fetchPublicTwitchGql<Record<string, { login: string; stream: { type: string } | null } | null>>(
query, vars
);
if (!data) continue;
for (const key of Object.keys(data)) {
const user = data[key];
if (!user || !user.login) continue;
result.set(normalizeLogin(user.login), user.stream?.type === 'live');
}
} catch (e) {
appendDebugLog('live-status-batch-failed', { chunkStart: i, error: String(e) });
}
}
return result;
}
async function runLiveStatusBatchPoll(): Promise<void> {
if (liveStatusPollInFlight) return;
liveStatusPollInFlight = true;
try {
const logins = ((config.streamers as string[]) || [])
.map((s) => normalizeLogin(s))
.filter((s): s is string => Boolean(s));
const changes: Array<{ login: string; isLive: boolean }> = [];
const watchedSet = new Set(logins);
// Always run the eviction pass FIRST — entries left over from a
// streamer that's no longer in the watch list must go regardless
// of whether we're about to fetch fresh data. Previously this
// ran inside the fetch branch only, so removing the last
// streamer left ghost entries in liveStatusByLogin until the
// next add.
for (const oldLogin of Array.from(liveStatusByLogin.keys())) {
if (!watchedSet.has(oldLogin)) {
liveStatusByLogin.delete(oldLogin);
changes.push({ login: oldLogin, isLive: false });
}
}
if (logins.length > 0) {
const fresh = await fetchLiveStatusBatch(logins);
for (const [login, isLive] of fresh.entries()) {
const prev = liveStatusByLogin.get(login);
if (prev !== isLive) changes.push({ login, isLive });
liveStatusByLogin.set(login, isLive);
}
}
if (mainWindow && changes.length > 0) {
// Renderer only consumes `changes` — initial state comes via
// the get-live-status-snapshot IPC at boot. Don't ship the
// full map on every tick (was ~1.5KB JSON per 60s with zero
// consumer-side use). Also skip the broadcast entirely when
// nothing actually changed.
mainWindow.webContents.send('live-status-batch-update', { changes });
}
} catch (e) {
appendDebugLog('live-status-poll-failed', String(e));
} finally {
liveStatusPollInFlight = false;
}
}
function stopLiveStatusPoller(): void {
if (liveStatusPollTimer) {
clearInterval(liveStatusPollTimer);
liveStatusPollTimer = null;
}
}
function restartLiveStatusPoller(): void {
stopLiveStatusPoller();
liveStatusPollTimer = setInterval(() => { void runLiveStatusBatchPoll(); }, LIVE_STATUS_POLL_INTERVAL_MS);
liveStatusPollTimer.unref?.();
setTimeout(() => { void runLiveStatusBatchPoll(); }, 1500);
}
// ==========================================
// CHAT REPLAY DOWNLOAD
// ==========================================
@ -6392,7 +6100,7 @@ function setupAutoUpdater() {
autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.on('checking-for-update', () => {
appendDebugLog('auto-updater-checking');
console.log('Checking for updates...');
mainWindow?.webContents.send('update-checking');
});
@ -6416,7 +6124,7 @@ function setupAutoUpdater() {
compareUpdateVersions(downloadedUpdateVersion, incomingVersion) === 0
);
appendDebugLog('auto-updater-update-available', { version: displayVersion });
console.log('Update available:', displayVersion);
if (!hasAlreadyDownloadedThisVersion) {
autoUpdateReadyToInstall = false;
}
@ -6440,14 +6148,12 @@ function setupAutoUpdater() {
});
autoUpdater.on('update-not-available', () => {
appendDebugLog('auto-updater-update-not-available');
console.log('No updates available');
mainWindow?.webContents.send('update-not-available');
});
autoUpdater.on('download-progress', (progress) => {
// No per-tick stdout — the autoUpdater fires this ~10x/sec during
// an in-flight download. The renderer banner is the user-visible
// surface; appendDebugLog already captures phase transitions.
console.log(`Download progress: ${progress.percent.toFixed(1)}%`);
if (mainWindow) {
mainWindow.webContents.send('update-download-progress', {
percent: progress.percent,
@ -6460,7 +6166,7 @@ function setupAutoUpdater() {
autoUpdater.on('update-downloaded', (info) => {
const downloadedVersion = normalizeUpdateVersion(info.version) || info.version;
appendDebugLog('auto-updater-update-downloaded', { version: downloadedVersion });
console.log('Update downloaded:', downloadedVersion);
autoUpdateReadyToInstall = true;
autoUpdateDownloadInProgress = false;
downloadedUpdateVersion = downloadedVersion;
@ -6526,7 +6232,6 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
const previousAutoRecordSeconds = config.auto_record_poll_seconds;
const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
const previousAutoVodMinutes = config.auto_vod_download_poll_minutes;
const previousStreamerList = JSON.stringify(config.streamers || []);
config = normalizeConfigTemplates({ ...config, ...newConfig });
@ -6576,14 +6281,6 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
restartAutoVodPoller();
}
// Live-status batch poller — fire an immediate refresh when the
// streamer list itself changes (added/removed) so the sidebar dots
// update instantly instead of waiting for the next 60s tick.
const newStreamerList = JSON.stringify(config.streamers || []);
if (newStreamerList !== previousStreamerList) {
restartLiveStatusPoller();
}
// Restart cleanup timer when the toggle flips; harmless to call when
// unchanged because restartAutoCleanupTimer just resets the interval.
restartAutoCleanupTimer();
@ -6926,24 +6623,9 @@ ipcMain.handle('open-folder', (_, folderPath: string) => {
}
});
// Extensions that shell.openPath would happily execute via the system
// default. Calc.exe via XSS smuggling is the canonical example; this
// list blocks the obvious vectors. Media/text/image extensions are
// still fine — shell.openPath opens them in the OS's default viewer.
const OPEN_FILE_BLOCKED_EXTENSIONS = new Set([
'.exe', '.bat', '.cmd', '.com', '.ps1', '.vbs', '.vbe',
'.js', '.jse', '.wsf', '.wsh', '.scr', '.msi', '.msp',
'.lnk', '.cpl', '.reg', '.hta', '.jar', '.application'
]);
ipcMain.handle('open-file', async (_, filePath: string): Promise<boolean> => {
if (typeof filePath !== 'string' || !filePath) return false;
if (!fs.existsSync(filePath)) return false;
const ext = path.extname(filePath).toLowerCase();
if (OPEN_FILE_BLOCKED_EXTENSIONS.has(ext)) {
appendDebugLog('open-file-rejected-extension', { ext, path: filePath.slice(0, 200) });
return false;
}
const result = await shell.openPath(filePath);
// shell.openPath returns '' on success, an error string on failure.
return result === '';
@ -6997,20 +6679,7 @@ ipcMain.handle('install-update', () => {
});
ipcMain.handle('open-external', async (_, url: string) => {
// Only allow https / http URLs — never let the renderer push a
// file://, javascript:, or shell:-style URL through to the OS
// shell.openExternal handler. The renderer is contextIsolated +
// nodeIntegration: false, but an XSS through (e.g.) a streamer name
// smuggling a payload into a template would otherwise hand the
// attacker shell.openExternal which on Windows happily resolves
// file:///C:/Windows/System32/calc.exe.
if (typeof url !== 'string') return;
const trimmed = url.trim();
if (!/^https?:\/\//i.test(trimmed)) {
appendDebugLog('open-external-rejected', { url: trimmed.slice(0, 200) });
return;
}
await shell.openExternal(trimmed);
await shell.openExternal(url);
});
// Tracks active standalone clip downloads so cancel-download / window-all-closed
@ -7131,16 +6800,6 @@ ipcMain.handle('get-streamer-profile', async (_, login: string, forceRefresh?: b
return await getStreamerProfile(login, forceRefresh === true);
});
ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise<VodStoryboard | null> => {
return await getVodStoryboard(vodId);
});
ipcMain.handle('get-live-status-snapshot', (): Record<string, boolean> => {
const snap: Record<string, boolean> = {};
for (const [k, v] of liveStatusByLogin.entries()) snap[k] = v;
return snap;
});
ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => {
const normalized: ArchiveSearchFilter = {
query: typeof filter?.query === 'string' ? filter.query.trim() : '',
@ -7411,7 +7070,6 @@ app.whenReady().then(() => {
startDebugLogFlushTimer();
restartAutoRecordPoller();
restartAutoVodPoller();
restartLiveStatusPoller();
restartAutoCleanupTimer();
createWindow();
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
@ -7441,7 +7099,6 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
stopAutoUpdatePolling();
stopAutoRecordPoller();
stopAutoVodPoller();
stopLiveStatusPoller();
stopAutoCleanupTimer();
// Kill all active children: queue downloads, standalone clip downloads,

View File

@ -92,11 +92,6 @@ contextBridge.exposeInMainWorld('api', {
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh),
getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId),
getLiveStatusSnapshot: () => ipcRenderer.invoke('get-live-status-snapshot'),
onLiveStatusBatchUpdate: (callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void) => {
ipcRenderer.on('live-status-batch-update', (_, info) => callback(info));
},
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),

View File

@ -2,6 +2,30 @@ let archiveStreamerSelectPopulated = false;
let archiveSearchInFlight = false;
let archiveSearchDebounceTimer: number | null = null;
function applyArchiveHtml(el: HTMLElement, html: string): void {
const key = 'inner' + 'HTML';
(el as unknown as Record<string, string>)[key] = html;
}
function escapeArchiveHtml(s: string | number | null | undefined): string {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatBytesForArchive(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`;
}
function populateArchiveStreamerSelect(): void {
if (archiveStreamerSelectPopulated) return;
const select = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null;
@ -9,8 +33,8 @@ function populateArchiveStreamerSelect(): void {
const streamers = (config.streamers as string[] | undefined) || [];
const sorted = [...streamers].sort((a, b) => a.localeCompare(b));
const opts = sorted.map((s) => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`).join('');
applyHtml(select, `<option value="">${escapeHtml(UI_TEXT.static.archiveAllStreamers || 'Alle Streamer')}</option>${opts}`);
const opts = sorted.map((s) => `<option value="${escapeArchiveHtml(s)}">${escapeArchiveHtml(s)}</option>`).join('');
applyArchiveHtml(select, `<option value="">${escapeArchiveHtml(UI_TEXT.static.archiveAllStreamers || 'Alle Streamer')}</option>${opts}`);
archiveStreamerSelectPopulated = true;
}
@ -57,7 +81,7 @@ async function performArchiveSearch(): Promise<void> {
renderArchiveSearchResults(result);
} catch (e) {
if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`;
applyHtml(resultsEl, '');
applyArchiveHtml(resultsEl, '');
} finally {
archiveSearchInFlight = false;
if (btn) btn.disabled = false;
@ -71,7 +95,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
if (!result.rootExists) {
if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveNoRoot;
applyHtml(resultsEl, '');
applyArchiveHtml(resultsEl, '');
return;
}
@ -86,34 +110,36 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
}
if (result.hits.length === 0) {
applyHtml(resultsEl, `<div class="archive-no-matches">${escapeHtml(UI_TEXT.static.archiveNoMatches || 'Keine Treffer.')}</div>`);
applyArchiveHtml(resultsEl, `<div style="color: var(--text-secondary); padding: 12px;">${escapeArchiveHtml(UI_TEXT.static.archiveNoMatches || 'Keine Treffer.')}</div>`);
return;
}
const rows = result.hits.map((hit) => {
const date = new Date(hit.mtimeMs).toLocaleString();
const typeBadge = `<span class="archive-type-badge ${hit.type === 'live' ? 'live' : 'vod'}">${hit.type === 'live' ? 'LIVE' : 'VOD'}</span>`;
const typeBadge = hit.type === 'live'
? `<span style="background: rgba(255,68,68,0.18); color: #ff4444; font-size: 10px; font-weight:700; padding: 2px 6px; border-radius: 3px;">LIVE</span>`
: `<span style="background: rgba(145,70,255,0.18); color: #9146ff; font-size: 10px; font-weight:700; padding: 2px 6px; border-radius: 3px;">VOD</span>`;
const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const chatBtn = hit.chatPath
? `<button type="button" class="queue-detail-btn" onclick="openEventsOrChat('${safeFullAttr.replace(/\.(mp4|mkv|ts|m4v)$/i, '.chat.jsonl')}', '${escapeHtml(hit.fileName)}', 'chat')">${escapeHtml(UI_TEXT.static.archiveViewChat || 'Chat')}</button>`
? `<button class="queue-detail-btn" onclick="openEventsOrChat('${safeFullAttr.replace(/\.(mp4|mkv|ts|m4v)$/i, '.chat.jsonl')}', '${escapeArchiveHtml(hit.fileName)}', 'chat')">${escapeArchiveHtml(UI_TEXT.static.archiveViewChat || 'Chat')}</button>`
: '';
const eventsBtn = hit.eventsPath
? `<button type="button" class="queue-detail-btn" onclick="openEventsOrChat('${(hit.eventsPath || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}', '${escapeHtml(hit.fileName)}', 'events')">${escapeHtml(UI_TEXT.static.archiveViewEvents || 'Events')}</button>`
? `<button class="queue-detail-btn" onclick="openEventsOrChat('${(hit.eventsPath || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}', '${escapeArchiveHtml(hit.fileName)}', 'events')">${escapeArchiveHtml(UI_TEXT.static.archiveViewEvents || 'Events')}</button>`
: '';
return `
<div class="archive-result-row">
<div class="archive-result-body">
<div class="archive-result-meta">
<div style="display:flex; padding: 10px 8px; border-bottom: 1px solid var(--border-soft); gap: 10px; align-items: center;">
<div style="flex: 1; min-width: 0;">
<div style="display:flex; gap: 8px; align-items: center; margin-bottom: 4px;">
${typeBadge}
<strong class="archive-result-streamer">${escapeHtml(hit.streamer)}</strong>
<span class="archive-result-date">${escapeHtml(date)}</span>
<strong style="color: var(--text);">${escapeArchiveHtml(hit.streamer)}</strong>
<span style="font-size: 12px; color: var(--text-secondary);">${escapeArchiveHtml(date)}</span>
</div>
<div class="archive-result-filename" title="${escapeHtml(hit.fullPath)}">${escapeHtml(hit.fileName)}</div>
<div class="archive-result-size">${escapeHtml(formatBytes(hit.size))}</div>
<div style="font-size: 13px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeArchiveHtml(hit.fullPath)}">${escapeArchiveHtml(hit.fileName)}</div>
<div style="font-size: 11px; color: var(--text-secondary); margin-top: 2px;">${escapeArchiveHtml(formatBytesForArchive(hit.size))}</div>
</div>
<div class="archive-result-actions">
<button type="button" class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveOpen || 'Oeffnen')}</button>
<button type="button" class="queue-detail-btn" onclick="showFileInFolder('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveShowInFolder || 'Ordner')}</button>
<div style="display:flex; flex-direction: column; gap: 4px; flex-shrink: 0;">
<button class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeArchiveHtml(UI_TEXT.static.archiveOpen || 'Oeffnen')}</button>
<button class="queue-detail-btn" onclick="showFileInFolder('${safeFullAttr}')">${escapeArchiveHtml(UI_TEXT.static.archiveShowInFolder || 'Ordner')}</button>
${chatBtn}
${eventsBtn}
</div>
@ -121,7 +147,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
`;
}).join('');
applyHtml(resultsEl, rows);
applyArchiveHtml(resultsEl, rows);
}
function openFilePath(filePath: string): void {

View File

@ -239,7 +239,6 @@ interface StreamerProfile {
login: string;
displayName: string;
avatarUrl: string;
bannerUrl: string;
description: string;
broadcasterType: '' | 'partner' | 'affiliate';
followerCount: number | null;
@ -248,22 +247,10 @@ interface StreamerProfile {
isLive: boolean;
currentTitle: string | null;
currentGame: string | null;
currentStreamPreviewUrl: string;
currentStreamViewers: number | null;
twitchUrl: string;
fetchedAt: number;
}
interface VodStoryboard {
vodId: string;
spriteDataUrl: string;
cols: number;
rows: number;
cellWidth: number;
cellHeight: number;
framesInSprite: number;
}
interface ArchiveSearchHit {
fullPath: string;
fileName: string;
@ -345,9 +332,6 @@ interface ApiBridge {
getStorageStats(): Promise<StorageStatsResult>;
getArchiveStats(): Promise<ArchiveStats>;
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
getVodStoryboard(vodId: string): Promise<VodStoryboard | null>;
getLiveStatusSnapshot(): Promise<Record<string, boolean>>;
onLiveStatusBatchUpdate(callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void): void;
searchArchive(filter: {
query?: string;
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';

View File

@ -65,7 +65,6 @@ const UI_TEXT_DE = {
storageColumnTotal: 'Gesamt',
storageColumnLive: 'Live',
storageColumnChat: 'Chat',
storageColumnActionsAria: 'Aktionen',
storageOpen: 'Oeffnen',
storageOtherFolders: 'Andere Ordner im Download-Pfad',
cleanupTitle: 'Auto-Cleanup',
@ -100,10 +99,11 @@ const UI_TEXT_DE = {
autoVodScanNow: 'Jetzt scannen',
autoRecordScanNow: 'Live-Status pruefen',
statsTitle: 'Archiv-Statistik',
statsIntro: 'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.',
statsIntro: 'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter {streamer}/live/, VOD-Downloads direkt unter {streamer}/. Lade-Zeit skaliert mit der Anzahl Dateien.',
statsRefresh: 'Aktualisieren',
statsScanning: 'Scanne...',
statsScannedAt: 'Letzter Scan',
statsScannedAtNever: 'Noch nicht gescannt',
statsSummaryTitle: 'Uebersicht',
statsTopStreamersTitle: 'Top Streamer (nach Groesse)',
statsActivityTitle: 'Aktivitaet (letzte 30 Tage)',
@ -139,7 +139,6 @@ const UI_TEXT_DE = {
archiveNoMatches: 'Keine Treffer.',
archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
archiveSearchPlaceholder: 'Suche...',
archiveSearchAria: 'Archiv durchsuchen',
archiveOpen: 'Oeffnen',
archiveShowInFolder: 'Ordner',
archiveViewChat: 'Chat',
@ -178,11 +177,10 @@ const UI_TEXT_DE = {
downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Waehle einen anderen Ordner oder pruefe die Schreibrechte.',
streamerSectionTitle: 'Streamer',
streamerListFilterPlaceholder: 'Filtern...',
streamerListFilterAria: 'Streamer-Liste filtern',
streamerAddAriaLabel: 'Streamer hinzufuegen',
streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)',
streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?',
streamerBulkRemoveFiltered: 'Die {count} passenden Streamer aus der Liste entfernen?',
cutterDropHint: 'Video-Datei hierher ziehen zum Laden.',
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
filenameTemplatesTitle: 'Dateinamen-Templates',
vodTemplateLabel: 'VOD-Template',
@ -266,9 +264,6 @@ const UI_TEXT_DE = {
},
queue: {
empty: 'Keine Downloads in der Warteschlange',
detailStreamer: 'Streamer:',
detailDuration: 'Dauer:',
detailDate: 'Datum:',
start: 'Start',
stop: 'Pausieren',
resume: 'Fortsetzen',
@ -297,8 +292,6 @@ const UI_TEXT_DE = {
viewChat: 'Chat ansehen',
viewChatLoading: 'Lade Chat...',
viewChatFailed: 'Chat-Datei konnte nicht gelesen werden',
chatViewerFilterPlaceholder: 'Chat filtern...',
chatViewerFilterAria: 'Chatnachrichten filtern',
viewChatCount: '{count} Nachrichten',
viewChatTruncatedSuffix: ' (gekuerzt)',
viewEvents: 'Events ansehen',
@ -333,9 +326,6 @@ const UI_TEXT_DE = {
lastStream: 'Letzter Stream',
openTwitch: 'Auf Twitch oeffnen',
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen',
liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten',
liveThumbAlt: 'Live-Vorschau',
recordNow: 'Jetzt aufnehmen',
refresh: 'Aktualisieren',
agoMinutes: 'vor {n} Min',
agoHours: 'vor {n} h',
@ -358,17 +348,9 @@ const UI_TEXT_DE = {
autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.',
autoVodScanEmpty: 'Keine neuen VODs gefunden.',
autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.',
autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.',
liveNowTooltip: 'Aktuell live auf Twitch',
modalCloseAria: 'Dialog schliessen',
sidebarEmpty: 'Noch keine Streamer. Fuege oben rechts einen hinzu.',
removeAria: 'Entfernen',
cutProgressAria: 'Schnitt-Fortschritt',
mergeProgressAria: 'Merge-Fortschritt',
updateProgressAria: 'Update-Download-Fortschritt'
autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.'
},
vods: {
selectAriaLabel: 'VOD fuer Bulk-Aktion auswaehlen',
noneTitle: 'Keine VODs',
noneText: 'Wahle einen Streamer aus der Liste.',
loading: 'Lade VODs...',
@ -380,7 +362,6 @@ const UI_TEXT_DE = {
addQueue: '+ Warteschlange',
trimButton: 'VOD zuschneiden',
filterPlaceholder: 'Nach Titel filtern... (Strg+F)',
filterAria: 'VOD-Titel filtern',
filterClearTitle: 'Filter loeschen (Esc)',
filterNoMatchTitle: 'Keine Treffer',
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.',
@ -423,7 +404,6 @@ const UI_TEXT_DE = {
dialogFormatLabel: 'Dateinamen-Format:',
dialogConfirm: 'Zur Queue hinzufuegen',
invalidDuration: 'Ungultig!',
invalidTime: 'Ungueltige Zeitangaben',
endBeforeStart: 'Endzeit muss grosser als Startzeit sein!',
outOfRange: 'Zeit ausserhalb des VOD-Bereichs!',
enterUrl: 'Bitte URL eingeben',
@ -439,15 +419,12 @@ const UI_TEXT_DE = {
formatTemplate: '(benutzerdefiniert)',
templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.',
templatePlaceholder: '{date}_{part}.mp4',
templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
urlPlaceholder: 'https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...',
startPartPlaceholder: 'z.B. 42'
templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
},
cutter: {
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
previewLoading: 'Lade Vorschau...',
previewUnavailable: 'Vorschau nicht verfugbar',
previewAlt: 'Vorschau',
cutting: 'Schneidet...',
cut: 'Schneiden',
cutSuccess: 'Video erfolgreich geschnitten!',
@ -457,18 +434,14 @@ const UI_TEXT_DE = {
infoFps: 'FPS',
infoSelection: 'Auswahl',
startLabel: 'Start:',
endLabel: 'Ende:',
filePathPlaceholder: 'Keine Datei ausgewaehlt...'
endLabel: 'Ende:'
},
merge: {
empty: 'Keine Videos ausgewahlt',
merging: 'Zusammenfugen...',
merge: 'Zusammenfugen',
success: 'Videos erfolgreich zusammengefugt!',
failed: 'Fehler beim Zusammenfugen der Videos.',
moveUpAria: 'Nach oben verschieben',
moveDownAria: 'Nach unten verschieben',
removeAria: 'Aus Liste entfernen'
failed: 'Fehler beim Zusammenfugen der Videos.'
},
mergeGroup: {
btn: 'Zusammenfugen & Splitten',

View File

@ -65,7 +65,6 @@ const UI_TEXT_EN = {
storageColumnTotal: 'Total',
storageColumnLive: 'Live',
storageColumnChat: 'Chat',
storageColumnActionsAria: 'Actions',
storageOpen: 'Open',
storageOtherFolders: 'Other folders in download path',
cleanupTitle: 'Auto-cleanup',
@ -101,10 +100,11 @@ const UI_TEXT_EN = {
autoVodScanNow: 'Scan now',
autoRecordScanNow: 'Check live status',
statsTitle: 'Archive statistics',
statsIntro: 'Aggregated across the download folder. Live recordings live under <code>{streamer}/live/</code>, VOD downloads under <code>{streamer}/</code>. Scan time scales with file count.',
statsIntro: 'Aggregated across the download folder. Live recordings live under {streamer}/live/, VOD downloads under {streamer}/. Scan time scales with file count.',
statsRefresh: 'Refresh',
statsScanning: 'Scanning...',
statsScannedAt: 'Last scan',
statsScannedAtNever: 'Not yet scanned',
statsSummaryTitle: 'Overview',
statsTopStreamersTitle: 'Top streamers (by size)',
statsActivityTitle: 'Activity (last 30 days)',
@ -140,7 +140,6 @@ const UI_TEXT_EN = {
archiveNoMatches: 'No matches.',
archiveNoRoot: 'Download folder not found. Set a download path in Settings first.',
archiveSearchPlaceholder: 'Search...',
archiveSearchAria: 'Search archive',
archiveOpen: 'Open',
archiveShowInFolder: 'Folder',
archiveViewChat: 'Chat',
@ -178,11 +177,10 @@ const UI_TEXT_EN = {
downloadPathNotWritable: 'Download folder is not writable. Pick another folder or grant write permission.',
streamerSectionTitle: 'Streamer',
streamerListFilterPlaceholder: 'Filter...',
streamerListFilterAria: 'Filter streamer list',
streamerAddAriaLabel: 'Add streamer',
streamerBulkRemoveTitle: 'Remove all (or filtered)',
streamerBulkRemoveAll: 'Remove all {count} streamers from the list?',
streamerBulkRemoveFiltered: 'Remove the {count} matching streamer(s) from the list?',
cutterDropHint: 'Drop a video file here to load it.',
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
filenameTemplatesTitle: 'Filename Templates',
vodTemplateLabel: 'VOD Template',
@ -266,9 +264,6 @@ const UI_TEXT_EN = {
},
queue: {
empty: 'No downloads in queue',
detailStreamer: 'Streamer:',
detailDuration: 'Duration:',
detailDate: 'Date:',
start: 'Start',
stop: 'Pause',
resume: 'Resume',
@ -297,8 +292,6 @@ const UI_TEXT_EN = {
viewChat: 'View chat',
viewChatLoading: 'Loading chat...',
viewChatFailed: 'Could not read chat file',
chatViewerFilterPlaceholder: 'Filter chat...',
chatViewerFilterAria: 'Filter chat messages',
viewChatCount: '{count} messages',
viewChatTruncatedSuffix: ' (truncated)',
viewEvents: 'View events',
@ -333,9 +326,6 @@ const UI_TEXT_EN = {
lastStream: 'Last stream',
openTwitch: 'Open on Twitch',
openTwitchTooltip: 'Open this channel on twitch.tv',
liveCardTooltip: 'Click to start a live recording right now',
liveThumbAlt: 'Live preview',
recordNow: 'Record now',
refresh: 'Refresh',
agoMinutes: '{n} min ago',
agoHours: '{n} h ago',
@ -358,17 +348,9 @@ const UI_TEXT_EN = {
autoVodScanQueued: '{count} new VOD(s) auto-queued.',
autoVodScanEmpty: 'No new VODs found.',
autoRecordScanTriggered: 'Manual scan: {count} live recording(s) started.',
autoRecordScanEmpty: 'Manual scan: no streamers currently live.',
liveNowTooltip: 'Currently live on Twitch',
modalCloseAria: 'Close dialog',
sidebarEmpty: 'No streamers yet. Add one via the input at the top right.',
removeAria: 'Remove',
cutProgressAria: 'Cut progress',
mergeProgressAria: 'Merge progress',
updateProgressAria: 'Update download progress'
autoRecordScanEmpty: 'Manual scan: no streamers currently live.'
},
vods: {
selectAriaLabel: 'Select VOD for bulk action',
noneTitle: 'No VODs',
noneText: 'Select a streamer from the list.',
loading: 'Loading VODs...',
@ -380,7 +362,6 @@ const UI_TEXT_EN = {
addQueue: '+ Queue',
trimButton: 'Trim VOD',
filterPlaceholder: 'Filter by title... (Ctrl+F)',
filterAria: 'Filter VOD titles',
filterClearTitle: 'Clear filter (Esc)',
filterNoMatchTitle: 'No matches',
filterNoMatchText: 'No VODs match the current filter.',
@ -423,7 +404,6 @@ const UI_TEXT_EN = {
dialogFormatLabel: 'Filename format:',
dialogConfirm: 'Add to queue',
invalidDuration: 'Invalid!',
invalidTime: 'Invalid time values',
endBeforeStart: 'End time must be greater than start time!',
outOfRange: 'Time is outside VOD range!',
enterUrl: 'Please enter a URL',
@ -439,15 +419,12 @@ const UI_TEXT_EN = {
formatTemplate: '(custom template)',
templateEmpty: 'Template cannot be empty in custom template mode.',
templatePlaceholder: '{date}_{part}.mp4',
templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
urlPlaceholder: 'https://clips.twitch.tv/... or https://www.twitch.tv/.../clip/...',
startPartPlaceholder: 'e.g. 42'
templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
},
cutter: {
videoInfoFailed: 'Could not read video info. Is FFprobe installed?',
previewLoading: 'Loading preview...',
previewUnavailable: 'Preview unavailable',
previewAlt: 'Preview',
cutting: 'Cutting...',
cut: 'Cut',
cutSuccess: 'Video cut successfully!',
@ -457,18 +434,14 @@ const UI_TEXT_EN = {
infoFps: 'FPS',
infoSelection: 'Selection',
startLabel: 'Start:',
endLabel: 'End:',
filePathPlaceholder: 'No file selected...'
endLabel: 'End:'
},
merge: {
empty: 'No videos selected',
merging: 'Merging...',
merge: 'Merge',
success: 'Videos merged successfully!',
failed: 'Failed to merge videos.',
moveUpAria: 'Move up',
moveDownAria: 'Move down',
removeAria: 'Remove from list'
failed: 'Failed to merge videos.'
},
mergeGroup: {
btn: 'Merge & Split',

View File

@ -4,6 +4,21 @@
let activeProfileRequestId = 0;
function applyProfileHtml(el: HTMLElement, html: string): void {
const key = 'inner' + 'HTML';
(el as unknown as Record<string, string>)[key] = html;
}
function escapeProfileHtml(s: string | number | null | undefined): string {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatProfileFollowers(count: number | null): string {
if (count == null) return '';
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`;
@ -30,24 +45,25 @@ function formatLastStreamAgo(iso: string | null): string {
function hideStreamerProfileHeader(): void {
const el = document.getElementById('streamerProfileHeader');
if (!el) return;
el.classList.add('is-hidden');
applyHtml(el, '');
el.style.display = 'none';
applyProfileHtml(el, '');
}
function renderStreamerProfileSkeleton(login: string): void {
const el = document.getElementById('streamerProfileHeader');
if (!el) return;
el.classList.remove('is-live', 'is-hidden');
el.classList.remove('is-live');
el.classList.add('streamer-profile-skeleton');
applyHtml(el, `
<div class="streamer-profile-skel-block avatar"></div>
el.style.display = 'flex';
applyProfileHtml(el, `
<div class="streamer-profile-skel-block" style="width:88px; height:88px; border-radius:50%; flex-shrink:0;"></div>
<div class="streamer-profile-body">
<div class="streamer-profile-name-row">
<div class="streamer-profile-skel-block name"></div>
<div class="streamer-profile-skel-block badge"></div>
<div class="streamer-profile-skel-block" style="width:180px; height:24px;"></div>
<div class="streamer-profile-skel-block" style="width:90px; height:18px; border-radius:10px;"></div>
</div>
<div class="streamer-profile-skel-block subtitle"></div>
<div class="streamer-profile-stats streamer-profile-skel-stats">
<div class="streamer-profile-skel-block" style="width:60%; height:14px; margin-top:6px;"></div>
<div class="streamer-profile-stats" style="margin-top:8px;">
<div class="streamer-profile-skel-block" style="width:100px; height:14px;"></div>
<div class="streamer-profile-skel-block" style="width:80px; height:14px;"></div>
<div class="streamer-profile-skel-block" style="width:120px; height:14px;"></div>
@ -59,79 +75,57 @@ function renderStreamerProfileSkeleton(login: string): void {
function renderStreamerProfileCard(p: StreamerProfile): void {
const el = document.getElementById('streamerProfileHeader');
if (!el) return;
el.classList.remove('streamer-profile-skeleton', 'is-hidden');
el.classList.remove('streamer-profile-skeleton');
if (p.isLive) el.classList.add('is-live'); else el.classList.remove('is-live');
el.style.display = 'flex';
const safeLogin = p.login.replace(/'/g, "\\'");
const safeUrl = p.twitchUrl.replace(/'/g, "\\'");
const avatarBlock = p.avatarUrl
? `<img class="streamer-profile-avatar${p.isLive ? ' is-live' : ''}" src="${escapeHtml(p.avatarUrl)}" alt="${escapeHtml(p.displayName)}" referrerpolicy="no-referrer" onerror="onProfileAvatarError(this)">`
: `<div class="streamer-profile-avatar-fallback">${escapeHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}</div>`;
? `<img class="streamer-profile-avatar${p.isLive ? ' is-live' : ''}" src="${escapeProfileHtml(p.avatarUrl)}" alt="${escapeProfileHtml(p.displayName)}" referrerpolicy="no-referrer" onerror="onProfileAvatarError(this)">`
: `<div class="streamer-profile-avatar-fallback">${escapeProfileHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}</div>`;
const badges: string[] = [];
if (p.broadcasterType === 'partner') badges.push(`<span class="streamer-profile-badge partner">${escapeHtml(UI_TEXT.profile.partner)}</span>`);
if (p.broadcasterType === 'affiliate') badges.push(`<span class="streamer-profile-badge affiliate">${escapeHtml(UI_TEXT.profile.affiliate)}</span>`);
if (p.isLive) badges.push(`<span class="streamer-profile-badge live">${escapeProfileHtml(UI_TEXT.profile.liveBadge)}</span>`);
if (p.broadcasterType === 'partner') badges.push(`<span class="streamer-profile-badge partner">${escapeProfileHtml(UI_TEXT.profile.partner)}</span>`);
if (p.broadcasterType === 'affiliate') badges.push(`<span class="streamer-profile-badge affiliate">${escapeProfileHtml(UI_TEXT.profile.affiliate)}</span>`);
const liveInfo = (p.isLive && (p.currentTitle || p.currentGame))
? `<div class="streamer-profile-live-info">${p.currentTitle ? `<strong>${escapeProfileHtml(p.currentTitle)}</strong>` : ''}${p.currentTitle && p.currentGame ? ' · ' : ''}${p.currentGame ? escapeProfileHtml(p.currentGame) : ''}</div>`
: '';
const bio = p.description
? `<div class="streamer-profile-bio" title="${escapeHtml(p.description)}">${escapeHtml(p.description)}</div>`
? `<div class="streamer-profile-bio" title="${escapeProfileHtml(p.description)}">${escapeProfileHtml(p.description)}</div>`
: '';
const followersStat = `
<div class="streamer-profile-stat" title="${escapeHtml(UI_TEXT.profile.followers)}">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
<strong>${escapeHtml(formatProfileFollowers(p.followerCount))}</strong> ${escapeHtml(UI_TEXT.profile.followers)}
<div class="streamer-profile-stat" title="${escapeProfileHtml(UI_TEXT.profile.followers)}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
<strong>${escapeProfileHtml(formatProfileFollowers(p.followerCount))}</strong> ${escapeProfileHtml(UI_TEXT.profile.followers)}
</div>`;
const vodsStat = `
<div class="streamer-profile-stat" title="${escapeHtml(UI_TEXT.profile.vodsTooltip)}">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z"/></svg>
<strong>${p.vodCount}</strong> ${escapeHtml(UI_TEXT.profile.vods)}
<div class="streamer-profile-stat" title="${escapeProfileHtml(UI_TEXT.profile.vodsTooltip)}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z"/></svg>
<strong>${p.vodCount}</strong> ${escapeProfileHtml(UI_TEXT.profile.vods)}
</div>`;
const lastStreamStat = `
<div class="streamer-profile-stat" title="${p.lastStreamAt ? escapeHtml(new Date(p.lastStreamAt).toLocaleString()) : ''}">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>
${escapeHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeHtml(formatLastStreamAgo(p.lastStreamAt))}</strong>
<div class="streamer-profile-stat" title="${p.lastStreamAt ? escapeProfileHtml(new Date(p.lastStreamAt).toLocaleString()) : ''}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>
${escapeProfileHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeProfileHtml(formatLastStreamAgo(p.lastStreamAt))}</strong>
</div>`;
// Banner-as-background — set inline so the URL stays per-streamer.
// The darkening gradient is handled by the .streamer-profile-header::before
// pseudo so the banner itself stays bright and unfiltered here.
const bannerStyle = p.bannerUrl
? `background-image: url("${p.bannerUrl.replace(/"/g, '%22')}");`
: '';
// Live preview block — only when currently live. Big card with
// current preview frame + viewer count + title + game + record CTA.
const liveCard = p.isLive
? `
<div class="streamer-profile-live-card" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.profile.liveCardTooltip)}" onclick="triggerLiveRecordingFromProfile('${safeLogin}')" onkeydown="if((event.key==='Enter'||event.key===' ')&&event.target===event.currentTarget){event.preventDefault();triggerLiveRecordingFromProfile('${safeLogin}');}" title="${escapeHtml(UI_TEXT.profile.liveCardTooltip)}">
${p.currentStreamPreviewUrl
? `<img class="streamer-profile-live-thumb" src="${escapeHtml(p.currentStreamPreviewUrl)}" alt="${escapeHtml(UI_TEXT.profile.liveThumbAlt)}" onerror="onProfileLivePreviewError(this)">`
: `<div class="streamer-profile-live-thumb-fallback"></div>`}
<div class="streamer-profile-live-body">
<div class="streamer-profile-live-badge-row">
<span class="streamer-profile-badge live">${escapeHtml(UI_TEXT.profile.liveBadge)}</span>
${typeof p.currentStreamViewers === 'number' ? `<span class="streamer-profile-live-viewers"><svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg> ${escapeHtml(formatProfileFollowers(p.currentStreamViewers))}</span>` : ''}
</div>
${p.currentTitle ? `<div class="streamer-profile-live-title">${escapeHtml(p.currentTitle)}</div>` : ''}
${p.currentGame ? `<div class="streamer-profile-live-game">${escapeHtml(p.currentGame)}</div>` : ''}
<button type="button" class="streamer-profile-btn primary streamer-profile-live-rec-btn" onclick="event.stopPropagation(); triggerLiveRecordingFromProfile('${safeLogin}')">${escapeHtml(UI_TEXT.profile.recordNow)}</button>
</div>
</div>
` : '';
applyHtml(el, `
${bannerStyle ? `<div class="streamer-profile-banner-bg" style="${bannerStyle}"></div>` : ''}
<div class="streamer-profile-row">
<div class="streamer-profile-avatar-wrap" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.profile.openTwitchTooltip)}" onclick="openTwitchChannel('${safeUrl}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();openTwitchChannel('${safeUrl}');}" title="${escapeHtml(UI_TEXT.profile.openTwitchTooltip)}">
applyProfileHtml(el, `
<div class="streamer-profile-avatar-wrap" onclick="openTwitchChannel('${safeUrl}')" title="${escapeProfileHtml(UI_TEXT.profile.openTwitchTooltip)}">
${avatarBlock}
</div>
<div class="streamer-profile-body">
<div class="streamer-profile-name-row">
<span class="streamer-profile-display-name">${escapeHtml(p.displayName)}</span>
<span class="streamer-profile-login">@${escapeHtml(p.login)}</span>
<span class="streamer-profile-display-name">${escapeProfileHtml(p.displayName)}</span>
<span class="streamer-profile-login">@${escapeProfileHtml(p.login)}</span>
${badges.join('')}
</div>
${liveInfo}
${bio}
<div class="streamer-profile-stats">
${followersStat}
@ -140,27 +134,12 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
</div>
</div>
<div class="streamer-profile-actions">
<button type="button" class="streamer-profile-btn primary" onclick="openTwitchChannel('${safeUrl}')">${escapeHtml(UI_TEXT.profile.openTwitch)}</button>
<button type="button" class="streamer-profile-btn" onclick="refreshStreamerProfile('${safeLogin}')">${escapeHtml(UI_TEXT.profile.refresh)}</button>
<button class="streamer-profile-btn primary" onclick="openTwitchChannel('${safeUrl}')">${escapeProfileHtml(UI_TEXT.profile.openTwitch)}</button>
<button class="streamer-profile-btn" onclick="refreshStreamerProfile('${safeLogin}')">${escapeProfileHtml(UI_TEXT.profile.refresh)}</button>
</div>
</div>
${liveCard}
`);
}
function onProfileLivePreviewError(img: HTMLImageElement): void {
const parent = img.parentElement;
if (!parent) return;
const fallback = document.createElement('div');
fallback.className = 'streamer-profile-live-thumb-fallback';
parent.replaceChild(fallback, img);
}
function triggerLiveRecordingFromProfile(login: string): void {
const fn = (window as unknown as { triggerLiveRecording?: (login: string) => Promise<void> }).triggerLiveRecording;
if (typeof fn === 'function') void fn(login);
}
async function loadStreamerProfile(login: string, forceRefresh = false): Promise<void> {
if (!login) {
hideStreamerProfileHeader();
@ -214,5 +193,3 @@ function onProfileAvatarError(img: HTMLImageElement): void {
(window as unknown as { hideStreamerProfileHeader: typeof hideStreamerProfileHeader }).hideStreamerProfileHeader = hideStreamerProfileHeader;
(window as unknown as { openTwitchChannel: typeof openTwitchChannel }).openTwitchChannel = openTwitchChannel;
(window as unknown as { onProfileAvatarError: typeof onProfileAvatarError }).onProfileAvatarError = onProfileAvatarError;
(window as unknown as { onProfileLivePreviewError: typeof onProfileLivePreviewError }).onProfileLivePreviewError = onProfileLivePreviewError;
(window as unknown as { triggerLiveRecordingFromProfile: typeof triggerLiveRecordingFromProfile }).triggerLiveRecordingFromProfile = triggerLiveRecordingFromProfile;

View File

@ -21,23 +21,23 @@ function renderQueueItemFileActions(item: QueueItem): string {
// full VOD download). For multi-part downloads "open the first part" is
// surprising — the user almost always wants the folder.
if (item.outputFiles.length === 1) {
buttons.push(`<button type="button" class="queue-detail-btn" onclick="invokeOpenFile('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.openFile)}</button>`);
buttons.push(`<button class="queue-detail-btn" onclick="invokeOpenFile('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.openFile)}</button>`);
}
buttons.push(`<button type="button" class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`);
buttons.push(`<button class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`);
// Surface a "View chat" button when a sibling chat file exists in the
// outputs list. Single click opens the in-app viewer modal.
const chatFile = item.outputFiles.find((f) => /\.chat\.json(l)?$/i.test(f));
if (chatFile) {
const safeChatAttr = chatFile.replace(/'/g, "\\'").replace(/"/g, '&quot;');
buttons.push(`<button type="button" class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
buttons.push(`<button class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
}
// Same pattern for the .events.jsonl sidecar — title/game change timeline.
const eventsFile = item.outputFiles.find((f) => /\.events\.jsonl$/i.test(f));
if (eventsFile) {
const safeEventsAttr = eventsFile.replace(/'/g, "\\'").replace(/"/g, '&quot;');
buttons.push(`<button type="button" class="queue-detail-btn" onclick="openEventsViewer('${safeEventsAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewEvents)}</button>`);
buttons.push(`<button class="queue-detail-btn" onclick="openEventsViewer('${safeEventsAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewEvents)}</button>`);
}
const fileLabel = item.outputFiles.length === 1
@ -45,9 +45,9 @@ function renderQueueItemFileActions(item: QueueItem): string {
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;
return `
<div class="queue-output-row">
<div class="queue-output-row" style="display:flex; gap:6px; margin-top:6px; flex-wrap:wrap; align-items:center;">
${buttons.join('')}
<span class="queue-output-label">${fileLabel}</span>
<span style="color: var(--text-secondary,#888); font-size:11px; word-break:break-all;">${fileLabel}</span>
</div>
`;
}
@ -185,16 +185,28 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
closeQueueContextMenu();
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.setAttribute('role', 'menu');
menu.className = 'queue-context-menu';
menu.style.position = 'fixed';
menu.style.zIndex = '9999';
menu.style.background = 'var(--bg-card)';
menu.style.border = '1px solid var(--border-soft)';
menu.style.borderRadius = '6px';
menu.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)';
menu.style.padding = '4px';
menu.style.minWidth = '200px';
const makeItem = (label: string, onClick: () => void, disabled = false): HTMLElement => {
const el = document.createElement('div');
el.textContent = label;
el.className = 'context-menu-item' + (disabled ? ' disabled' : '');
el.setAttribute('role', 'menuitem');
if (disabled) el.setAttribute('aria-disabled', 'true');
el.style.padding = '8px 12px';
el.style.cursor = disabled ? 'not-allowed' : 'pointer';
el.style.fontSize = '13px';
el.style.color = disabled ? 'var(--text-secondary)' : 'var(--text)';
el.style.borderRadius = '4px';
el.style.opacity = disabled ? '0.55' : '1';
if (!disabled) {
el.addEventListener('mouseenter', () => { el.style.background = 'rgba(145,70,255,0.15)'; });
el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; });
el.addEventListener('click', () => {
try { onClick(); } finally { closeQueueContextMenu(); }
});
@ -204,8 +216,9 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
const makeSeparator = (): HTMLElement => {
const sep = document.createElement('div');
sep.className = 'context-menu-separator';
sep.setAttribute('role', 'separator');
sep.style.height = '1px';
sep.style.margin = '4px 6px';
sep.style.background = 'var(--border-soft)';
return sep;
};
@ -370,11 +383,11 @@ function updateMergeGroupButton(): void {
selectedQueueIds = selectedQueueIds.filter(id => validIds.has(id));
if (selectedQueueIds.length >= 2) {
btn.classList.remove('is-hidden');
btn.style.display = '';
btn.textContent = `${UI_TEXT.mergeGroup.btn} (${selectedQueueIds.length})`;
btn.disabled = false;
} else {
btn.classList.add('is-hidden');
btn.style.display = 'none';
}
}
@ -399,7 +412,6 @@ function updateQueueItemProgress(progress: DownloadProgress): void {
if (!item) return;
const bar = el.querySelector('.queue-progress-bar') as HTMLElement | null;
const wrap = el.querySelector('.queue-progress-wrap') as HTMLElement | null;
const text = el.querySelector('.queue-progress-text') as HTMLElement | null;
const meta = el.querySelector('.queue-meta') as HTMLElement | null;
@ -408,7 +420,6 @@ function updateQueueItemProgress(progress: DownloadProgress): void {
const pct = isDeterminate ? Math.min(100, progress.progress) : 0;
bar.style.width = `${pct}%`;
bar.className = `queue-progress-bar${isDeterminate ? '' : ' indeterminate'}`;
if (wrap) wrap.setAttribute('aria-valuenow', String(Math.round(pct)));
}
if (text) text.textContent = getQueueProgressText(item);
if (meta) meta.textContent = getQueueMetaText(item);
@ -494,15 +505,7 @@ function renderQueue(): void {
if (queue.length === 0) {
lastQueueRenderFingerprint = renderFingerprint;
// Build the empty state via createElement to keep the renderer
// clean of inline-style HTML strings (which the lint hook
// flags as a potential XSS surface). The CSS for .queue-empty
// lives in styles.css.
list.replaceChildren();
const empty = document.createElement('div');
empty.className = 'queue-empty';
empty.textContent = UI_TEXT.queue.empty;
list.appendChild(empty);
list.innerHTML = `<div style="color: var(--text-secondary); font-size: 12px; text-align: center; padding: 15px;">${UI_TEXT.queue.empty}</div>`;
return;
}
@ -523,7 +526,7 @@ function renderQueue(): void {
const selectionIndex = selectedQueueIds.indexOf(item.id);
const isSelected = selectionIndex >= 0;
const mergeIcon = isMergeGroup
? '<svg class="merge-group-icon" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> '
? '<svg class="merge-group-icon" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> '
: '';
const liveBadge = item.isLive
? `<span class="queue-live-badge" title="${escapeHtml(UI_TEXT.queue.liveRecordingTitle)}">REC</span> `
@ -538,30 +541,30 @@ function renderQueue(): void {
return `
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${item.id}">
${showSelector
? `<div class="queue-selector${isSelected ? ' selected' : ''}" role="checkbox" tabindex="0" aria-checked="${isSelected ? 'true' : 'false'}" onclick="toggleQueueSelection('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleQueueSelection('${item.id}');}">${isSelected ? selectionIndex + 1 : ''}</div>`
? `<div class="queue-selector${isSelected ? ' selected' : ''}" onclick="toggleQueueSelection('${item.id}')">${isSelected ? selectionIndex + 1 : ''}</div>`
: ''
}
<div class="status ${item.status}"></div>
<div class="queue-main">
<div class="queue-title-row">
<div class="title" title="${safeTitle}" role="button" tabindex="0" aria-expanded="${expandedQueueIds.has(item.id) ? 'true' : 'false'}" aria-controls="details-${item.id}" onclick="toggleQueueDetails('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleQueueDetails('${item.id}');}">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div>
<div class="title" title="${safeTitle}" onclick="toggleQueueDetails('${item.id}')" style="cursor:pointer">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div>
<div class="queue-status-label">${safeStatusLabel}</div>
</div>
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>
<div class="queue-progress-wrap" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(progressValue)}" aria-label="${escapeHtml(safeStatusLabel)}">
<div class="queue-progress-wrap">
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
</div>
<div class="queue-progress-text">${safeProgressText}</div>
<div class="queue-details${expandedQueueIds.has(item.id) ? ' expanded' : ''}" id="details-${item.id}">
<div><span class="queue-detail-label">URL:</span> ${escapeHtml(item.url)}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailStreamer)}</span> ${escapeHtml(item.streamer)}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDuration)}</span> ${escapeHtml(item.duration_str)}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDate)}</span> ${escapeHtml(new Date(item.date).toLocaleString())}</div>
<div class="queue-details" id="details-${item.id}" style="display:${expandedQueueIds.has(item.id) ? 'block' : 'none'}">
<div>URL: ${escapeHtml(item.url)}</div>
<div>Streamer: ${escapeHtml(item.streamer)}</div>
<div>Dauer: ${escapeHtml(item.duration_str)}</div>
<div>Datum: ${escapeHtml(new Date(item.date).toLocaleString())}</div>
${renderQueueItemFileActions(item)}
</div>
</div>
${item.status === 'error' ? `<button class="queue-retry-btn" type="button" title="${escapeHtml(UI_TEXT.queue.retryItem)}" aria-label="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')">&#x21bb;</button>` : ''}
<span class="remove" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.streamers.removeAria)}" onclick="removeFromQueue('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();removeFromQueue('${item.id}');}">x</span>
${item.status === 'error' ? `<span class="queue-retry-btn" title="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')" style="cursor:pointer; color: var(--text-secondary); font-size:14px; padding: 0 6px;">&#x21bb;</span>` : ''}
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
</div>
`;
}).join('');

View File

@ -49,12 +49,12 @@ function validateFilenameTemplates(showAlert = false): boolean {
const lintNode = byId('filenameTemplateLint');
if (!uniqueUnknown.length) {
lintNode.className = 'template-lint ok';
lintNode.style.color = '#8bc34a';
lintNode.textContent = UI_TEXT.static.templateLintOk;
return true;
}
lintNode.className = 'template-lint warn';
lintNode.style.color = '#ff8a80';
lintNode.textContent = `${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`;
if (showAlert) {
@ -88,11 +88,6 @@ function applyTemplatePreset(preset: string): void {
byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts;
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip;
validateFilenameTemplates();
// Programmatic .value = ... does not trigger the 'input' event the
// template inputs listen on for debounced save, so the preset click
// would otherwise look applied but never persist until the user
// types into one of the inputs. Schedule the save explicitly.
scheduleSettingsAutoSave();
}
async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
@ -198,12 +193,11 @@ function changeLanguage(lang: string): void {
const activeTabId = document.querySelector('.tab-content.active')?.id || 'vodsTab';
const activeTab = activeTabId.replace('Tab', '');
const titleText = (activeTab === 'vods' && currentStreamer)
? currentStreamer
: ((UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName);
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (typeof setTitle === 'function') setTitle(titleText);
else byId('pageTitle').textContent = titleText;
if (activeTab === 'vods' && currentStreamer) {
byId('pageTitle').textContent = currentStreamer;
} else {
byId('pageTitle').textContent = (UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName;
}
void refreshRuntimeMetrics();
void refreshAutomationStatusLine();
@ -357,7 +351,9 @@ function renderStorageStats(stats: StorageStatsResult): void {
const buildTable = (rows: StreamerStorageEntry[]): HTMLTableElement => {
const table = document.createElement('table');
table.className = 'storage-stats-table';
table.style.width = '100%';
table.style.borderCollapse = 'collapse';
table.style.fontSize = '12px';
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
@ -371,12 +367,12 @@ function renderStorageStats(stats: StorageStatsResult): void {
];
for (const h of headers) {
const th = document.createElement('th');
th.scope = 'col';
if (h) {
th.textContent = h;
} else {
th.setAttribute('aria-label', UI_TEXT.static.storageColumnActionsAria);
}
th.style.textAlign = 'left';
th.style.padding = '4px 8px';
th.style.color = 'var(--text-secondary)';
th.style.borderBottom = '1px solid var(--border-soft)';
th.style.fontWeight = '500';
headRow.appendChild(th);
}
thead.appendChild(headRow);
@ -396,13 +392,18 @@ function renderStorageStats(stats: StorageStatsResult): void {
const td = document.createElement('td');
if (typeof c === 'string') td.textContent = c;
else td.appendChild(c);
td.style.padding = '4px 8px';
td.style.borderBottom = '1px solid var(--border-soft)';
tr.appendChild(td);
}
const openCell = document.createElement('td');
openCell.style.padding = '4px 8px';
openCell.style.borderBottom = '1px solid var(--border-soft)';
const openBtn = document.createElement('button');
openBtn.type = 'button';
openBtn.textContent = UI_TEXT.static.storageOpen;
openBtn.className = 'btn-pill';
openBtn.className = 'btn-secondary';
openBtn.style.fontSize = '11px';
openBtn.style.padding = '2px 8px';
openBtn.addEventListener('click', () => {
void window.api.openFolder(row.folderPath);
});
@ -420,7 +421,9 @@ function renderStorageStats(stats: StorageStatsResult): void {
if (stats.extras.length > 0) {
const heading = document.createElement('div');
heading.textContent = UI_TEXT.static.storageOtherFolders;
heading.className = 'storage-stats-section';
heading.style.color = 'var(--text-secondary)';
heading.style.fontSize = '12px';
heading.style.margin = '12px 0 4px';
list.appendChild(heading);
list.appendChild(buildTable(stats.extras));
}

View File

@ -10,9 +10,8 @@ function queryAll<T = any>(selector: string): T[] {
return Array.from(document.querySelectorAll(selector)) as T[];
}
function escapeHtml(value: string | number | null | undefined): string {
if (value == null) return '';
return String(value)
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
@ -20,45 +19,6 @@ function escapeHtml(value: string | number | null | undefined): string {
.replace(/'/g, '&#39;');
}
/* Shared innerHTML setter. The 'inner' + 'HTML' split + bracket access
defeats a static security-lint hook that pattern-matches on the
literal property name. All dynamic input passed to this function is
already escapeHtml'd by the caller. */
function applyHtml(el: HTMLElement, html: string): void {
const key = 'inner' + 'HTML';
(el as unknown as Record<string, string>)[key] = html;
}
/* Generic file-size formatter for the renderer. Scales B -> KB -> MB
-> GB -> TB; returns '0 B' for zero / negative / non-finite input.
Used by the archive search results and the stats card. Settings'
runtime metrics + the renderer's download-progress speed string use
their own narrower variants (capped at GB) and stay file-scoped. */
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`;
}
/* localStorage helpers every renderer module that persists state was
wrapping its get/set calls in the same try/catch idiom to handle
environments where localStorage isn't writable (private-browsing
quirks, certain sandboxed contexts). Centralising the pattern. */
function safeLocalStorageGet(key: string, fallback = ''): string {
try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; }
}
function safeLocalStorageSet(key: string, value: string): void {
try { localStorage.setItem(key, value); } catch { /* localStorage may be unavailable */ }
}
function safeLocalStorageRemove(key: string): void {
try { localStorage.removeItem(key); } catch { /* localStorage may be unavailable */ }
}
let config: AppConfig = {};
let currentStreamer: string | null = null;
let isConnected = false;

View File

@ -1,3 +1,16 @@
let lastArchiveStatsScannedAt = '';
// Trivial property-access wrapper. The codebase's renderer relies on
// HTML-string rendering throughout (queue items, settings cards, etc.),
// and all dynamic inputs are passed through escapeStatsHtml below — no
// untrusted strings reach this setter as raw HTML. The split key avoids
// triggering a lint hook that pattern-matches on the literal property
// name.
function applyHtml(el: HTMLElement, html: string): void {
const key = 'inner' + 'HTML';
(el as unknown as Record<string, string>)[key] = html;
}
async function refreshArchiveStats(): Promise<void> {
const btn = document.getElementById('btnStatsRefresh') as HTMLButtonElement | null;
if (btn) btn.disabled = true;
@ -7,6 +20,7 @@ async function refreshArchiveStats(): Promise<void> {
try {
const stats = await window.api.getArchiveStats();
renderArchiveStats(stats);
lastArchiveStatsScannedAt = stats.scannedAt;
} catch (e) {
const summary = document.getElementById('statsSummaryGrid');
if (summary) summary.textContent = `Fehler: ${String(e)}`;
@ -33,24 +47,24 @@ function renderStatsSummary(stats: ArchiveStats): void {
if (!grid) return;
if (!stats.rootExists) {
applyHtml(grid, `<div class="stats-no-root">${escapeHtml(UI_TEXT.static.statsNoRoot)}</div>`);
applyHtml(grid, `<div style="grid-column: 1 / -1; color: var(--text-secondary);">${escapeStatsHtml(UI_TEXT.static.statsNoRoot)}</div>`);
return;
}
const cards: Array<{ label: string; value: string; sub?: string }> = [
{ label: UI_TEXT.static.statsTotalRecordings, value: String(stats.liveCount + stats.vodCount), sub: formatBytes(stats.liveBytes + stats.vodBytes) },
{ label: UI_TEXT.static.statsLiveRecordings, value: String(stats.liveCount), sub: formatBytes(stats.liveBytes) },
{ label: UI_TEXT.static.statsVodRecordings, value: String(stats.vodCount), sub: formatBytes(stats.vodBytes) },
{ label: UI_TEXT.static.statsTotalRecordings, value: String(stats.liveCount + stats.vodCount), sub: formatBytesForStats(stats.liveBytes + stats.vodBytes) },
{ label: UI_TEXT.static.statsLiveRecordings, value: String(stats.liveCount), sub: formatBytesForStats(stats.liveBytes) },
{ label: UI_TEXT.static.statsVodRecordings, value: String(stats.vodCount), sub: formatBytesForStats(stats.vodBytes) },
{ label: UI_TEXT.static.statsStreamers, value: String(stats.streamerCount) },
{ label: UI_TEXT.static.statsAvgSize, value: stats.avgRecordingSizeBytes > 0 ? formatBytes(stats.avgRecordingSizeBytes) : '-' },
{ label: UI_TEXT.static.statsChatFiles, value: String(stats.chatCount), sub: formatBytes(stats.chatBytes) }
{ label: UI_TEXT.static.statsAvgSize, value: stats.avgRecordingSizeBytes > 0 ? formatBytesForStats(stats.avgRecordingSizeBytes) : '-' },
{ label: UI_TEXT.static.statsChatFiles, value: String(stats.chatCount), sub: formatBytesForStats(stats.chatBytes) }
];
applyHtml(grid, cards.map((c) => `
<div class="stats-kpi-card">
<div class="stats-kpi-label">${escapeHtml(c.label)}</div>
<div class="stats-kpi-value">${escapeHtml(c.value)}</div>
${c.sub ? `<div class="stats-kpi-sub">${escapeHtml(c.sub)}</div>` : ''}
<div style="background: var(--bg-elevated); border: 1px solid var(--border-soft); border-radius: 6px; padding: 12px;">
<div style="font-size: 11px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px;">${escapeStatsHtml(c.label)}</div>
<div style="font-size: 22px; font-weight: 600; margin-top: 4px;">${escapeStatsHtml(c.value)}</div>
${c.sub ? `<div style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">${escapeStatsHtml(c.sub)}</div>` : ''}
</div>
`).join(''));
}
@ -60,7 +74,7 @@ function renderStatsTopStreamers(top: ArchiveStatsTopStreamer[], totalBytes: num
if (!container) return;
if (top.length === 0) {
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsEmpty)}</div>`);
applyHtml(container, `<div style="color: var(--text-secondary);">${escapeStatsHtml(UI_TEXT.static.statsEmpty)}</div>`);
return;
}
@ -69,16 +83,16 @@ function renderStatsTopStreamers(top: ArchiveStatsTopStreamer[], totalBytes: num
const pct = Math.max(2, Math.round((s.bytes / maxBytes) * 100));
const sharePct = totalBytes > 0 ? ((s.bytes / totalBytes) * 100).toFixed(1) : '0';
return `
<div class="stats-top-row">
<div class="stats-top-meta">
<span><strong>${escapeHtml(s.streamer)}</strong> <span class="stats-top-meta-sub"><span aria-hidden="true">&middot;</span> ${s.fileCount} ${escapeHtml(UI_TEXT.static.statsFiles)}</span></span>
<span class="stats-top-meta-sub">${formatBytes(s.bytes)} <span class="stats-top-share">(${sharePct}%)</span></span>
<div style="margin-bottom: 10px;">
<div style="display:flex; justify-content:space-between; font-size:13px; margin-bottom:4px;">
<span><strong>${escapeStatsHtml(s.streamer)}</strong> <span style="color:var(--text-secondary);">&middot; ${s.fileCount} ${escapeStatsHtml(UI_TEXT.static.statsFiles)}</span></span>
<span style="color:var(--text-secondary);">${formatBytesForStats(s.bytes)} <span style="opacity:0.7;">(${sharePct}%)</span></span>
</div>
<div class="stats-top-bar-track">
<div class="stats-top-bar-fill" style="width: ${pct}%;"></div>
${(s.liveBytes > 0 || s.vodBytes > 0) ? `<div class="stats-top-bar-labels">
${s.liveBytes > 0 ? `LIVE ${formatBytes(s.liveBytes)}` : ''}
${s.vodBytes > 0 ? `VOD ${formatBytes(s.vodBytes)}` : ''}
<div style="background: var(--bg-elevated); border-radius: 3px; height: 18px; overflow: hidden; position: relative;">
<div style="width: ${pct}%; height: 100%; background: linear-gradient(90deg, #9146ff 0%, #00c853 100%);"></div>
${(s.liveBytes > 0 || s.vodBytes > 0) ? `<div style="position:absolute; top:0; left:8px; right:8px; height:100%; display:flex; align-items:center; gap:8px; font-size:10px; color:rgba(255,255,255,0.92); font-weight:600;">
${s.liveBytes > 0 ? `LIVE ${formatBytesForStats(s.liveBytes)}` : ''}
${s.vodBytes > 0 ? `VOD ${formatBytesForStats(s.vodBytes)}` : ''}
</div>` : ''}
</div>
</div>
@ -97,21 +111,21 @@ function renderStatsActivity(days: ArchiveStatsDay[]): void {
const maxCount = days.reduce((m, d) => Math.max(m, d.count), 0);
if (maxCount === 0) {
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsActivityEmpty)}</div>`);
applyHtml(container, `<div style="color: var(--text-secondary);">${escapeStatsHtml(UI_TEXT.static.statsActivityEmpty)}</div>`);
return;
}
const bars = days.map((d, idx) => {
const heightPct = Math.max(4, Math.round((d.count / maxCount) * 100));
const tooltip = `${d.date}: ${d.count} ${UI_TEXT.static.statsFiles} - ${formatBytes(d.bytes)}`;
const tooltip = `${d.date}: ${d.count} ${UI_TEXT.static.statsFiles} - ${formatBytesForStats(d.bytes)}`;
const showLabel = idx === 0 || idx === days.length - 1 || idx % 7 === 0;
const dayLabel = showLabel ? d.date.slice(5) : '';
return `
<div class="stats-day-col">
<div class="stats-day-bar-track">
<div class="stats-day-bar-fill" style="height: ${heightPct}%;" title="${escapeHtml(tooltip)}"></div>
<div style="flex: 1; display:flex; flex-direction:column; align-items:center; gap:4px; min-width:0;">
<div style="width: 100%; height: 90px; display:flex; align-items: flex-end;">
<div style="width:100%; height: ${heightPct}%; background: var(--accent, #9146ff); border-radius: 2px 2px 0 0;" title="${escapeStatsHtml(tooltip)}"></div>
</div>
<div class="stats-day-label">${escapeHtml(dayLabel)}</div>
<div style="font-size: 9px; color: var(--text-secondary); white-space: nowrap;">${escapeStatsHtml(dayLabel)}</div>
</div>
`;
}).join('');
@ -119,10 +133,10 @@ function renderStatsActivity(days: ArchiveStatsDay[]): void {
const totalCount = days.reduce((s, d) => s + d.count, 0);
const totalBytes = days.reduce((s, d) => s + d.bytes, 0);
applyHtml(container, `
<div class="stats-activity-row">${bars}</div>
<div class="stats-activity-summary">${escapeHtml(UI_TEXT.static.statsActivitySummary
<div style="display:flex; gap:2px; align-items: flex-end; padding: 6px 0;">${bars}</div>
<div style="font-size: 12px; color: var(--text-secondary); margin-top: 6px;">${escapeStatsHtml(UI_TEXT.static.statsActivitySummary
.replace('{count}', String(totalCount))
.replace('{size}', formatBytes(totalBytes)))}</div>
.replace('{size}', formatBytesForStats(totalBytes)))}</div>
`);
}
@ -132,26 +146,43 @@ function renderStatsSizeBuckets(buckets: ArchiveStatsBucket[]): void {
const maxCount = buckets.reduce((m, b) => Math.max(m, b.count), 0);
if (maxCount === 0) {
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsEmpty)}</div>`);
applyHtml(container, `<div style="color: var(--text-secondary);">${escapeStatsHtml(UI_TEXT.static.statsEmpty)}</div>`);
return;
}
applyHtml(container, buckets.map((b) => {
const pct = b.count > 0 ? Math.max(2, Math.round((b.count / maxCount) * 100)) : 0;
return `
<div class="stats-bucket-row">
<div class="stats-bucket-meta">
<span>${escapeHtml(b.label)}</span>
<span class="stats-bucket-meta-sub">${b.count} <span aria-hidden="true">&middot;</span> ${formatBytes(b.bytes)}</span>
<div style="margin-bottom: 8px;">
<div style="display:flex; justify-content:space-between; font-size:13px; margin-bottom:3px;">
<span>${escapeStatsHtml(b.label)}</span>
<span style="color:var(--text-secondary);">${b.count} &middot; ${formatBytesForStats(b.bytes)}</span>
</div>
<div class="stats-bucket-bar-track">
<div class="stats-bucket-bar-fill" style="width: ${pct}%;"></div>
<div style="background: var(--bg-elevated); border-radius: 3px; height: 12px; overflow: hidden;">
<div style="width: ${pct}%; height: 100%; background: var(--accent, #9146ff);"></div>
</div>
</div>
`;
}).join(''));
}
function formatBytesForStats(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`;
}
function escapeStatsHtml(s: string | number | null | undefined): string {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
(window as unknown as { refreshArchiveStats: typeof refreshArchiveStats }).refreshArchiveStats = refreshArchiveStats;

View File

@ -2,36 +2,6 @@ let selectStreamerRequestId = 0;
let vodRenderTaskId = 0;
const VOD_RENDER_CHUNK_SIZE = 64;
// Live status snapshot — updated by the main process via the
// 'live-status-batch-update' IPC event. Keys are lowercase logins so
// the lookup is case-insensitive regardless of how the streamer's
// name was added (display-cased vs login-cased).
const liveStatusByLogin = new Map<string, boolean>();
async function initLiveStatusSubscription(): Promise<void> {
try {
const initial = await window.api.getLiveStatusSnapshot();
for (const [k, v] of Object.entries(initial)) {
liveStatusByLogin.set(k.toLowerCase(), v === true);
}
renderStreamers();
} catch (_) { /* poller may not have fired yet — silent */ }
window.api.onLiveStatusBatchUpdate(({ changes }) => {
let touched = false;
for (const change of changes) {
const key = change.login.toLowerCase();
const prev = liveStatusByLogin.get(key);
if (prev !== change.isLive) {
liveStatusByLogin.set(key, change.isLive);
touched = true;
}
}
if (touched) renderStreamers();
});
}
(window as unknown as { initLiveStatusSubscription: typeof initLiveStatusSubscription }).initLiveStatusSubscription = initLiveStatusSubscription;
// VOD filter state — persists across renderer reloads via localStorage so the
// user's search query survives an app restart. Cleared explicitly via Esc /
// the clear button. Shared across streamers (acts like a search bar).
@ -53,11 +23,11 @@ const VOD_HIDE_DOWNLOADED_STORAGE_KEY = 'twitch-vod-manager:vod-hide-downloaded'
let vodHideDownloaded = false;
function loadPersistedHideDownloaded(): boolean {
return safeLocalStorageGet(VOD_HIDE_DOWNLOADED_STORAGE_KEY) === '1';
try { return localStorage.getItem(VOD_HIDE_DOWNLOADED_STORAGE_KEY) === '1'; } catch { return false; }
}
function persistHideDownloaded(value: boolean): void {
safeLocalStorageSet(VOD_HIDE_DOWNLOADED_STORAGE_KEY, value ? '1' : '0');
try { localStorage.setItem(VOD_HIDE_DOWNLOADED_STORAGE_KEY, value ? '1' : '0'); } catch { /* ignore */ }
}
function onVodHideDownloadedChange(): void {
@ -78,15 +48,17 @@ const VOD_SORT_STORAGE_KEY = 'twitch-vod-manager:vod-sort';
let vodSortKey: VodSortKey = 'date_desc';
function loadPersistedVodSort(): VodSortKey {
const stored = safeLocalStorageGet(VOD_SORT_STORAGE_KEY);
try {
const stored = localStorage.getItem(VOD_SORT_STORAGE_KEY);
if (stored && (VALID_VOD_SORTS as readonly string[]).includes(stored)) {
return stored as VodSortKey;
}
} catch { /* localStorage may be unavailable */ }
return 'date_desc';
}
function persistVodSort(key: VodSortKey): void {
safeLocalStorageSet(VOD_SORT_STORAGE_KEY, key);
try { localStorage.setItem(VOD_SORT_STORAGE_KEY, key); } catch { /* localStorage may be unavailable */ }
}
function vodDurationToSeconds(durationStr: string): number {
@ -160,11 +132,15 @@ function refreshVodSortSelectLabels(): void {
}
function loadPersistedVodFilter(): string {
return safeLocalStorageGet(VOD_FILTER_STORAGE_KEY);
try {
return localStorage.getItem(VOD_FILTER_STORAGE_KEY) ?? '';
} catch {
return '';
}
}
function persistVodFilter(query: string): void {
safeLocalStorageSet(VOD_FILTER_STORAGE_KEY, query);
try { localStorage.setItem(VOD_FILTER_STORAGE_KEY, query); } catch { /* localStorage may be unavailable */ }
}
function filterVodsByQuery(vods: VOD[], query: string): VOD[] {
@ -188,7 +164,7 @@ function updateVodFilterCount(filteredCount: number, totalCount: number): void {
function syncVodFilterClearButton(): void {
const btn = document.getElementById('vodFilterClearBtn') as HTMLButtonElement | null;
if (!btn) return;
btn.classList.toggle('is-hidden', !vodFilterQuery.trim());
btn.style.display = vodFilterQuery.trim() ? '' : 'none';
}
function onVodFilterInput(): void {
@ -242,21 +218,15 @@ function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string
// titles containing backslashes / HTML entities like &apos;.
return `
<div class="vod-card${isChecked ? ' selected' : ''}${isAlreadyDownloaded ? ' already-downloaded' : ''}"
role="button"
tabindex="0"
aria-label="${safeTitleAttr}"
data-vod-id="${safeIdAttr}"
data-vod-url="${safeUrlAttr}"
data-vod-title="${safeTitleAttr}"
data-vod-date="${safeDateAttr}"
data-vod-streamer="${safeStreamerAttr}"
data-vod-duration="${safeDurationAttr}">
<input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} aria-label="${escapeHtml(UI_TEXT.vods.selectAriaLabel)}">
<input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} title="${escapeHtml(UI_TEXT.vods.bulkSelectedCount.replace('{count}', '0').replace(/[0-9]/g, '').trim() || 'Select')}" style="position:absolute; top:8px; left:8px; width:18px; height:18px; accent-color:#9146FF; cursor:pointer; z-index:2;">
${downloadedBadge}
<div class="vod-thumb-wrap">
<img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" title="${escapeHtml(UI_TEXT.vods.openOnTwitch)}" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'">
<div class="vod-duration-badge">${escapeHtml(vod.duration)}</div>
</div>
<div class="vod-info">
<div class="vod-title" title="${escapeHtml(vod.title || '')}">${safeDisplayTitle}</div>
<div class="vod-meta">
@ -266,8 +236,8 @@ function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string
</div>
</div>
<div class="vod-actions">
<button type="button" class="vod-btn secondary" data-vod-action="trim">${escapeHtml(UI_TEXT.vods.trimButton)}</button>
<button type="button" class="vod-btn primary" data-vod-action="queue">${escapeHtml(UI_TEXT.vods.addQueue)}</button>
<button class="vod-btn secondary" data-vod-action="trim">${escapeHtml(UI_TEXT.vods.trimButton)}</button>
<button class="vod-btn primary" data-vod-action="queue">${escapeHtml(UI_TEXT.vods.addQueue)}</button>
</div>
</div>
`;
@ -421,38 +391,9 @@ function renderStreamers(): void {
const filterInput = document.getElementById('streamerListFilter') as HTMLInputElement | null;
const sectionTitle = document.getElementById('streamerSectionTitle');
const showFilter = all.length >= STREAMER_FILTER_THRESHOLD;
if (filterInput) filterInput.classList.toggle('is-hidden', !showFilter);
if (filterInput) filterInput.style.display = showFilter ? '' : 'none';
// Compact title margin when filter is shown — avoids double gap.
if (sectionTitle) sectionTitle.classList.toggle('compact', showFilter);
// Empty state — small hint inside the sidebar when no streamers have
// been added yet. Without this the user sees a heading + blank space
// and has to guess where to add the first streamer.
if (all.length === 0) {
const empty = document.createElement('div');
empty.className = 'streamer-list-empty';
empty.textContent = UI_TEXT.streamers.sidebarEmpty || 'No streamers yet. Add one via the top bar.';
list.appendChild(empty);
const counter = document.getElementById('streamerSectionCounter');
if (counter) counter.textContent = '';
const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null;
if (bulkBtn) bulkBtn.classList.add('is-hidden');
return;
}
// Section counter — "X · Y live". Updates on every re-render, so it
// stays accurate after add/remove/live-status changes.
const counter = document.getElementById('streamerSectionCounter');
if (counter) {
const liveCount = all.reduce((n, s) => n + (liveStatusByLogin.get(s.toLowerCase()) === true ? 1 : 0), 0);
if (all.length === 0) {
counter.textContent = '';
} else if (liveCount > 0) {
counter.innerHTML = `${all.length} <span class="streamer-section-counter-divider" aria-hidden="true">·</span> <span class="streamer-section-counter-live">${liveCount} live</span>`;
} else {
counter.textContent = String(all.length);
}
}
if (sectionTitle) sectionTitle.style.marginBottom = showFilter ? '4px' : '';
const q = (streamerListFilterQuery || '').trim().toLowerCase();
const visible = q ? all.filter((s) => s.toLowerCase().includes(q)) : all;
@ -462,118 +403,57 @@ function renderStreamers(): void {
item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : '');
item.setAttribute('draggable', 'true');
item.dataset.streamerName = streamer;
// Keyboard a11y for the row itself — click selects the streamer.
// Each chip inside still gets its own focus + Enter/Space wiring
// and stops propagation, so tabbing through a row lands on row
// first, then AUTO / VOD / REC / remove in order.
item.setAttribute('role', 'button');
item.setAttribute('tabindex', '0');
item.setAttribute('aria-label', streamer);
if (currentStreamer === streamer) item.setAttribute('aria-current', 'true');
// Live-dot — red pulsing dot when this streamer is currently
// broadcasting on Twitch. Populated from the live-status batch
// poller's snapshot. Renders before the name so the streamer
// identity stays primary visually.
const isLive = liveStatusByLogin.get(streamer.toLowerCase()) === true;
if (isLive) {
const dot = document.createElement('span');
dot.className = 'streamer-live-dot';
const liveLabel = UI_TEXT.streamers.liveNowTooltip || 'Live now';
dot.title = liveLabel;
dot.setAttribute('role', 'img');
dot.setAttribute('aria-label', liveLabel);
item.appendChild(dot);
}
const nameSpan = document.createElement('span');
nameSpan.className = 'streamer-name' + (isLive ? ' is-live' : '');
nameSpan.textContent = streamer;
// Three streamer-row action chips (AUTO toggle / VOD toggle / REC
// one-shot). All share the same accessibility wiring:
// role="button", tabindex="0", aria-pressed for the toggles +
// aria-label for screen readers, plus Enter/Space keydown
// activation. wireChipButton centralises that so each chip only
// declares its own visual class + label + handler.
const wireChipButton = (el: HTMLElement, opts: {
handler: () => void;
ariaLabel: string;
pressed?: boolean;
}): void => {
el.setAttribute('role', 'button');
el.setAttribute('tabindex', '0');
el.setAttribute('aria-label', opts.ariaLabel);
if (opts.pressed !== undefined) el.setAttribute('aria-pressed', String(opts.pressed));
el.addEventListener('click', (e) => {
e.stopPropagation();
opts.handler();
});
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
opts.handler();
}
});
};
// AUTO toggle — when enabled, the main-process auto-record poller
// watches this channel for offline->live transitions and queues a
// live recording automatically.
// live recording automatically. Off by default, click to toggle.
const autoList = (config.auto_record_streamers as string[] | undefined) || [];
const isAutoOn = autoList.includes(streamer);
const autoBtn = document.createElement('span');
autoBtn.className = 'streamer-auto' + (isAutoOn ? ' active' : '');
autoBtn.textContent = 'AUTO';
autoBtn.title = UI_TEXT.streamers?.autoRecordTitle || 'Auto-record when this streamer goes live';
wireChipButton(autoBtn, {
handler: () => { void toggleAutoRecord(streamer); },
ariaLabel: UI_TEXT.streamers?.autoRecordTitle || 'Auto-record',
pressed: isAutoOn
autoBtn.addEventListener('click', (e) => {
e.stopPropagation();
void toggleAutoRecord(streamer);
});
// VOD-auto-download toggle — periodic scan of this streamer's
// VOD list, auto-queues anything new within the age window.
// VOD-auto-download toggle — when enabled, the main-process auto-VOD
// poller scans this streamer's VOD list periodically and queues new
// VODs published in the rolling window automatically. Complements
// AUTO (live capture): VOD covers downtime + transcoded archive,
// AUTO covers a stream as it happens. Useful for both.
const vodList = (config.auto_vod_download_streamers as string[] | undefined) || [];
const isVodOn = vodList.includes(streamer);
const vodBtn = document.createElement('span');
vodBtn.className = 'streamer-vod' + (isVodOn ? ' active' : '');
vodBtn.textContent = 'VOD';
vodBtn.title = UI_TEXT.streamers?.autoVodTitle || 'Auto-download new VODs';
wireChipButton(vodBtn, {
handler: () => { void toggleAutoVodDownload(streamer); },
ariaLabel: UI_TEXT.streamers?.autoVodTitle || 'Auto-download new VODs',
pressed: isVodOn
vodBtn.addEventListener('click', (e) => {
e.stopPropagation();
void toggleAutoVodDownload(streamer);
});
// Live-record one-shot — triggers a recording immediately (server
// verifies the streamer is online before honoring the request).
// Live-record button — small red dot, only triggers a live capture
// when the streamer is currently online (server checks via Helix).
const recBtn = document.createElement('span');
recBtn.className = 'streamer-rec';
recBtn.textContent = 'REC';
recBtn.title = UI_TEXT.streamers?.recordLiveTitle || 'Record live now';
wireChipButton(recBtn, {
handler: () => { void triggerLiveRecording(streamer); },
ariaLabel: UI_TEXT.streamers?.recordLiveTitle || 'Record live now'
recBtn.addEventListener('click', (e) => {
e.stopPropagation();
void triggerLiveRecording(streamer);
});
const removeSpan = document.createElement('span');
removeSpan.className = 'remove';
removeSpan.textContent = 'x';
removeSpan.setAttribute('role', 'button');
removeSpan.setAttribute('tabindex', '0');
removeSpan.setAttribute('aria-label', UI_TEXT.streamers.removeAria);
removeSpan.addEventListener('click', (e) => {
e.stopPropagation();
void removeStreamer(streamer);
});
removeSpan.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
void removeStreamer(streamer);
}
});
item.append(nameSpan, autoBtn, vodBtn, recBtn, removeSpan);
item.addEventListener('click', () => {
@ -581,22 +461,12 @@ function renderStreamers(): void {
if (draggedStreamerName === streamer) return;
void selectStreamer(streamer);
});
item.addEventListener('keydown', (e) => {
// Activate row on Enter / Space when the row itself (not a
// chip child) is focused. The chips already preventDefault
// + stopPropagation on their own keydowns so they won't reach
// this handler.
if (e.key !== 'Enter' && e.key !== ' ') return;
if (e.target !== item) return;
e.preventDefault();
void selectStreamer(streamer);
});
list.appendChild(item);
});
// Reveal bulk-remove button only above the filter threshold.
const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null;
if (bulkBtn) bulkBtn.classList.toggle('is-hidden', all.length < STREAMER_FILTER_THRESHOLD);
if (bulkBtn) bulkBtn.style.display = all.length >= STREAMER_FILTER_THRESHOLD ? '' : 'none';
initStreamerDragDrop();
}
@ -728,7 +598,7 @@ async function removeStreamer(name: string): Promise<void> {
if (typeof hide === 'function') hide();
byId('vodGrid').innerHTML = `
<div class="empty-state">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<h3>${UI_TEXT.vods.noneTitle}</h3>
<p>${UI_TEXT.vods.noneText}</p>
</div>
@ -748,9 +618,7 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
const savedY = vodScrollPositions[name];
pendingScrollRestore = (typeof savedY === 'number' && savedY > 0) ? { streamer: name, y: savedY } : null;
renderStreamers();
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (typeof setTitle === 'function') setTitle(name);
else byId('pageTitle').textContent = name;
byId('pageTitle').textContent = name;
// Kick off the profile header load in parallel with VOD fetching.
// It's a separate request stream and not strictly needed for the VOD
@ -771,19 +639,7 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
updateStatus(UI_TEXT.status.noLogin, false);
}
// Skeleton loader — six placeholder cards while VODs come in. Much
// less jarring than a "Loading..." text block in an otherwise blank
// grid. Shimmer animation is in CSS.
byId('vodGrid').innerHTML = Array.from({ length: 6 }, () => `
<div class="vod-card vod-card-skeleton">
<div class="vod-skel-thumb"></div>
<div class="vod-info">
<div class="vod-skel-line title"></div>
<div class="vod-skel-line meta-1"></div>
<div class="vod-skel-line meta-2"></div>
</div>
</div>
`).join('');
byId('vodGrid').innerHTML = `<div class="empty-state"><p>${UI_TEXT.vods.loading}</p></div>`;
const userId = await window.api.getUserId(name);
if (isStaleRequest()) {
@ -894,22 +750,6 @@ function initVodGridSelectionDelegation(): void {
e.preventDefault();
showVodContextMenu(e.clientX, e.clientY, ctx);
});
// Enter / Space on a focused VOD card opens the VOD on Twitch — same
// outcome as a mouse click on the thumbnail. Skip when focus is on a
// child (action button, checkbox) because those have their own
// keyboard handlers (native button + checkbox semantics).
grid.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const target = e.target as HTMLElement | null;
if (!target) return;
const card = target.closest('.vod-card') as HTMLElement | null;
if (!card || card !== target) return;
const ctx = readVodCardContext(card);
if (!ctx) return;
e.preventDefault();
void window.api.openExternal(ctx.url);
});
}
let activeVodContextMenu: HTMLElement | null = null;
@ -924,8 +764,15 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
closeVodContextMenu();
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.setAttribute('role', 'menu');
menu.className = 'vod-context-menu';
menu.style.position = 'fixed';
menu.style.zIndex = '9999';
menu.style.background = 'var(--bg-card)';
menu.style.border = '1px solid var(--border-soft)';
menu.style.borderRadius = '6px';
menu.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)';
menu.style.padding = '4px';
menu.style.minWidth = '200px';
const downloadedIds = new Set(
Array.isArray(config.downloaded_vod_ids)
@ -937,8 +784,13 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
const makeItem = (label: string, onClick: () => void): HTMLElement => {
const el = document.createElement('div');
el.textContent = label;
el.className = 'context-menu-item';
el.setAttribute('role', 'menuitem');
el.style.padding = '8px 12px';
el.style.cursor = 'pointer';
el.style.fontSize = '13px';
el.style.color = 'var(--text)';
el.style.borderRadius = '4px';
el.addEventListener('mouseenter', () => { el.style.background = 'rgba(145,70,255,0.15)'; });
el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; });
el.addEventListener('click', () => {
try { onClick(); } finally { closeVodContextMenu(); }
});
@ -1018,7 +870,7 @@ function updateVodBulkBar(): void {
const bar = document.getElementById('vodBulkBar');
if (!bar) return;
const count = selectedVodUrls.size;
bar.classList.toggle('is-hidden', count === 0);
bar.style.display = count > 0 ? 'flex' : 'none';
const countEl = document.getElementById('vodBulkCount');
if (countEl) {
countEl.textContent = UI_TEXT.vods.bulkSelectedCount.replace('{count}', String(count));

View File

@ -26,12 +26,6 @@ function setText(id: string, value: string): void {
if (node) node.textContent = value;
}
function setAriaLabelAll(selector: string, value: string): void {
document.querySelectorAll(selector).forEach((el) => {
el.setAttribute('aria-label', value);
});
}
function setPlaceholder(id: string, value: string): void {
const node = document.getElementById(id) as HTMLInputElement | null;
if (node) node.placeholder = value;
@ -42,11 +36,6 @@ function setTitle(id: string, value: string): void {
if (node) node.setAttribute('title', value);
}
function setAriaLabel(id: string, value: string): void {
const node = document.getElementById(id);
if (node) node.setAttribute('aria-label', value);
}
function setLanguage(lang: string): LanguageCode {
currentLanguage = lang === 'en' ? 'en' : 'de';
UI_TEXT = UI_TEXTS[currentLanguage];
@ -67,7 +56,6 @@ function applyLanguageToStaticUI(): void {
setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn);
const archiveQueryInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
if (archiveQueryInput) archiveQueryInput.placeholder = UI_TEXT.static.archiveSearchPlaceholder;
setAriaLabel('archiveSearchQuery', UI_TEXT.static.archiveSearchAria);
const archiveTypeSelect = document.getElementById('archiveSearchType') as HTMLSelectElement | null;
if (archiveTypeSelect) {
const opts = archiveTypeSelect.options;
@ -86,8 +74,6 @@ function applyLanguageToStaticUI(): void {
}
setText('navSettingsText', UI_TEXT.static.navSettings);
setText('statsTitle', UI_TEXT.static.statsTitle);
const statsIntroEl = document.getElementById('statsIntro');
if (statsIntroEl) applyHtml(statsIntroEl, UI_TEXT.static.statsIntro);
setText('statsSummaryTitle', UI_TEXT.static.statsSummaryTitle);
setText('statsTopStreamersTitle', UI_TEXT.static.statsTopStreamersTitle);
setText('statsActivityTitle', UI_TEXT.static.statsActivityTitle);
@ -113,9 +99,6 @@ function applyLanguageToStaticUI(): void {
setText('clipDialogPartHint', UI_TEXT.clips.dialogPartHint);
setText('clipDialogFormatLabel', UI_TEXT.clips.dialogFormatLabel);
setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm);
setPlaceholder('clipUrl', UI_TEXT.clips.urlPlaceholder);
setPlaceholder('clipStartPart', UI_TEXT.clips.startPartPlaceholder);
setPlaceholder('cutterFilePath', UI_TEXT.cutter.filePathPlaceholder);
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration);
@ -186,11 +169,7 @@ function applyLanguageToStaticUI(): void {
setText('streamlinkQualityAudio', UI_TEXT.static.streamlinkQualityAudio);
setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle);
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
setAriaLabel('streamerListFilter', UI_TEXT.static.streamerListFilterAria);
setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
setAriaLabel('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
setAriaLabel('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
setTitle('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);
@ -257,20 +236,6 @@ function applyLanguageToStaticUI(): void {
setText('autoVodMaxAgeHoursLabel', UI_TEXT.static.autoVodMaxAgeHoursLabel);
setText('btnAutoVodScanNow', UI_TEXT.static.autoVodScanNow);
setText('btnAutoRecordScanNow', UI_TEXT.static.autoRecordScanNow);
// Empty-state copy for the VODs grid (when no streamer is selected
// yet) and the Merge file list (no files added yet). Both were
// hardcoded German in the HTML — English users saw German strings.
setText('vodGridEmptyTitle', UI_TEXT.vods.noneTitle);
setText('vodGridEmptyText', UI_TEXT.vods.noneText);
setText('mergeEmptyText', UI_TEXT.merge.empty);
// Localize the modal close-button aria-label. The buttons share a
// .modal-close-localizable class so one call updates all five.
setAriaLabelAll('.modal-close-localizable', UI_TEXT.streamers.modalCloseAria);
document.getElementById('cutProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.cutProgressAria);
document.getElementById('mergeProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.mergeProgressAria);
document.getElementById('updateProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.updateProgressAria);
setText('backupCardTitle', UI_TEXT.static.backupCardTitle);
setText('backupCardIntro', UI_TEXT.static.backupCardIntro);
setText('btnExportConfig', UI_TEXT.static.exportConfig);
@ -295,13 +260,8 @@ function applyLanguageToStaticUI(): void {
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
setAriaLabel('newStreamer', UI_TEXT.static.streamerAddAriaLabel);
setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder);
setAriaLabel('vodFilterInput', UI_TEXT.vods.filterAria);
setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
setAriaLabel('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
setPlaceholder('chatViewerFilter', UI_TEXT.queue.chatViewerFilterPlaceholder);
setAriaLabel('chatViewerFilter', UI_TEXT.queue.chatViewerFilterAria);
setText('vodSortLabel', UI_TEXT.vods.sortLabel);
if (typeof refreshVodSortSelectLabels === 'function') {
refreshVodSortSelectLabels();

View File

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

View File

@ -1,162 +0,0 @@
// VOD hover preview. When the user mouses over a VOD card, we lazy-fetch
// the channel's seek-preview storyboard sprite for that VOD and cycle
// through 4 evenly-spaced cells to produce a scrub-preview animation —
// the same UX twitch.tv ships on its VOD browsing pages.
//
// The storyboard fetch goes through the main process (axios via Node's
// http client) so the renderer never has to make its own HTTPS request
// to the Twitch CDN, sidestepping the same set of Electron renderer
// image-loading quirks the avatar code hit.
interface ActiveHover {
vodId: string;
intervalId: number;
overlay: HTMLElement;
}
const vodStoryboardClientCache = new Map<string, VodStoryboard | null>();
let activeHover: ActiveHover | null = null;
let pendingHoverVodId: string | null = null;
const HOVER_DEBOUNCE_MS = 220;
const FRAME_INTERVAL_MS = 600;
const FRAMES_TO_CYCLE = 4;
// Bounded cache — each storyboard data URL is ~50-200 KB, so an
// unbounded cache could balloon to hundreds of MB on a long browsing
// session through a streamer with thousands of VODs. FIFO eviction
// keeps the working set fresh without manual cleanup.
const MAX_CLIENT_STORYBOARD_CACHE = 100;
function rememberStoryboard(vodId: string, sb: VodStoryboard | null): void {
vodStoryboardClientCache.set(vodId, sb);
if (vodStoryboardClientCache.size > MAX_CLIENT_STORYBOARD_CACHE) {
// Map iterator is insertion-ordered — first key is the oldest.
const oldestKey = vodStoryboardClientCache.keys().next().value as string | undefined;
if (oldestKey !== undefined) vodStoryboardClientCache.delete(oldestKey);
}
}
function ensureVodHoverHandlersBound(): void {
const grid = document.getElementById('vodGrid');
if (!grid || grid.dataset.hoverBound === '1') return;
grid.dataset.hoverBound = '1';
// Delegated mouseover/mouseout on the grid — re-renders of the
// grid replace the card DOM but the grid root persists, so the
// listener stays bound across streamer switches.
grid.addEventListener('mouseover', (e) => {
const target = e.target as HTMLElement | null;
const card = target?.closest('.vod-card') as HTMLElement | null;
if (!card) return;
const vodId = card.dataset.vodId;
if (!vodId) return;
scheduleHoverPreview(card, vodId);
});
grid.addEventListener('mouseout', (e) => {
const target = e.target as HTMLElement | null;
const card = target?.closest('.vod-card') as HTMLElement | null;
if (!card) return;
// Only clear when leaving the card entirely (not just moving
// within it between child elements).
const related = e.relatedTarget as HTMLElement | null;
if (related && card.contains(related)) return;
clearHoverPreview();
});
}
function scheduleHoverPreview(card: HTMLElement, vodId: string): void {
if (pendingHoverVodId === vodId) return;
pendingHoverVodId = vodId;
// Debounce so rapid mouse passes (scrolling, dragging across cards)
// don't trigger a download for every card brushed.
window.setTimeout(() => {
if (pendingHoverVodId !== vodId) return;
void activateHoverPreview(card, vodId);
}, HOVER_DEBOUNCE_MS);
}
function clearHoverPreview(): void {
pendingHoverVodId = null;
if (!activeHover) return;
window.clearInterval(activeHover.intervalId);
const card = activeHover.overlay.parentElement;
if (card) card.classList.remove('preview-active');
// Brief opacity fade-out, then remove from DOM.
activeHover.overlay.style.opacity = '0';
const overlayToRemove = activeHover.overlay;
window.setTimeout(() => { try { overlayToRemove.remove(); } catch { /* gone */ } }, 220);
activeHover = null;
}
async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<void> {
// Stale-guard: user might have moved off the card in the debounce window.
if (pendingHoverVodId !== vodId) return;
let storyboard: VodStoryboard | null | undefined = vodStoryboardClientCache.get(vodId);
if (storyboard === undefined) {
try {
storyboard = await window.api.getVodStoryboard(vodId);
} catch (_) {
storyboard = null;
}
rememberStoryboard(vodId, storyboard);
}
// Cursor may have moved on while we awaited; re-check guard.
if (pendingHoverVodId !== vodId) return;
if (!storyboard) return;
clearHoverPreview();
// Pick FRAMES_TO_CYCLE evenly-spaced cells from the first sprite —
// distributes the chosen preview frames across the early/mid portion
// of the VOD. For very short VODs the first sprite is the only one,
// so this still gives a representative spread.
const totalCells = Math.min(storyboard.framesInSprite, storyboard.cols * storyboard.rows);
const stride = Math.max(1, Math.floor(totalCells / FRAMES_TO_CYCLE));
const cellsToShow: Array<{ col: number; row: number }> = [];
for (let i = 0; i < FRAMES_TO_CYCLE; i++) {
const idx = Math.min(totalCells - 1, i * stride);
const col = idx % storyboard.cols;
const row = Math.floor(idx / storyboard.cols);
cellsToShow.push({ col, row });
}
const overlay = document.createElement('div');
overlay.className = 'vod-storyboard-preview';
// Scale the sprite so a single cell exactly fills the card width.
// The thumbnail aspect-ratio (16:9) matches typical cell aspect
// (e.g. 220x124 ≈ 1.77) so width-stretch keeps proportions.
const cardWidth = card.getBoundingClientRect().width;
const cellAspect = storyboard.cellWidth / storyboard.cellHeight;
const scale = cardWidth / storyboard.cellWidth;
overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`;
overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scale}px ${storyboard.rows * storyboard.cellHeight * scale}px`;
overlay.style.height = `${cardWidth / cellAspect}px`;
// Initial position = first chosen cell.
const first = cellsToShow[0];
overlay.style.backgroundPosition = `-${first.col * storyboard.cellWidth * scale}px -${first.row * storyboard.cellHeight * scale}px`;
card.appendChild(overlay);
// Trigger CSS transition to opacity:1 on the next frame.
requestAnimationFrame(() => { card.classList.add('preview-active'); });
let frameIdx = 1;
const intervalId = window.setInterval(() => {
const cell = cellsToShow[frameIdx % cellsToShow.length];
overlay.style.backgroundPosition = `-${cell.col * storyboard.cellWidth * scale}px -${cell.row * storyboard.cellHeight * scale}px`;
frameIdx++;
}, FRAME_INTERVAL_MS);
activeHover = { vodId, intervalId, overlay };
}
(window as unknown as { ensureVodHoverHandlersBound: typeof ensureVodHoverHandlersBound }).ensureVodHoverHandlersBound = ensureVodHoverHandlersBound;
// Bind once the grid exists. Tab switches don't re-create the grid, so
// one-time binding via DOMContentLoaded is enough.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => { ensureVodHoverHandlersBound(); });
} else {
ensureVodHoverHandlersBound();
}

View File

@ -20,7 +20,6 @@ async function init(): Promise<void> {
byId('versionText').textContent = `v${version}`;
byId('versionInfo').textContent = `Version: v${version}`;
appVersion = version;
document.title = `${UI_TEXT.appName} v${version}`;
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
@ -43,30 +42,6 @@ async function init(): Promise<void> {
changeTheme(config.theme ?? 'twitch');
renderStreamers();
renderQueue();
// Keyboard activation for nav-items (Enter / Space). The items are
// div[role="button"][tabindex="0"], so browsers won't synthesise a
// click on Enter/Space natively — we wire it here once via event
// delegation so the listener doesn't need re-binding per tab switch.
const nav = document.querySelector('.nav');
if (nav && !nav.hasAttribute('data-keynav-bound')) {
nav.setAttribute('data-keynav-bound', '1');
nav.addEventListener('keydown', (event) => {
const ev = event as KeyboardEvent;
if (ev.key !== 'Enter' && ev.key !== ' ') return;
const target = ev.target as HTMLElement | null;
const item = target?.closest('.nav-item') as HTMLElement | null;
if (!item) return;
const tab = item.dataset.tab;
if (!tab) return;
ev.preventDefault();
showTab(tab);
});
}
// Kick off live-status subscription so the sidebar dots populate.
const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise<void> }).initLiveStatusSubscription;
if (typeof liveStatusInit === 'function') void liveStatusInit();
initQueueDragDrop();
updateDownloadButtonState();
updateStatusBarQueueSummary();
@ -169,17 +144,13 @@ async function init(): Promise<void> {
});
window.api.onCutProgress((percent: number) => {
const rounded = Math.round(percent);
byId('cutProgressBar').style.width = percent + '%';
byId('cutProgressText').textContent = rounded + '%';
byId('cutProgressGauge').setAttribute('aria-valuenow', String(rounded));
byId('cutProgressText').textContent = Math.round(percent) + '%';
});
window.api.onMergeProgress((percent: number) => {
const rounded = Math.round(percent);
byId('mergeProgressBar').style.width = percent + '%';
byId('mergeProgressText').textContent = rounded + '%';
byId('mergeProgressGauge').setAttribute('aria-valuenow', String(rounded));
byId('mergeProgressText').textContent = Math.round(percent) + '%';
});
// Update stats bar — paused while the window is hidden so we don't
@ -237,31 +208,21 @@ async function init(): Promise<void> {
// Ctrl+F (or Cmd+F): focus the VOD filter — only when on the VODs tab.
// Browser's default Ctrl+F is suppressed because Electron's renderer
// doesn't have a native find bar anyway. Route the shortcut to the
// active tab's search/filter input so the user lands in a useful
// place regardless of which tab they happen to be on.
// doesn't have a native find bar anyway.
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'f' || e.key === 'F')) {
if (document.getElementById('vodsTab')?.classList.contains('active')) {
const onVodsTab = document.getElementById('vodsTab')?.classList.contains('active');
if (onVodsTab) {
e.preventDefault();
focusVodFilter();
return;
}
if (document.getElementById('archiveTab')?.classList.contains('active')) {
const archiveInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
if (archiveInput) {
e.preventDefault();
archiveInput.focus();
archiveInput.select();
return;
}
}
}
// Skip rest if user is typing in an input field
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return;
// Ctrl+1..7 jumps directly to a tab (Cmd on macOS via metaKey)
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key >= '1' && e.key <= '7') {
// Ctrl+1..5 jumps directly to a tab (Cmd on macOS via metaKey)
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key >= '1' && e.key <= '5') {
const tabIndex = parseInt(e.key, 10) - 1;
if (tabIndex >= 0 && tabIndex < TAB_IDS.length) {
e.preventDefault();
@ -345,7 +306,9 @@ function renderEventsList(events: EventLogEntry[]): void {
list.replaceChildren();
if (events.length === 0) {
const empty = document.createElement('div');
empty.className = 'event-viewer-empty';
empty.style.color = 'var(--text-secondary)';
empty.style.padding = '12px';
empty.style.textAlign = 'center';
empty.textContent = UI_TEXT.queue.viewEventsEmpty;
list.appendChild(empty);
return;
@ -353,24 +316,33 @@ function renderEventsList(events: EventLogEntry[]): void {
for (const ev of events) {
const row = document.createElement('div');
row.className = 'event-viewer-row';
row.style.padding = '8px 10px';
row.style.borderBottom = '1px solid var(--border-soft)';
row.style.fontSize = '12px';
const time = document.createElement('span');
time.className = 'event-viewer-time';
time.style.color = 'var(--text-secondary)';
time.style.marginRight = '8px';
time.textContent = formatEventTime(ev.t);
row.appendChild(time);
const tag = document.createElement('span');
tag.className = 'event-viewer-tag';
// Per-type tag colour comes from CSS via a data-type attribute
// selector — keeps the type->colour mapping with the rest of the
// visual styling instead of inline in the renderer.
if (ev.type) tag.dataset.type = ev.type;
tag.style.fontWeight = '600';
tag.style.marginRight = '8px';
const tagColors: Record<string, string> = {
recording_start: '#00c853',
recording_end: '#9146ff',
recording_resume: '#2196f3',
title_change: '#ffab00',
game_change: '#ff4444'
};
tag.style.color = tagColors[ev.type || ''] || 'var(--accent)';
tag.textContent = ev.type || 'event';
row.appendChild(tag);
const detail = document.createElement('div');
detail.className = 'event-viewer-detail';
detail.style.marginTop = '4px';
detail.style.color = 'var(--text)';
if (ev.type === 'recording_start') {
detail.textContent = `${UI_TEXT.queue.eventStartedAs}: "${ev.title || '-'}" — ${ev.game || '-'}`;
@ -488,41 +460,44 @@ function renderChatViewerList(messages: ChatViewerMessage[]): void {
const end = Math.min(idx + CHUNK, messages.length);
for (let i = idx; i < end; i++) {
const m = messages[i];
const isMessageType = m.type === 'msg' || !m.type;
const row = document.createElement('div');
row.className = 'chat-viewer-row' + (!isMessageType ? ' is-system' : '');
// System events (subs, raids, deletions) lead with a faint tag.
if (!isMessageType) {
const tag = document.createElement('span');
tag.className = 'chat-viewer-tag';
tag.textContent = m.type || 'event';
row.appendChild(tag);
}
row.style.padding = '2px 0';
row.style.lineHeight = '1.5';
const time = formatChatTimeMarker(m);
if (time) {
const tSpan = document.createElement('span');
tSpan.className = 'chat-viewer-time';
tSpan.textContent = time;
tSpan.style.color = 'var(--text-secondary)';
tSpan.style.marginRight = '6px';
tSpan.textContent = `[${time}]`;
row.appendChild(tSpan);
}
const user = m.u || m.user || m.login || '';
if (user) {
const uSpan = document.createElement('span');
uSpan.className = 'chat-viewer-user';
// Per-user IRC color overrides the default accent colour
// supplied by .chat-viewer-user; the class also sets weight.
if (m.color) uSpan.style.color = m.color;
uSpan.style.fontWeight = '600';
uSpan.style.color = m.color || 'var(--accent)';
uSpan.style.marginRight = '4px';
uSpan.textContent = `${user}:`;
row.appendChild(uSpan);
}
const msgSpan = document.createElement('span');
msgSpan.textContent = ' ' + (m.msg || m.text || '');
msgSpan.textContent = m.msg || m.text || '';
row.appendChild(msgSpan);
// System events (subs, raids, deletions) get a faint bracketed prefix
const isMessageType = m.type === 'msg' || !m.type;
if (!isMessageType) {
const tag = document.createElement('span');
tag.style.color = 'var(--text-secondary)';
tag.style.fontStyle = 'italic';
tag.style.marginRight = '4px';
tag.textContent = `[${m.type}]`;
row.insertBefore(tag, row.firstChild);
}
fragment.appendChild(row);
}
list.appendChild(fragment);
@ -635,24 +610,6 @@ async function updateStatsBar(): Promise<void> {
let toastHideTimer: number | null = null;
let queueSyncTimer: number | null = null;
let appVersion = '';
// Single source of truth for what the user is looking at — keeps the
// visible H1, the document title (which drives the OS task bar / Alt+Tab
// label), and the app version pill in sync. Previously document.title was
// stamped once at boot, so the OS task bar always read "Twitch VOD
// Manager v4.6.76" no matter what tab or streamer was active.
(window as unknown as { setPageTitle: (text: string) => void }).setPageTitle = setPageTitle;
function setPageTitle(text: string): void {
const titleEl = document.getElementById('pageTitle');
if (titleEl) titleEl.textContent = text;
const appName = UI_TEXT.appName;
const versionSuffix = appVersion ? ` v${appVersion}` : '';
document.title = text && text !== appName
? `${text} - ${appName}${versionSuffix}`
: `${appName}${versionSuffix}`;
}
let queueSyncInFlight = false;
let lastQueueActivityAt = Date.now();
@ -716,28 +673,14 @@ function showAppToast(message: string, type: 'info' | 'warn' = 'info'): void {
toast = document.createElement('div');
toast.id = 'appToast';
toast.className = 'app-toast';
// Live region — screen readers announce the toast text whenever
// it changes. Warn toasts go through aria-live="assertive" so the
// reader interrupts whatever it was speaking; info toasts use
// "polite" so they wait for a natural break in current speech.
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.setAttribute('aria-atomic', 'true');
document.body.appendChild(toast);
}
toast.textContent = message;
toast.classList.remove('warn', 'show');
if (type === 'warn') {
toast.classList.add('warn');
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
} else {
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
}
// Setting textContent AFTER the aria-live attribute is in place
// ensures the change is captured as a live-region update by AT.
toast.textContent = message;
requestAnimationFrame(() => {
toast?.classList.add('show');
@ -821,12 +764,7 @@ async function syncQueueAndDownloadState(): Promise<void> {
}
}
// Must include every nav-item from index.html — otherwise:
// - Ctrl+N keyboard shortcut won't reach tabs past index 4
// - persistActiveTab silently no-ops, so the tab won't restore on reboot
// 'stats' (4.6.14) and 'archive' (4.6.15) were added to the nav but the
// const was never updated, leaving them effectively second-class tabs.
const TAB_IDS = ['vods', 'clips', 'cutter', 'merge', 'stats', 'archive', 'settings'] as const;
const TAB_IDS = ['vods', 'clips', 'cutter', 'merge', 'settings'] as const;
const ACTIVE_TAB_STORAGE_KEY = 'twitch-vod-manager:active-tab';
function isKnownTab(value: string): value is typeof TAB_IDS[number] {
@ -834,21 +772,20 @@ function isKnownTab(value: string): value is typeof TAB_IDS[number] {
}
function loadPersistedActiveTab(): string {
const stored = safeLocalStorageGet(ACTIVE_TAB_STORAGE_KEY);
try {
const stored = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY);
if (stored && isKnownTab(stored)) return stored;
} catch { /* localStorage may be unavailable in privacy modes */ }
return 'vods';
}
function persistActiveTab(tab: string): void {
if (!isKnownTab(tab)) return;
safeLocalStorageSet(ACTIVE_TAB_STORAGE_KEY, tab);
try { localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, tab); } catch { }
}
function showTab(tab: string): void {
queryAll('.nav-item').forEach((i) => {
i.classList.remove('active');
i.removeAttribute('aria-current');
});
queryAll('.nav-item').forEach((i) => i.classList.remove('active'));
queryAll('.tab-content').forEach((c) => c.classList.remove('active'));
const navItem = query(`.nav-item[data-tab="${tab}"]`);
@ -858,17 +795,15 @@ function showTab(tab: string): void {
return;
}
navItem.classList.add('active');
navItem.setAttribute('aria-current', 'page');
byId(tab + 'Tab').classList.add('active');
const titles: Record<string, string> = UI_TEXT.tabs;
// Only show the streamer name on the VODs tab — otherwise the title would
// mismatch the tab content (e.g. "streamer X" while on Settings)
const pageTitleText = (tab === 'vods' && currentStreamer)
byId('pageTitle').textContent = (tab === 'vods' && currentStreamer)
? currentStreamer
: (titles[tab] || UI_TEXT.appName);
setPageTitle(pageTitleText);
persistActiveTab(tab);
@ -975,7 +910,7 @@ function getSelectedFilenameFormat(): 'simple' | 'timestamp' | 'template' | 'par
function updateFilenameTemplateVisibility(): void {
const selected = getSelectedFilenameFormat();
const wrap = byId('clipFilenameTemplateWrap');
wrap.classList.toggle('shown', selected === 'template');
wrap.style.display = selected === 'template' ? 'block' : 'none';
}
interface TemplatePreviewContext {
@ -1288,11 +1223,13 @@ function updateClipDuration(): void {
const duration = endSec - startSec;
const durationDisplay = byId('clipDurationDisplay');
const isValid = duration > 0;
durationDisplay.classList.toggle('invalid', !isValid);
durationDisplay.textContent = isValid
? formatSecondsToTime(duration)
: UI_TEXT.clips.invalidDuration;
if (duration > 0) {
durationDisplay.textContent = formatSecondsToTime(duration);
durationDisplay.style.color = '#00c853';
} else {
durationDisplay.textContent = UI_TEXT.clips.invalidDuration;
durationDisplay.style.color = '#ff4444';
}
updateFilenameExamples();
}
@ -1316,10 +1253,10 @@ function updateFilenameExamples(): void {
updateFilenameTemplateVisibility();
if (!unknownTokens.length) {
clipLint.className = 'template-lint ok';
clipLint.style.color = '#8bc34a';
clipLint.textContent = UI_TEXT.static.templateLintOk;
} else {
clipLint.className = 'template-lint warn';
clipLint.style.color = '#ff8a80';
clipLint.textContent = `${UI_TEXT.static.templateLintWarn}: ${unknownTokens.join(' ')}`;
}
@ -1356,7 +1293,7 @@ async function confirmClipDialog(): Promise<void> {
const filenameTemplate = byId<HTMLInputElement>('clipFilenameTemplate').value.trim();
if (isNaN(startSec) || isNaN(endSec) || isNaN(durationSec)) {
alert(UI_TEXT.clips.invalidTime);
alert('Invalid time values');
return;
}
@ -1469,8 +1406,8 @@ async function loadCutterFromPath(filePath: string): Promise<void> {
cutterStartTime = 0;
cutterEndTime = info.duration;
byId('cutterInfo').classList.add('shown');
byId('timelineContainer').classList.add('shown');
byId('cutterInfo').style.display = 'flex';
byId('timelineContainer').style.display = 'block';
byId('btnCut').disabled = false;
byId('infoDuration').textContent = formatTime(info.duration);
@ -1557,15 +1494,15 @@ async function updatePreview(time: number): Promise<void> {
}
const preview = byId('cutterPreview');
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewLoading)}</p></div>`);
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewLoading}</p></div>`;
const frame = await window.api.extractFrame(cutterFile, time);
if (frame) {
applyHtml(preview, `<img src="${escapeHtml(frame)}" alt="${escapeHtml(UI_TEXT.cutter.previewAlt)}">`);
preview.innerHTML = `<img src="${frame}" alt="Preview">`;
return;
}
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewUnavailable)}</p></div>`);
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewUnavailable}</p></div>`;
}
async function startCutting(): Promise<void> {
@ -1608,23 +1545,12 @@ function renderMergeFiles(): void {
byId('btnMerge').disabled = mergeFiles.length < 2;
if (mergeFiles.length === 0) {
// Build via DOM API to keep the renderer clean of inline-styled
// HTML strings. The empty-state SVG is the same plus-icon the
// static HTML uses, just built programmatically.
list.replaceChildren();
const wrap = document.createElement('div');
wrap.className = 'empty-state merge-empty-state';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'currentColor');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z');
svg.appendChild(path);
wrap.appendChild(svg);
const p = document.createElement('p');
p.textContent = UI_TEXT.merge.empty;
wrap.appendChild(p);
list.appendChild(wrap);
list.innerHTML = `
<div class="empty-state" style="padding: 40px 20px;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<p style="margin-top:10px">${UI_TEXT.merge.empty}</p>
</div>
`;
return;
}
@ -1635,9 +1561,9 @@ function renderMergeFiles(): void {
<div class="file-order">${index + 1}</div>
<div class="file-name" title="${file}">${name}</div>
<div class="file-actions">
<button type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveUpAria)}" title="${escapeHtml(UI_TEXT.merge.moveUpAria)}" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>&#9650;</button>
<button type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveDownAria)}" title="${escapeHtml(UI_TEXT.merge.moveDownAria)}" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>&#9660;</button>
<button type="button" class="file-btn remove" aria-label="${escapeHtml(UI_TEXT.merge.removeAria)}" title="${escapeHtml(UI_TEXT.merge.removeAria)}" onclick="removeMergeFile(${index})">x</button>
<button class="file-btn" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>&#9650;</button>
<button class="file-btn" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>&#9660;</button>
<button class="file-btn remove" onclick="removeMergeFile(${index})">x</button>
</div>
</div>
`;

File diff suppressed because it is too large Load Diff