release: publish Twitch VOD Manager 1.0.3

Polish navigation, settings, streamer and queue workflows; add safe pause and partial-file lifecycle handling; apply the product identity across Windows surfaces; and replace the public README with a complete English product guide and isolated screenshot.
This commit is contained in:
Sucukdeluxe
2026-08-10 19:25:08 +02:00
parent f9e415da88
commit b3c2a8b9fd
48 changed files with 4035 additions and 826 deletions
+200 -214
View File
@@ -13,8 +13,8 @@
<div class="modal update-modal">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button>
<div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div>
<h2 id="updateModalTitle">Update verfugbar</h2>
<p class="update-modal-message" id="updateModalMessage">Version 0.0.0 ist verfugbar. Jetzt herunterladen?</p>
<h2 id="updateModalTitle">Update verfügbar</h2>
<p class="update-modal-message" id="updateModalMessage">Version 0.0.0 ist verfügbar. Jetzt herunterladen?</p>
<div class="update-modal-meta is-hidden" id="updateModalMeta"></div>
<div class="update-changelog-card is-hidden" id="updateChangelogCard">
@@ -24,13 +24,13 @@
</div>
<div class="update-changelog-panel" id="updateChangelogPanel" hidden>
<div class="update-changelog-content" id="updateChangelogContent"></div>
<p class="update-changelog-empty" id="updateChangelogEmpty" hidden>Kein Changelog verfugbar.</p>
<p class="update-changelog-empty" id="updateChangelogEmpty" hidden>Kein Changelog verfügbar.</p>
</div>
</div>
<div class="modal-actions update-modal-actions">
<button class="btn-secondary" id="updateModalDismissBtn" type="button" onclick="dismissUpdateModal()">Nein</button>
<button class="btn-secondary" id="updateModalSkipBtn" type="button" onclick="skipUpdateVersion()">Diese Version ueberspringen</button>
<button class="btn-secondary" id="updateModalSkipBtn" type="button" onclick="skipUpdateVersion()">Diese Version überspringen</button>
<button class="btn-primary" id="updateModalConfirmBtn" type="button" onclick="confirmUpdateModal()">Ja, herunterladen</button>
</div>
</div>
@@ -66,7 +66,7 @@
</div>
<div class="clip-modal-field">
<label class="clip-modal-label" id="clipDialogPartLabel" for="clipStartPart">Start Part-Nummer (optional, fur Fortsetzung):</label>
<label class="clip-modal-label" id="clipDialogPartLabel" for="clipStartPart">Start Part-Nummer (optional, für 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>
</div>
@@ -99,7 +99,7 @@
</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 type="button" class="btn-pill success" id="clipDialogConfirmBtn" style="padding: 12px 30px;" onclick="confirmClipDialog()">Zur Queue hinzufügen</button>
</div>
</div>
</div>
@@ -132,7 +132,7 @@
<div class="modal template-guide-modal">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button>
<h2 id="templateGuideTitle">Template Guide</h2>
<p id="templateGuideIntro" class="template-guide-intro">Nutze Variablen fur Dateinamen und prufe das Ergebnis als Live-Vorschau.</p>
<p id="templateGuideIntro" class="template-guide-intro">Nutze Variablen für Dateinamen und prüfe 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>
@@ -149,7 +149,7 @@
<div id="templateGuideContext" class="template-guide-context"></div>
</div>
<h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfugbare Variablen</h3>
<h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfügbare Variablen</h3>
<div class="template-guide-table-wrap">
<table class="template-guide-table" aria-labelledby="templateGuideVarsTitle">
<thead>
@@ -164,7 +164,7 @@
</div>
<div class="template-guide-footer">
<button type="button" class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button>
<button type="button" class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schließen</button>
</div>
</div>
</div>
@@ -172,11 +172,7 @@
<header class="app-topbar">
<div class="topbar-navigation-cluster">
<div class="topbar-brand">
<svg class="topbar-brand-mark" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2.5" y="3.5" width="19" height="15" rx="3"></rect>
<path d="M9.5 8.25 15 11l-5.5 2.75z"></path>
<path d="M7 18.5V21l3.5-2.5"></path>
</svg>
<img class="topbar-brand-mark" src="../build/icon.png" alt="" aria-hidden="true">
<span id="logoText">Twitch VOD Manager</span>
</div>
<nav class="nav top-nav" aria-label="Main navigation">
@@ -192,9 +188,9 @@
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.6 8.5 11 7M8.6 15.5 20 8"></path></svg>
<span class="top-nav-label" id="navCutterText">Video schneiden</span>
</button>
<button class="nav-item top-nav-item" type="button" data-tab="merge" onclick="showTab('merge')" title="Videos zusammenfugen">
<button class="nav-item top-nav-item" type="button" data-tab="merge" onclick="showTab('merge')" title="Videos zusammenfügen">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4"></path><path d="m16 16 3 3-3 3M5 19h4a3 3 0 0 0 3-3V8a3 3 0 0 1 3-3h4"></path><path d="m16 2 3 3-3 3"></path></svg>
<span class="top-nav-label" id="navMergeText">Videos zusammenfugen</span>
<span class="top-nav-label" id="navMergeText">Videos zusammenfügen</span>
</button>
<button class="nav-item top-nav-item" type="button" data-tab="stats" onclick="showTab('stats')" title="Statistik">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V9M10 19V5M16 19v-7M22 19V3"></path></svg>
@@ -211,7 +207,7 @@
</nav>
</div>
<div class="topbar-actions">
<div class="update-banner workspace-update" id="updateBanner" data-update-state="idle">
<div class="update-banner workspace-update" id="updateBanner" data-update-state="idle" hidden>
<button type="button" class="workspace-update-button" id="workspaceUpdateButton" onclick="handleWorkspaceUpdateAction()" aria-describedby="updateText" aria-controls="workspaceUpdatePopover" aria-expanded="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"></path><path d="m8 11 4 4 4-4"></path><path d="M4 19h16"></path></svg>
<span id="workspaceUpdateLabel">Update</span>
@@ -235,33 +231,28 @@
</div>
</div>
</div>
<button type="button" class="topbar-icon-button" id="systemStatusButton" onclick="showTab('settings')" aria-label="System status" title="System status">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l2-5 4 10 2-5h6"></path></svg>
</button>
<button type="button" class="topbar-account-button" id="openSettingsButton" onclick="showTab('settings')" aria-label="Open settings" title="Open settings">
<span>TV</span>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 10 4 4 4-4"></path></svg>
</button>
</div>
</header>
<div class="app workspace-shell">
<aside class="sidebar context-sidebar" aria-label="Workspace context">
<section class="context-panel" data-context-for="vods">
<section class="context-panel" data-context-for="vods" data-vods-workspace="streamers" data-vods-layout="split">
<div class="context-switcher" role="group" aria-label="VOD workspace">
<button type="button" id="streamerWorkspaceSwitch" class="active" aria-pressed="true" onclick="focusWorkspaceTarget('streamerList', this)">Streamer</button>
<button type="button" id="queueWorkspaceSwitch" aria-pressed="false" onclick="focusWorkspaceTarget('queueList', this)">Warteschlange</button>
<button type="button" id="streamerWorkspaceSwitch" class="active" aria-pressed="true" onclick="setVodsWorkspace('streamers', this)">Streamer</button>
<button type="button" id="queueWorkspaceSwitch" aria-pressed="false" onclick="setVodsWorkspace('queue', this)">Warteschlange</button>
</div>
<div class="section-title" id="streamerSectionTitle">
<span class="section-title-label">
<span id="streamerSectionTitleText">Streamer</span>
<div class="streamer-section">
<div class="section-title" id="streamerSectionTitle">
<span class="section-title-label">
<span id="streamerSectionTitleText">Streamer</span>
</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" class="btn-close is-hidden" type="button" onclick="bulkRemoveStreamers()" title="Bulk remove">x</button>
</div>
<input type="text" id="streamerListFilter" class="filter-input compact is-hidden" placeholder="Filter..." oninput="onStreamerListFilterChange()">
<div class="streamers" id="streamerList"></div>
</div>
<input type="text" id="streamerListFilter" class="filter-input compact is-hidden" placeholder="Filter..." oninput="onStreamerListFilterChange()">
<div class="streamers" id="streamerList"></div>
<div class="queue-section">
<div class="queue-header">
@@ -290,28 +281,28 @@
<section class="context-panel" data-context-for="cutter" hidden>
<div class="context-panel-heading" data-context-heading>Video schneiden</div>
<nav class="context-list" aria-label="Cutter sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('cutterBrowseBtn', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterSelectTitle">Video auswahlen</span></button>
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('cutterBrowseBtn', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterSelectTitle">Video auswählen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('timelineContainer', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 12h16M7 8v8M17 8v8"></path></svg><span data-label-source="cutterInfoSelectionLabel">Auswahl</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('btnCut', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg><span data-label-source="btnCut">Schneiden</span></button>
</nav>
</section>
<section class="context-panel" data-context-for="merge" hidden>
<div class="context-panel-heading" data-context-heading>Zusammenfugen</div>
<div class="context-panel-heading" data-context-heading>Zusammenfügen</div>
<nav class="context-list" aria-label="Merge sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('mergeAddBtn', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h16v12H4zM12 9v6M9 12h6"></path></svg><span data-label-source="mergeAddBtn">Videos hinzufugen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('mergeFileList', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 6h14M5 12h14M5 18h14"></path></svg><span data-label-source="mergeTitle">Videos zusammenfugen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('btnMerge', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4M16 16l3 3-3 3"></path></svg><span data-label-source="btnMerge">Zusammenfugen</span></button>
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('mergeAddBtn', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h16v12H4zM12 9v6M9 12h6"></path></svg><span data-label-source="mergeAddBtn">Videos hinzufügen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('mergeFileList', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 6h14M5 12h14M5 18h14"></path></svg><span data-label-source="mergeTitle">Videos zusammenfügen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('btnMerge', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4M16 16l3 3-3 3"></path></svg><span data-label-source="btnMerge">Zusammenfügen</span></button>
</nav>
</section>
<section class="context-panel" data-context-for="stats" hidden>
<div class="context-panel-heading" data-context-heading>Statistik</div>
<nav class="context-list" aria-label="Statistics sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('statsSummaryTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 19V9M10 19V5M16 19v-7M22 19V3"></path></svg><span data-label-source="statsSummaryTitle">Uebersicht</span></button>
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('statsSummaryTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 19V9M10 19V5M16 19v-7M22 19V3"></path></svg><span data-label-source="statsSummaryTitle">Übersicht</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsTopStreamersTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h8"></path></svg><span data-label-source="statsTopStreamersTitle">Top Streamer</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsActivityTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m3 17 5-5 4 3 7-8 2 2"></path></svg><span data-label-source="statsActivityTitle">Aktivitaet</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsSizeBucketsTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle><path d="M12 3v9h9"></path></svg><span data-label-source="statsSizeBucketsTitle">Groessen-Verteilung</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsActivityTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m3 17 5-5 4 3 7-8 2 2"></path></svg><span data-label-source="statsActivityTitle">Aktivität</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsSizeBucketsTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle><path d="M12 3v9h9"></path></svg><span data-label-source="statsSizeBucketsTitle">Größen-Verteilung</span></button>
</nav>
</section>
@@ -327,13 +318,17 @@
<section class="context-panel" data-context-for="settings" hidden>
<div class="context-panel-heading" data-context-heading>Einstellungen</div>
<nav class="context-list" aria-label="Settings sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('designTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"></path></svg><span data-label-source="designTitle">Design</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('apiTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M8 12h8M12 8v8"></path><circle cx="12" cy="12" r="9"></circle></svg><span data-label-source="apiTitle">Twitch API</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('downloadSettingsTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 20h16"></path></svg><span data-label-source="downloadSettingsTitle">Downloads</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('updateTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 19h16"></path></svg><span data-label-source="updateTitle">Updates</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('preflightTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3 4 7v5c0 5 3.4 8 8 9 4.6-1 8-4 8-9V7z"></path><path d="m9 12 2 2 4-4"></path></svg><span data-label-source="preflightTitle">System-Check</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('debugLogTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h14v14H5zM8 9l2 2-2 2M12 15h4"></path></svg><span data-label-source="debugLogTitle">Debug-Log</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('storageCardTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><ellipse cx="12" cy="6" rx="8" ry="3"></ellipse><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"></path></svg><span data-label-source="storageCardTitle">Storage</span></button>
<button type="button" class="context-link active" data-settings-pane="design" onclick="setSettingsPane('design', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"></path></svg><span data-label-source="designTitle">Design</span></button>
<button type="button" class="context-link" data-settings-pane="api" onclick="setSettingsPane('api', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M8 12h8M12 8v8"></path><circle cx="12" cy="12" r="9"></circle></svg><span data-label-source="apiTitle">Twitch API</span></button>
<button type="button" class="context-link" data-settings-pane="downloads" onclick="setSettingsPane('downloads', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 20h16"></path></svg><span data-label-source="downloadSettingsTitle">Downloads</span></button>
<button type="button" class="context-link" data-settings-pane="automation" onclick="setSettingsPane('automation', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 0 2.3-5.7M4 4v6h6"></path></svg><span data-label-source="autoVodCardTitle">Automatisierung</span></button>
<button type="button" class="context-link" data-settings-pane="notifications" onclick="setSettingsPane('notifications', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"></path></svg><span data-label-source="discordCardTitle">Benachrichtigungen</span></button>
<button type="button" class="context-link" data-settings-pane="storage" onclick="setSettingsPane('storage', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><ellipse cx="12" cy="6" rx="8" ry="3"></ellipse><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"></path></svg><span data-label-source="storageCardTitle">Storage</span></button>
<button type="button" class="context-link" data-settings-pane="maintenance" onclick="setSettingsPane('maintenance', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 7h16v13H4zM8 7V4h8v3M9 12h6"></path></svg><span data-label-source="backupCardTitle">Sicherung &amp; Wartung</span></button>
<button type="button" class="context-link" data-settings-pane="updates" onclick="setSettingsPane('updates', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 19h16"></path></svg><span data-label-source="updateTitle">Updates</span></button>
<button type="button" class="context-link" data-settings-pane="system" onclick="setSettingsPane('system', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3 4 7v5c0 5 3.4 8 8 9 4.6-1 8-4 8-9V7z"></path><path d="m9 12 2 2 4-4"></path></svg><span data-label-source="preflightTitle">System-Check</span></button>
<button type="button" class="context-link" data-settings-pane="debug" onclick="setSettingsPane('debug', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h14v14H5zM8 9l2 2-2 2M12 15h4"></path></svg><span data-label-source="debugLogTitle">Debug-Log</span></button>
<button type="button" class="context-link" data-settings-pane="metrics" onclick="setSettingsPane('metrics', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 19V9M10 19V5M16 19v-7M22 19V3"></path></svg><span data-label-source="runtimeMetricsTitle">Runtime Metrics</span></button>
</nav>
</section>
</aside>
@@ -341,8 +336,8 @@
<main class="main workspace-main">
<header class="header workspace-toolbar">
<div class="toolbar-context" data-toolbar-for="vods">
<button type="button" class="toolbar-primary" onclick="focusWorkspaceTarget('newStreamer')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span>Streamer</span></button>
<button type="button" class="toolbar-icon-button" id="toolbarRefreshVodsBtn" onclick="refreshVODs()" aria-label="Refresh VODs" title="Refresh VODs"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6 7M5.5 15A7 7 0 0 0 18 17"></path></svg></button>
<button type="button" class="toolbar-primary" onclick="focusWorkspaceTarget('newStreamer')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span>Streamer</span></button>
</div>
<div class="toolbar-context" data-toolbar-for="clips" hidden>
<button type="button" class="toolbar-primary" onclick="focusWorkspaceTarget('clipUrl')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span data-label-source="clipsHeading">Clip</span></button>
@@ -353,7 +348,7 @@
<button type="button" class="toolbar-icon-button" id="toolbarCutBtn" onclick="startCutting()" aria-label="Cut video" title="Cut video"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg></button>
</div>
<div class="toolbar-context" data-toolbar-for="merge" hidden>
<button type="button" class="toolbar-primary" onclick="addMergeFiles()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span data-label-source="mergeAddBtn">Videos hinzufugen</span></button>
<button type="button" class="toolbar-primary" onclick="addMergeFiles()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span data-label-source="mergeAddBtn">Videos hinzufügen</span></button>
<button type="button" class="toolbar-icon-button" id="toolbarMergeBtn" onclick="startMerging()" aria-label="Merge videos" title="Merge videos"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4M16 16l3 3-3 3"></path></svg></button>
</div>
<div class="toolbar-context" data-toolbar-for="stats" hidden>
@@ -372,7 +367,7 @@
<h1 class="workspace-title" id="pageTitle">VODs</h1>
<div class="header-actions workspace-toolbar-actions" data-toolbar-search-for="vods">
<div class="header-search workspace-search">
<input type="text" id="newStreamer" placeholder="Streamer hinzufugen…" onkeypress="if(event.key==='Enter')addStreamer()">
<input type="text" id="newStreamer" placeholder="Streamer hinzufügen…" onkeypress="if(event.key==='Enter')addStreamer()">
<button id="btnAddStreamer" type="button" onclick="addStreamer()" aria-label="Add streamer" title="Add streamer">+</button>
</div>
<span id="refreshText" class="sr-only">Aktualisieren</span>
@@ -386,6 +381,11 @@
<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>
<span id="vodFilterCount" class="form-sublabel vod-filter-count"></span>
<label id="vodHideDownloadedLabel" class="inline-toggle vod-hide-downloaded" title="">
<input type="checkbox" id="vodHideDownloadedToggle" onchange="onVodHideDownloadedChange()">
<span id="vodHideDownloadedText">Hide downloaded</span>
</label>
<label id="vodSortLabel" for="vodSortSelect" class="form-sublabel vod-sort-label">Sort:</label>
<select id="vodSortSelect" class="select-compact" onchange="onVodSortChange()">
<option value="date_desc">Newest first</option>
@@ -394,11 +394,6 @@
<option value="duration_desc">Longest first</option>
<option value="duration_asc">Shortest first</option>
</select>
<span id="vodFilterCount" class="form-sublabel vod-filter-count"></span>
<label id="vodHideDownloadedLabel" class="inline-toggle" title="">
<input type="checkbox" id="vodHideDownloadedToggle" onchange="onVodHideDownloadedChange()">
<span id="vodHideDownloadedText">Hide downloaded</span>
</label>
</div>
<div id="vodBulkBar" class="vod-bulk-bar is-hidden">
<span id="vodBulkCount" class="vod-bulk-count">0 selected</span>
@@ -412,7 +407,7 @@
<div class="empty-state">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2h8l4 4v16H6z"></path><path d="M14 2v5h5M10 11l5 3-5 3z"></path></svg>
<h3 id="vodGridEmptyTitle">Keine VODs</h3>
<p id="vodGridEmptyText">Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
<p id="vodGridEmptyText">Wähle einen Streamer aus der Liste oder füge einen neuen hinzu.</p>
</div>
</div>
</div>
@@ -442,7 +437,7 @@
<div class="tab-content" id="cutterTab">
<div class="cutter-container">
<div class="settings-card">
<h3 id="cutterSelectTitle">Video auswahlen</h3>
<h3 id="cutterSelectTitle">Video auswählen</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>
@@ -452,7 +447,7 @@
<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 id="cutterPreviewPlaceholder">Video auswaehlen um Vorschau zu sehen</p>
<p id="cutterPreviewPlaceholder">Video auswählen um Vorschau zu sehen</p>
</div>
</div>
@@ -462,7 +457,7 @@
<span class="cutter-info-value" id="infoDuration">--:--:--</span>
</div>
<div class="cutter-info-item">
<span class="cutter-info-label" id="cutterInfoResolutionLabel">Aufloesung</span>
<span class="cutter-info-label" id="cutterInfoResolutionLabel">Auflösung</span>
<span class="cutter-info-value" id="infoResolution">----x----</span>
</div>
<div class="cutter-info-item">
@@ -510,12 +505,12 @@
<div class="tab-content" id="mergeTab">
<div class="merge-container">
<div class="settings-card">
<h3 id="mergeTitle">Videos zusammenfugen</h3>
<h3 id="mergeTitle">Videos zusammenfügen</h3>
<p id="mergeDesc" class="card-intro">
Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen.
Die Reihenfolge kann per Drag & Drop geandert werden.
Wähle mehrere Videos aus um sie zu einem Video zusammenzufügen.
Die Reihenfolge kann per Drag & Drop geändert werden.
</p>
<button type="button" class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button>
<button type="button" class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufügen</button>
</div>
<div class="file-list" id="mergeFileList">
@@ -533,7 +528,7 @@
</div>
<div class="merge-actions">
<button type="button" class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button>
<button type="button" class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfügen</button>
</div>
</div>
</div>
@@ -548,26 +543,26 @@
<button type="button" class="btn-secondary" id="btnStatsRefresh" onclick="refreshArchiveStats()">Aktualisieren</button>
</div>
</div>
<p id="statsIntro" class="card-intro flush">Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.</p>
<p id="statsIntro" class="card-intro flush">Aggregiert über 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>
<h3 id="statsSummaryTitle">Übersicht</h3>
<div id="statsSummaryGrid" class="stats-summary-grid"></div>
</div>
<div class="settings-card">
<h3 id="statsTopStreamersTitle">Top Streamer (nach Groesse)</h3>
<h3 id="statsTopStreamersTitle">Top Streamer (nach Größe)</h3>
<div id="statsTopStreamers"></div>
</div>
<div class="settings-card">
<h3 id="statsActivityTitle">Aktivitaet (letzte 30 Tage)</h3>
<h3 id="statsActivityTitle">Aktivität (letzte 30 Tage)</h3>
<div id="statsActivity"></div>
</div>
<div class="settings-card">
<h3 id="statsSizeBucketsTitle">Aufnahme-Groessen-Verteilung</h3>
<h3 id="statsSizeBucketsTitle">Aufnahme-Größen-Verteilung</h3>
<div id="statsSizeBuckets"></div>
</div>
</div>
@@ -576,7 +571,7 @@
<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>
<p id="archiveIntro" class="card-intro">Suche nach Dateinamen, Streamern oder Datumsangaben. Treffer zeigen Aufnahmen (Live + VOD); zugehörige Chat- und Event-Dateien werden als zusätzliche Schaltflächen 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">
@@ -589,8 +584,8 @@
</select>
<select id="archiveSearchSort" class="select-compact">
<option value="date_desc">Neueste zuerst</option>
<option value="date_asc">Aelteste zuerst</option>
<option value="size_desc">Groesste zuerst</option>
<option value="date_asc">Älteste zuerst</option>
<option value="size_desc">Größte zuerst</option>
<option value="size_asc">Kleinste zuerst</option>
<option value="name_asc">Name (A-Z)</option>
</select>
@@ -604,8 +599,8 @@
</div>
<!-- Settings Tab -->
<div class="tab-content" id="settingsTab">
<div class="settings-card">
<div class="tab-content" id="settingsTab" data-settings-pane="design">
<div class="settings-card" data-settings-pane="design">
<h3 id="designTitle">Design</h3>
<div class="form-group">
<label id="themeLabel" for="themeSelect">Theme</label>
@@ -635,23 +630,35 @@
<div class="form-group">
<label id="languageLabel">Sprache</label>
<div class="language-picker" id="languagePicker" role="group" aria-labelledby="languageLabel">
<button type="button" class="lang-option" id="langOptionEn" onclick="selectLanguageOption('en')" aria-pressed="false">
<svg class="flag-icon flag-en" viewBox="0 0 18 12" aria-hidden="true" focusable="false" shape-rendering="crispEdges">
<rect width="18" height="12" fill="#b31942"></rect>
<path d="M0 2h18v2H0zm0 4h18v2H0zm0 4h18v2H0z" fill="#fff"></path>
<path d="M0 0h8v6H0z" fill="#0a3161"></path>
<path d="M1 1h1v1H1zm3 0h1v1H4zm3 0h1v1H7zM2 3h1v1H2zm3 0h1v1H5zM1 5h1v1H1zm3 0h1v1H4zm3 0h1v1H7z" fill="#fff"></path>
</svg>
<span id="languageEnText">English</span>
</button>
<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>
</button>
<button type="button" class="lang-option" id="langOptionEn" onclick="selectLanguageOption('en')" aria-pressed="false">
<span class="flag-icon flag-en" aria-hidden="true"></span>
<span id="languageEnText">English</span>
</button>
</div>
<select id="languageSelect" onchange="changeLanguage(this.value)" style="display:none">
<option value="de">de</option>
<option value="en">en</option>
</select>
</div>
<div class="form-group sidebar-layout-setting">
<label class="toggle-row">
<input type="checkbox" id="sidebarSplitViewToggle" checked onchange="applySidebarLayoutPreference(this.checked)">
<span id="sidebarSplitViewLabel">Streamer und Queue gleichzeitig anzeigen</span>
</label>
<p id="sidebarSplitViewHint" class="form-note">Teilt die linke Seitenleiste gleichmäßig auf. Deaktiviert erscheinen wieder die Umschaltflächen.</p>
</div>
</div>
<div class="settings-card">
<div class="settings-card" data-settings-pane="api" hidden>
<h3 id="apiTitle">Twitch API</h3>
<p id="apiHelpText" class="card-intro">
<span id="apiHelpIntro">Du brauchst eine Client-ID und ein Client-Secret von Twitch.</span>
@@ -668,9 +675,9 @@
<button type="button" class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
</div>
<div class="settings-card">
<div class="settings-card" data-settings-pane="downloads" hidden>
<h3 id="downloadSettingsTitle">Download-Einstellungen</h3>
<div class="form-group">
<div class="form-group download-settings-storage">
<label id="storageLabel" for="downloadPath">Speicherort</label>
<div class="form-row">
<input type="text" id="downloadPath" readonly>
@@ -678,130 +685,109 @@
<button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
</div>
</div>
<div class="form-group">
<label id="modeLabel" for="downloadMode">Download-Modus</label>
<select id="downloadMode">
<option value="full" id="modeFullText">Ganzes VOD</option>
<option value="parts" id="modePartsText">In Teile splitten</option>
</select>
</div>
<div class="form-group">
<label id="partMinutesLabel" for="partMinutes">Teil-Lange (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480">
</div>
<div class="form-group">
<label id="parallelDownloadsLabel" for="parallelDownloads">Parallele Downloads</label>
<select id="parallelDownloads">
<option value="1" id="parallelDownloads1">1 (Standard)</option>
<option value="2" id="parallelDownloads2">2 (Parallel)</option>
</select>
</div>
<div class="form-group">
<label id="streamlinkQualityLabel" for="streamlinkQuality">Stream-Qualitaet</label>
<select id="streamlinkQuality">
<option value="best" id="streamlinkQualityBest">Best (Standard)</option>
<option value="source" id="streamlinkQualitySource">Source (Original)</option>
<option value="1080p60" id="streamlinkQuality1080p60">1080p60</option>
<option value="720p60" id="streamlinkQuality720p60">720p60</option>
<option value="720p" id="streamlinkQuality720p">720p</option>
<option value="480p" id="streamlinkQuality480p">480p</option>
<option value="audio_only" id="streamlinkQualityAudio">Audio only</option>
</select>
</div>
<div class="form-group">
<label id="performanceModeLabel" for="performanceMode">Performance-Profil</label>
<select id="performanceMode">
<option value="stability" id="performanceModeStability">Max Stabilitat</option>
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
<option value="speed" id="performanceModeSpeed">Max Geschwindigkeit</option>
</select>
</div>
<div class="form-group">
<label class="toggle-row">
<input type="checkbox" id="smartSchedulerToggle" checked>
<span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="duplicatePreventionToggle" checked>
<span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="persistQueueToggle" checked>
<span id="persistQueueLabel">Queue zwischen App-Starts speichern</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="autoResumeQueueToggle">
<span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="notifyEachCompletionToggle">
<span id="notifyEachCompletionLabel">Benachrichtigung bei jedem fertigen Download</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="streamlinkDisableAdsToggle" checked>
<span id="streamlinkDisableAdsLabel">Twitch-Ads beim Download ueberspringen</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="downloadChatReplayToggle">
<span id="downloadChatReplayLabel">Chat-Replay parallel zum VOD speichern (.chat.json)</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="captureLiveChatToggle">
<span id="captureLiveChatLabel">Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="logStreamEventsToggle" checked>
<span id="logStreamEventsLabel">Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="autoResumeLiveRecordingToggle" checked>
<span id="autoResumeLiveRecordingLabel">Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="autoMergeResumedPartsToggle">
<span id="autoMergeResumedPartsLabel">Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat)</span>
</label>
<label class="toggle-row indented">
<input type="checkbox" id="deletePartsAfterMergeToggle">
<span id="deletePartsAfterMergeLabel">Einzelne Parts nach erfolgreichem Merge loeschen</span>
</label>
</div>
<div class="form-group">
<label id="metadataCacheMinutesLabel" for="metadataCacheMinutes">Metadata-Cache (Minuten)</label>
<input type="number" id="metadataCacheMinutes" value="10" min="1" max="120">
</div>
<div class="form-group">
<div class="form-row" style="align-items:center; margin-bottom: 4px;">
<label id="filenameTemplatesTitle">Dateinamen-Templates</label>
<button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
<div class="download-settings-layout">
<div class="download-settings-column">
<section class="download-settings-section">
<h4 id="downloadBasicsTitle">Download und Qualität</h4>
<div class="form-group">
<label id="modeLabel" for="downloadMode">Download-Modus</label>
<select id="downloadMode">
<option value="full" id="modeFullText">Ganzes VOD</option>
<option value="parts" id="modePartsText">In Teile splitten</option>
</select>
</div>
<div class="form-group">
<label id="partMinutesLabel" for="partMinutes">Teil-Lange (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480">
</div>
<div class="form-group">
<label id="parallelDownloadsLabel" for="parallelDownloads">Parallele Downloads</label>
<select id="parallelDownloads">
<option value="1" id="parallelDownloads1">1 (Standard)</option>
<option value="2" id="parallelDownloads2">2 (Parallel)</option>
</select>
</div>
<div class="form-group">
<label id="streamlinkQualityLabel" for="streamlinkQuality">Stream-Qualität</label>
<select id="streamlinkQuality">
<option value="best" id="streamlinkQualityBest">Best (Standard)</option>
<option value="source" id="streamlinkQualitySource">Source (Original)</option>
<option value="1080p60" id="streamlinkQuality1080p60">1080p60</option>
<option value="720p60" id="streamlinkQuality720p60">720p60</option>
<option value="720p" id="streamlinkQuality720p">720p</option>
<option value="480p" id="streamlinkQuality480p">480p</option>
<option value="audio_only" id="streamlinkQualityAudio">Audio only</option>
</select>
</div>
<div class="form-group">
<label id="performanceModeLabel" for="performanceMode">Performance-Profil</label>
<select id="performanceMode">
<option value="stability" id="performanceModeStability">Max Stabilitat</option>
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
<option value="speed" id="performanceModeSpeed">Max Geschwindigkeit</option>
</select>
</div>
</section>
<section class="download-settings-section">
<div class="download-settings-section-heading">
<h4 id="filenameTemplatesTitle">Dateinamen-Templates</h4>
<button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
</div>
<div class="form-row template-presets">
<button class="btn-secondary" id="templatePresetDefault" type="button" onclick="applyTemplatePreset('default')">Preset: Default</button>
<button class="btn-secondary" id="templatePresetArchive" type="button" onclick="applyTemplatePreset('archive')">Preset: Archive</button>
<button class="btn-secondary" id="templatePresetClipper" type="button" onclick="applyTemplatePreset('clipper')">Preset: Clipper</button>
</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()">
<label id="partsTemplateLabel" for="partsFilenameTemplate">VOD Part Template</label>
<input type="text" id="partsFilenameTemplate" class="input-monospace" placeholder="{date}_Part{part_padded}.mp4" oninput="validateFilenameTemplates()">
<label id="defaultClipTemplateLabel" for="defaultClipFilenameTemplate">Clip Template</label>
<input type="text" id="defaultClipFilenameTemplate" class="input-monospace" placeholder="{date}_{part}.mp4" oninput="validateFilenameTemplates()">
</div>
<div id="filenameTemplateHint" class="form-note">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>
</section>
</div>
<div class="form-row" style="gap: 8px; margin: 8px 0 6px;">
<button class="btn-secondary" id="templatePresetDefault" type="button" onclick="applyTemplatePreset('default')">Preset: Default</button>
<button class="btn-secondary" id="templatePresetArchive" type="button" onclick="applyTemplatePreset('archive')">Preset: Archive</button>
<button class="btn-secondary" id="templatePresetClipper" type="button" onclick="applyTemplatePreset('clipper')">Preset: Clipper</button>
<div class="download-settings-column">
<section class="download-settings-section">
<h4 id="queueAutomationTitle">Queue und Automatisierung</h4>
<div class="download-settings-toggles">
<label class="toggle-row"><input type="checkbox" id="smartSchedulerToggle" checked><span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span></label>
<label class="toggle-row"><input type="checkbox" id="duplicatePreventionToggle" checked><span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span></label>
<label class="toggle-row"><input type="checkbox" id="persistQueueToggle" checked><span id="persistQueueLabel">Queue zwischen App-Starts speichern</span></label>
<label class="toggle-row"><input type="checkbox" id="autoResumeQueueToggle"><span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span></label>
<label class="toggle-row"><input type="checkbox" id="notifyEachCompletionToggle"><span id="notifyEachCompletionLabel">Benachrichtigung bei jedem fertigen Download</span></label>
</div>
</section>
<section class="download-settings-section">
<h4 id="recordingMetadataTitle">Aufnahmen und Metadaten</h4>
<div class="download-settings-toggles">
<label class="toggle-row"><input type="checkbox" id="streamlinkDisableAdsToggle" checked><span id="streamlinkDisableAdsLabel">Twitch-Ads beim Download überspringen</span></label>
<label class="toggle-row"><input type="checkbox" id="downloadChatReplayToggle"><span id="downloadChatReplayLabel">Chat-Replay parallel zum VOD speichern (.chat.json)</span></label>
<label class="toggle-row"><input type="checkbox" id="captureLiveChatToggle"><span id="captureLiveChatLabel">Live-Chat während der Aufnahme mitschneiden (.chat.jsonl)</span></label>
<label class="toggle-row"><input type="checkbox" id="logStreamEventsToggle" checked><span id="logStreamEventsLabel">Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)</span></label>
<label class="toggle-row"><input type="checkbox" id="autoResumeLiveRecordingToggle" checked><span id="autoResumeLiveRecordingLabel">Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)</span></label>
<label class="toggle-row"><input type="checkbox" id="autoMergeResumedPartsToggle"><span id="autoMergeResumedPartsLabel">Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfügen (ffmpeg concat)</span></label>
<label class="toggle-row indented"><input type="checkbox" id="deletePartsAfterMergeToggle"><span id="deletePartsAfterMergeLabel">Einzelne Parts nach erfolgreichem Merge löschen</span></label>
</div>
<div class="form-group metadata-cache-setting">
<label id="metadataCacheMinutesLabel" for="metadataCacheMinutes">Metadata-Cache (Minuten)</label>
<input type="number" id="metadataCacheMinutes" value="10" min="1" max="120">
</div>
</section>
</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()">
<label id="partsTemplateLabel" for="partsFilenameTemplate">VOD Part Template</label>
<input type="text" id="partsFilenameTemplate" class="input-monospace" placeholder="{date}_Part{part_padded}.mp4" oninput="validateFilenameTemplates()">
<label id="defaultClipTemplateLabel" for="defaultClipFilenameTemplate">Clip Template</label>
<input type="text" id="defaultClipFilenameTemplate" class="input-monospace" placeholder="{date}_{part}.mp4" oninput="validateFilenameTemplates()">
</div>
<div id="filenameTemplateHint" class="form-note" style="margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<div id="filenameTemplateLint" class="template-lint ok">Template-Check: OK</div>
</div>
</div>
<div class="settings-card">
<div class="settings-card" data-settings-pane="updates" hidden>
<h3 id="updateTitle">Updates</h3>
<p id="versionInfo" class="card-intro">Version: v1.0.2</p>
<p id="versionInfo" class="card-intro">Version: v1.0.3</p>
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
<div class="settings-card">
<div class="settings-card" data-settings-pane="system" hidden>
<div class="form-row section-header">
<h3 id="preflightTitle">System-Check</h3>
<span class="health-badge unknown" id="healthBadge">System: Unbekannt</span>
@@ -813,11 +799,11 @@
<pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre>
</div>
<div class="settings-card">
<div class="settings-card" data-settings-pane="debug" hidden>
<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>
<button type="button" class="btn-secondary" id="btnOpenDebugLogFile" onclick="openDebugLogFile()">Log-Datei öffnen</button>
<label class="inline-toggle">
<input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)">
<span id="autoRefreshText">Auto-Refresh</span>
@@ -826,7 +812,7 @@
<pre id="debugLogOutput" class="log-panel">Lade...</pre>
</div>
<div class="settings-card">
<div class="settings-card" data-settings-pane="storage" hidden>
<div class="form-row section-header">
<h3 id="storageCardTitle">Storage</h3>
<button type="button" class="btn-secondary" id="btnRefreshStorage" onclick="refreshStorageStats()">Aktualisieren</button>
@@ -837,7 +823,7 @@
<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>
<p id="cleanupIntro" class="card-intro">Aufnahmen älter als X Tage automatisch archivieren oder löschen. Schiebt Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) mit der Aufnahme.</p>
<label class="toggle-row" style="margin-bottom: 8px;">
<input type="checkbox" id="autoCleanupEnabledToggle">
<span id="autoCleanupEnabledLabel">Auto-Cleanup aktivieren</span>
@@ -858,20 +844,20 @@
<span id="autoCleanupActionLabel" class="form-sublabel">Aktion</span>
<select id="autoCleanupAction">
<option value="archive" id="autoCleanupActionArchive">In Archiv verschieben</option>
<option value="delete" id="autoCleanupActionDelete">Loeschen</option>
<option value="delete" id="autoCleanupActionDelete">Löschen</option>
</select>
</label>
</div>
<div class="form-row" style="margin-bottom: 8px; gap: 8px;">
<button type="button" class="btn-secondary" id="btnCleanupDryRun" onclick="runCleanupDryRun()">Vorschau</button>
<button type="button" class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausfuehren</button>
<button type="button" class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausführen</button>
</div>
<div id="cleanupReport" class="form-note" role="status" aria-live="polite"></div>
</div>
<div class="settings-card">
<div class="settings-card" data-settings-pane="notifications" hidden>
<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" class="card-intro">Sende Benachrichtigungen an einen Discord-Channel via Webhook — nützlich für Multi-Device-Setups oder eine dedizierte Archiv-Maschine.</p>
<div class="form-group">
<label id="discordWebhookUrlLabel" for="discordWebhookUrl">Webhook-URL</label>
<input type="text" id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/...">
@@ -896,9 +882,9 @@
</div>
</div>
<div class="settings-card">
<div class="settings-card" data-settings-pane="automation" hidden>
<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>
<p id="autoVodCardIntro" class="card-intro">Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprüft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefügt.</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">
@@ -907,22 +893,22 @@
</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>
<button type="button" class="btn-secondary" id="btnAutoRecordScanNow" onclick="triggerManualAutoRecordScan()">Live-Status prüfen</button>
<span id="autoVodStatusLine" class="form-sublabel"></span>
</div>
</div>
<div class="settings-card">
<div class="settings-card" data-settings-pane="maintenance" hidden>
<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" class="card-intro">Konfiguration sichern, auf einem anderen Gerät wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zurücksetzen.</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 type="button" class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zurücksetzen</button>
</div>
</div>
<div class="settings-card">
<div class="settings-card" data-settings-pane="metrics" hidden>
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
<div class="form-row aligned">
<button type="button" class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
@@ -961,7 +947,7 @@
aria-label="Befehlspalette"
/>
<ul id="commandPaletteList" class="cp-list" role="listbox" aria-label="Befehlsergebnisse"></ul>
<p class="cp-hint" id="commandPaletteHint">Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen</p>
<p class="cp-hint" id="commandPaletteHint">Up/Down zum Navigieren, Enter zum Ausführen, Esc zum Schließen</p>
</div>
</div>
+308 -113
View File
@@ -15,6 +15,9 @@ import {
getMergeGroupPhaseText as getMergeGroupPhaseTextCore,
} from './main/infra/format-helpers';
import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
import { watchRendererChanges } from './main/dev-reload';
import { createPausableOutput, type PausableOutput } from './main/domain/pausable-output';
import { PartialDownloadRegistry } from './main/domain/partial-download';
import type { DbHandle } from './main/infra/db';
import {
normalizeLogin,
@@ -31,6 +34,8 @@ import {
type PerformanceMode,
} from './main/domain/config-normalize';
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
import { buildVodPreviewFrameUrls } from './main/domain/vod-preview';
import { getWindowsAppIdentity } from './main/domain/app-identity';
import {
setDebugLogFn, initToolDirs,
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
@@ -45,6 +50,9 @@ import {
// CONFIG & CONSTANTS
// ==========================================
const APP_VERSION = app.getVersion();
const WINDOWS_APP_IDENTITY = getWindowsAppIdentity(process.env.TWITCH_VOD_MANAGER_DEV === '1');
app.setName(WINDOWS_APP_IDENTITY.name);
app.setAppUserModelId(WINDOWS_APP_IDENTITY.appUserModelId);
const GITHUB_REPO_OWNER = 'Sucukdeluxe';
const GITHUB_REPO_NAME = 'Twitch-VOD-Manager';
const GITHUB_RELEASES_API_LATEST_URL = 'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest';
@@ -55,6 +63,7 @@ const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twi
const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json');
const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json');
const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log');
const PARTIAL_DOWNLOADS_FILE = path.join(APPDATA_DIR, 'partial-downloads.json');
const TOOLS_DIR = path.join(APPDATA_DIR, 'tools');
const TOOLS_STREAMLINK_DIR = path.join(TOOLS_DIR, 'streamlink');
const TOOLS_FFMPEG_DIR = path.join(TOOLS_DIR, 'ffmpeg');
@@ -110,6 +119,7 @@ function tBackend(key: BackendMessageKey, params?: Record<string, string | numbe
if (!fs.existsSync(APPDATA_DIR)) {
fs.mkdirSync(APPDATA_DIR, { recursive: true });
}
const partialDownloadRegistry = new PartialDownloadRegistry(PARTIAL_DOWNLOADS_FILE);
// ==========================================
// INTERFACES
@@ -119,10 +129,12 @@ interface Config {
client_secret: string;
download_path: string;
streamers: string[];
streamer_display_names: Record<string, string>;
theme: string;
download_mode: 'parts' | 'full';
part_minutes: number;
language: 'de' | 'en';
sidebar_split_view: boolean;
filename_template_vod: string;
filename_template_parts: string;
filename_template_clip: string;
@@ -256,10 +268,12 @@ const defaultConfig: Config = {
client_secret: '',
download_path: DEFAULT_DOWNLOAD_PATH,
streamers: [],
streamer_display_names: {},
theme: 'twitch',
download_mode: 'full',
part_minutes: 120,
language: 'en',
sidebar_split_view: true,
filename_template_vod: DEFAULT_FILENAME_TEMPLATE_VOD,
filename_template_parts: DEFAULT_FILENAME_TEMPLATE_PARTS,
filename_template_clip: DEFAULT_FILENAME_TEMPLATE_CLIP,
@@ -314,12 +328,22 @@ function normalizeConfigTemplates(input: Config): Config {
const trimmedIds = cleanIds.length > DOWNLOADED_IDS_MAX
? cleanIds.slice(cleanIds.length - DOWNLOADED_IDS_MAX)
: cleanIds;
const displayNames: Record<string, string> = {};
if (isPlainObject(input.streamer_display_names)) {
for (const [login, displayName] of Object.entries(input.streamer_display_names)) {
const normalizedLogin = normalizeLogin(login);
const normalizedDisplayName = typeof displayName === 'string' ? displayName.trim() : '';
if (normalizedLogin && normalizedDisplayName) displayNames[normalizedLogin] = normalizedDisplayName;
}
}
return {
...input,
streamer_display_names: displayNames,
filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD),
filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS),
filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP),
sidebar_split_view: input.sidebar_split_view !== false,
smart_queue_scheduler: input.smart_queue_scheduler !== false,
performance_mode: normalizePerformanceMode(input.performance_mode),
prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false,
@@ -505,6 +529,7 @@ function sanitizeQueueItem(raw: unknown): QueueItem | null {
if (typeof raw.totalParts === 'number' && Number.isFinite(raw.totalParts)) item.totalParts = raw.totalParts;
if (typeof raw.speed === 'string') item.speed = raw.speed;
if (typeof raw.eta === 'string') item.eta = raw.eta;
if (typeof raw.progressStatus === 'string') item.progressStatus = raw.progressStatus;
if (typeof raw.last_error === 'string') item.last_error = raw.last_error;
if (typeof raw.downloadedBytes === 'number' && Number.isFinite(raw.downloadedBytes)) item.downloadedBytes = raw.downloadedBytes;
if (typeof raw.totalBytes === 'number' && Number.isFinite(raw.totalBytes)) item.totalBytes = raw.totalBytes;
@@ -634,12 +659,27 @@ function flushQueueSave(): void {
// GLOBAL STATE
// ==========================================
let mainWindow: BrowserWindow | null = null;
let stopDevelopmentReload: (() => void) | null = null;
function startDevelopmentReload(): void {
if (process.env.TWITCH_VOD_MANAGER_DEV !== '1' || stopDevelopmentReload) return;
stopDevelopmentReload = watchRendererChanges(
__dirname,
path.join(__dirname, '../src'),
() => mainWindow?.webContents.reloadIgnoringCache(),
);
app.once('before-quit', () => {
stopDevelopmentReload?.();
stopDevelopmentReload = null;
});
}
let config = loadConfig();
let accessToken: string | null = null;
let downloadQueue: QueueItem[] = loadQueue();
let queueIdCounter = 0;
let lastQueueBroadcastFingerprint = '';
let isDownloading = false;
let queuePaused = false;
// Process handle for the standalone video editor pipeline (cutter / merger /
// splitter). Queue downloads track their own children via activeDownloads,
// and clip downloads via activeClipProcesses. Keeping these separate
@@ -649,12 +689,19 @@ let currentEditorProcess: ChildProcess | null = null;
// `currentDownloadCancelled` flag was redundant once pause/cancel/remove
// started iterating activeDownloads and adding each item to that Set; it
// was removed in the 4.5.27 cleanup.
let pauseRequested = false;
let activeQueueItemId: string | null = null;
let downloadStartTime = 0;
let downloadedBytes = 0;
// Per-item tracking for parallel downloads
const activeDownloads = new Map<string, { process: ChildProcess | null; cancelled: boolean; startTime: number; bytes: number }>();
interface ActiveDownloadTracking {
process: ChildProcess | null;
cancelled: boolean;
startTime: number;
bytes: number;
output: PausableOutput;
partialFilename: string;
}
const activeDownloads = new Map<string, ActiveDownloadTracking>();
const cancelledItemIds = new Set<string>();
// userId -> login reverse map. Bounded via Map insertion-order eviction so
// a long-running session doesn't grow it unbounded across thousands of
@@ -1337,12 +1384,12 @@ function classifyDownloadError(errorMessage: string): RetryErrorClass {
const text = (errorMessage || '').toLowerCase();
if (!text) return 'unknown';
if (text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation';
if (text.includes('ungültige vod-url') || text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation';
if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit';
if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden') || text.includes('subscriber only') || text.includes('sub-only') || text.includes('not subscribed')) return 'auth';
if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns') || text.includes('http error') || text.includes('connectionerror') || text.includes('readerror')) return 'network';
if (text.includes('streamlink nicht gefunden') || text.includes('streamlink not found') || text.includes('streamlink is missing') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling';
if (text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream') || text.includes('no video stream')) return 'integrity';
if (text.includes('integrität') || text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream') || text.includes('no video stream')) return 'integrity';
if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner') || text.includes('folder')) return 'io';
// Twitch-spezifische streamlink errors:
// "error: No playable streams found on this URL" — VOD weg / private / sub-only
@@ -1436,6 +1483,17 @@ const activeDownloadProgress = new Map<string, number>();
function recordDownloadProgress(progress: DownloadProgress): void {
const p = Number(progress.progress);
const item = downloadQueue.find((candidate) => candidate.id === progress.id);
if (item) {
if (Number.isFinite(p) && p > 0 && p <= 100) item.progress = Math.max(item.progress, p);
item.speed = progress.speed || '';
item.eta = progress.eta || '';
item.progressStatus = progress.status;
if (typeof progress.currentPart === 'number') item.currentPart = progress.currentPart;
if (typeof progress.totalParts === 'number') item.totalParts = progress.totalParts;
if (typeof progress.downloadedBytes === 'number') item.downloadedBytes = progress.downloadedBytes;
if (typeof progress.totalBytes === 'number') item.totalBytes = progress.totalBytes;
}
const fraction = Number.isFinite(p) && p > 0 && p <= 100 ? p / 100 : 0.3;
activeDownloadProgress.set(progress.id, fraction);
updateTaskbarProgress();
@@ -2189,6 +2247,50 @@ interface PublicStreamerProfileResult {
stream: PublicStreamInfo | null;
}
interface PublicDisplayNameQueryResult {
user: {
login: string;
displayName: string;
} | null;
}
async function getStreamerDisplayNames(logins: string[]): Promise<Record<string, string>> {
const normalizedLogins = [...new Set(logins.map((login) => normalizeLogin(login)).filter(Boolean))];
const displayNames = { ...config.streamer_display_names };
const missing = normalizedLogins;
let changed = false;
let nextIndex = 0;
const resolveNext = async (): Promise<void> => {
while (nextIndex < missing.length) {
const login = missing[nextIndex++];
const data = await fetchPublicTwitchGql<PublicDisplayNameQueryResult>(
'query($login: String!) { user(login: $login) { login displayName } }',
{ login }
);
const result = data?.user;
const resolvedLogin = result?.login ? normalizeLogin(result.login) : login;
const displayName = result?.displayName?.trim();
if (displayName && displayNames[resolvedLogin] !== displayName) {
displayNames[resolvedLogin] = displayName;
changed = true;
}
}
};
await Promise.all(Array.from({ length: Math.min(4, missing.length) }, () => resolveNext()));
if (changed) {
config.streamer_display_names = displayNames;
saveConfig(config);
}
return normalizedLogins.reduce<Record<string, string>>((resolved, login) => {
const displayName = displayNames[login];
if (displayName) resolved[login] = displayName;
return resolved;
}, {});
}
interface PublicStreamInfo {
previewUrl: string;
viewers: number | null;
@@ -2392,6 +2494,9 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
interface VodStoryboard {
vodId: string;
spriteDataUrl: string;
frameDataUrls: string[];
frameWidth: number;
frameHeight: number;
cols: number;
rows: number;
cellWidth: number;
@@ -2399,7 +2504,7 @@ interface VodStoryboard {
framesInSprite: number;
}
const MAX_VOD_STORYBOARD_CACHE_ENTRIES = 1024;
const MAX_VOD_STORYBOARD_CACHE_ENTRIES = 12;
const vodStoryboardCache = new Map<string, CacheEntry<VodStoryboard | null>>();
const inFlightStoryboardRequests = new Map<string, Promise<VodStoryboard | null>>();
@@ -2430,8 +2535,8 @@ async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
// 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 } }`,
const data = await fetchPublicTwitchGql<{ video: { seekPreviewsURL: string | null; previewThumbnailURL: string | null } | null }>(
`query($id: ID!) { video(id: $id) { seekPreviewsURL previewThumbnailURL(width: 1920, height: 1080) } }`,
{ id: vodId }
);
const manifestUrl = data?.video?.seekPreviewsURL;
@@ -2474,10 +2579,23 @@ async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
// and sprite filenames are relative (e.g. "{vodId}-high-0.jpg").
// Strip the JSON filename to get the base, then append the sprite.
const baseUrl = manifestUrl.replace(/\/[^/]+$/, '/');
const frameUrls = buildVodPreviewFrameUrls(data?.video?.previewThumbnailURL || '');
const frameDataUrls = (await Promise.all(frameUrls.map(async (url) => {
try {
const response = await axios.get<ArrayBuffer>(url, {
responseType: 'arraybuffer',
timeout: 8000,
headers: { 'User-Agent': 'TwitchVODManager/1.0' }
});
const contentType = String(response.headers['content-type'] || 'image/jpeg').split(';')[0];
return `data:${contentType};base64,${Buffer.from(response.data).toString('base64')}`;
} catch {
return '';
}
}))).filter((url) => url.length > 0);
const firstSpriteUrl = baseUrl + entry.images[0];
const spriteDataUrl = await fetchAvatarAsDataUrl(firstSpriteUrl);
if (!spriteDataUrl) {
const spriteDataUrl = frameDataUrls.length >= 2 ? '' : await fetchAvatarAsDataUrl(firstSpriteUrl);
if (frameDataUrls.length < 2 && !spriteDataUrl) {
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
return null;
}
@@ -2485,6 +2603,9 @@ async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
const storyboard: VodStoryboard = {
vodId,
spriteDataUrl,
frameDataUrls: frameDataUrls.length >= 2 ? frameDataUrls : [],
frameWidth: frameDataUrls.length >= 2 ? 1920 : 0,
frameHeight: frameDataUrls.length >= 2 ? 1080 : 0,
cols: entry.cols,
rows: entry.rows,
cellWidth: entry.width,
@@ -3050,7 +3171,7 @@ function downloadVODPart(
): Promise<DownloadResult> {
return new Promise((resolve) => {
const streamlinkCmd = getStreamlinkCommand();
const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '-o', filename, '--force'];
const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '--stdout'];
if (config.streamlink_disable_ads !== false) {
// Skips Twitch mid-roll ads which would otherwise be embedded
// in the VOD output. Off only if the user explicitly disabled it.
@@ -3083,12 +3204,23 @@ function downloadVODPart(
// for support / forensics — no need to flood stdout too.
appendDebugLog('download-part-start', { itemId, command: streamlinkCmd.command, filename, args });
const partialFilename = partialDownloadRegistry.begin(filename);
const proc = spawn(streamlinkCmd.command, args, { windowsHide: true });
const outputStream = fs.createWriteStream(partialFilename, { flags: 'w' });
if (!proc.stdout) {
outputStream.destroy();
partialDownloadRegistry.discard(partialFilename);
resolve({ success: false, error: tBackend('unknownDownloadError') });
return;
}
const output = createPausableOutput(proc.stdout, outputStream);
const outputFinished = output.finished.then(() => null, (error) => error);
// Register in per-item tracking map for parallel downloads
// (no longer mirrored on a global — currentEditorProcess is editor-only)
const itemTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0 };
const itemTracking: ActiveDownloadTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0, output, partialFilename };
activeDownloads.set(itemId, itemTracking);
if (queuePaused) output.pause();
downloadStartTime = itemTracking.startTime;
downloadedBytes = 0;
@@ -3097,9 +3229,9 @@ function downloadVODPart(
// Monitor file size for progress
const progressInterval = setInterval(() => {
if (fs.existsSync(filename)) {
if (fs.existsSync(partialFilename)) {
try {
const stats = fs.statSync(filename);
const stats = fs.statSync(partialFilename);
downloadedBytes = stats.size;
itemTracking.bytes = stats.size;
@@ -3167,46 +3299,24 @@ function downloadVODPart(
}
}, 1000);
const stdoutBuffer: string[] = [];
proc.stdout?.on('data', (data: Buffer) => {
const line = data.toString();
// Capture non-progress lines auch fuer Diagnose — streamlink-Windows-
// Builds schreiben einige Errors auf stdout statt stderr (z.B. "error:
// No playable streams found on this URL"). Wenn stderr leer bleibt,
// greift der close-handler auf stdout zurueck.
stdoutBuffer.push(line);
if (stdoutBuffer.length > 200) stdoutBuffer.shift();
const lower = line.toLowerCase();
if (lower.includes('error:') || lower.includes('warning:')) {
appendDebugLog('download-part-stdout-err', { itemId, message: line.trim() });
if (lower.includes('error:')) {
// Letzte echte streamlink-Errorzeile, auch wenn auf stdout
const errLine = line.split('\n').map(l => l.trim()).filter(l => l.toLowerCase().includes('error:')).pop();
if (errLine) lastErrorLine = errLine;
}
}
// Parse progress
const match = line.match(/(\d+\.\d+)%/);
if (match) {
const percent = parseFloat(match[1]);
lastStreamlinkPercent = percent;
onProgress({
id: itemId,
progress: percent,
speed: '',
eta: '',
status: `${percent.toFixed(1)}%`,
currentPart: partNum,
totalParts: totalParts
});
}
});
proc.stderr?.on('data', (data: Buffer) => {
const message = data.toString();
if (message.trim()) {
stderrBuffer.push(message);
const match = message.match(/(\d+\.\d+)%/);
if (match) {
const percent = parseFloat(match[1]);
lastStreamlinkPercent = percent;
onProgress({
id: itemId,
progress: percent,
speed: '',
eta: '',
status: `${percent.toFixed(1)}%`,
currentPart: partNum,
totalParts: totalParts
});
}
// Bounded buffer — wir wollen nicht 100MB stderr in RAM bei einem
// streamlink-loop. 200 chunks reichen fuer normale Diagnose.
if (stderrBuffer.length > 200) stderrBuffer.shift();
@@ -3230,26 +3340,36 @@ function downloadVODPart(
proc.on('close', async (code) => {
clearInterval(progressInterval);
const outputError = await outputFinished;
activeDownloads.delete(itemId);
if (outputError) {
partialDownloadRegistry.discard(partialFilename);
resolve({ success: false, error: String(outputError) });
return;
}
if (cancelledItemIds.has(itemId)) {
cancelledItemIds.delete(itemId);
partialDownloadRegistry.discard(partialFilename);
appendDebugLog('download-part-cancelled', { itemId, filename });
resolve({ success: false, error: tBackend('downloadCancelled') });
return;
}
if (code === 0 && fs.existsSync(filename)) {
const stats = fs.statSync(filename);
if (code === 0 && fs.existsSync(partialFilename)) {
const stats = fs.statSync(partialFilename);
if (stats.size <= MIN_FILE_BYTES) {
const tooSmall = tBackend('fileTooSmall', { bytes: String(stats.size) });
partialDownloadRegistry.discard(partialFilename);
appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size });
resolve({ success: false, error: tooSmall });
return;
}
const integrityResult = validateDownloadedFileIntegrity(filename, expectedDurationSeconds);
const integrityResult = validateDownloadedFileIntegrity(partialFilename, expectedDurationSeconds);
if (!integrityResult.success) {
partialDownloadRegistry.discard(partialFilename);
appendDebugLog('download-part-failed-integrity', {
itemId,
filename,
@@ -3260,6 +3380,13 @@ function downloadVODPart(
return;
}
try {
partialDownloadRegistry.commit(partialFilename, filename);
} catch (error) {
partialDownloadRegistry.discard(partialFilename);
resolve({ success: false, error: String(error) });
return;
}
runtimeMetrics.downloadedBytesTotal += stats.size;
appendDebugLog('download-part-success', { itemId, filename, bytes: stats.size });
resolve({ success: true });
@@ -3272,13 +3399,12 @@ function downloadVODPart(
// war historisch ein stdout-Error). Wir mergen beide Streams
// damit immer SICHTBAR ist was passiert.
const fullStderr = stderrBuffer.join('').trim();
const fullStdout = stdoutBuffer.join('').trim();
// Letzte Error-/Warning-Zeile aus beiden Streams suchen, falls
// lastErrorLine noch leer ist (z.B. weil streamlink ohne Output
// mit Code 1 exited — was bei pre-flight-Auth-Fails passiert).
let userFacingError = lastErrorLine;
if (!userFacingError) {
const combined = (fullStderr + '\n' + fullStdout).split('\n').map(l => l.trim()).filter(Boolean);
const combined = fullStderr.split('\n').map(l => l.trim()).filter(Boolean);
userFacingError = combined.filter(l => l.toLowerCase().includes('error:')).pop()
|| combined.filter(l => !l.startsWith('[')).pop()
|| '';
@@ -3288,14 +3414,16 @@ function downloadVODPart(
}
appendDebugLog('download-part-failed', {
itemId, filename, code, error: userFacingError,
stderrTail: fullStderr.slice(-2000),
stdoutTail: fullStdout.slice(-2000),
stderrTail: fullStderr.slice(-2000)
});
partialDownloadRegistry.discard(partialFilename);
resolve({ success: false, error: userFacingError });
});
proc.on('error', (err) => {
proc.on('error', async (err) => {
clearInterval(progressInterval);
await output.cancel();
partialDownloadRegistry.discard(partialFilename);
console.error('Process error:', err);
activeDownloads.delete(itemId);
const rawError = String(err);
@@ -5046,8 +5174,8 @@ async function downloadLiveStream(
}
// Resume decision tree.
if (cancelledItemIds.has(item.id) || !isDownloading || pauseRequested) {
appendDebugLog('recording-resume-cancelled', { itemId: item.id, partNumber, reason: pauseRequested ? 'pause' : 'cancel' });
if (cancelledItemIds.has(item.id) || !isDownloading) {
appendDebugLog('recording-resume-cancelled', { itemId: item.id, partNumber, reason: 'cancel' });
break;
}
if (!config.auto_resume_live_recording) {
@@ -5721,12 +5849,12 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
const result = item.mergeGroup
? await processDownloadMergeGroup(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress);
recordDownloadProgress(progress);
if (!queuePaused) mainWindow?.webContents.send('download-progress', progress);
})
: await downloadVOD(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress);
recordDownloadProgress(progress);
if (!queuePaused) mainWindow?.webContents.send('download-progress', progress);
});
if (result.success) {
@@ -5736,8 +5864,8 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
finalResult = result;
if (!isDownloading || cancelledItemIds.has(item.id) || pauseRequested) {
finalResult = { success: false, error: pauseRequested ? tBackend('downloadPaused') : tBackend('downloadCancelled') };
if (!isDownloading || cancelledItemIds.has(item.id)) {
finalResult = { success: false, error: tBackend('downloadCancelled') };
break;
}
@@ -5781,10 +5909,9 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
return;
}
const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert');
item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error');
item.status = finalResult.success ? 'completed' : 'error';
item.progress = finalResult.success ? 100 : item.progress;
item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || tBackend('unknownDownloadError'));
item.last_error = finalResult.success ? '' : (finalResult.error || tBackend('unknownDownloadError'));
if (finalResult.success && Array.isArray(finalResult.outputFiles) && finalResult.outputFiles.length > 0) {
// Attach the produced file paths so the renderer can offer
@@ -5818,7 +5945,8 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
if (Notification.isSupported()) {
const itemNotification = new Notification({
title: 'Twitch VOD Manager',
body: `${item.title || item.url}`
body: `${item.title || item.url}`,
icon: path.join(__dirname, '../build/icon.png')
});
const firstFile = item.outputFiles?.[0];
itemNotification.on('click', () => {
@@ -5924,7 +6052,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
if (finalResult.success) {
runtimeMetrics.downloadsCompleted += 1;
} else if (!wasPaused) {
} else {
runtimeMetrics.downloadsFailed += 1;
}
@@ -5956,7 +6084,7 @@ async function processQueue(): Promise<void> {
});
isDownloading = true;
pauseRequested = false;
queuePaused = false;
cancelledItemIds.clear();
mainWindow?.webContents.send('download-started');
emitQueueUpdated();
@@ -5964,17 +6092,17 @@ async function processQueue(): Promise<void> {
const maxSlots = Math.min(Math.max(1, config.parallel_downloads || 1), 2);
const activePromises = new Map<string, Promise<void>>();
while (isDownloading && !pauseRequested) {
while (isDownloading) {
// Clean up finished promises
for (const [id] of activePromises) {
const queueItem = downloadQueue.find(i => i.id === id);
if (!queueItem || queueItem.status !== 'downloading') {
if (!queueItem || (queueItem.status !== 'downloading' && queueItem.status !== 'paused')) {
activePromises.delete(id);
}
}
// Fill available slots
while (activePromises.size < maxSlots && !pauseRequested) {
while (activePromises.size < maxSlots && !queuePaused) {
const item = pickNextPendingQueueItem();
if (!item) break;
@@ -5994,7 +6122,7 @@ async function processQueue(): Promise<void> {
}
isDownloading = false;
pauseRequested = false;
queuePaused = false;
runtimeMetrics.activeItemId = null;
runtimeMetrics.activeItemTitle = null;
activeQueueItemId = null;
@@ -6012,7 +6140,8 @@ async function processQueue(): Promise<void> {
title: 'Twitch VOD Manager',
body: failed > 0
? `${completed} Downloads fertig, ${failed} fehlgeschlagen`
: `${completed} Downloads abgeschlossen`
: `${completed} Downloads abgeschlossen`,
icon: path.join(__dirname, '../build/icon.png')
});
// Click brings the app to the foreground AND opens the download
// folder so the user can immediately see the output files.
@@ -6048,6 +6177,7 @@ function createWindow(): void {
minHeight: 700,
title: `Twitch VOD Manager [v${APP_VERSION}]`,
backgroundColor: '#0e0e10',
icon: path.join(__dirname, process.platform === 'win32' ? '../build/icon.ico' : '../build/icon.png'),
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
@@ -6582,7 +6712,7 @@ ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'prog
return downloadQueue;
});
ipcMain.handle('remove-from-queue', (_, id: string) => {
ipcMain.handle('remove-from-queue', async (_, id: string) => {
const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id);
if (wasActiveItem) {
@@ -6590,6 +6720,8 @@ ipcMain.handle('remove-from-queue', (_, id: string) => {
const tracking = activeDownloads.get(id);
if (tracking?.process) {
tracking.process.kill();
await tracking.output.cancel();
partialDownloadRegistry.discard(tracking.partialFilename);
}
activeDownloads.delete(id);
activeQueueItemId = null;
@@ -6762,6 +6894,20 @@ ipcMain.handle('create-merge-group', (_, itemIds: string[]) => {
});
ipcMain.handle('start-download', async () => {
if (isDownloading && queuePaused) {
queuePaused = false;
for (const item of downloadQueue) {
if (item.status === 'paused') item.status = 'downloading';
}
for (const [id, tracking] of activeDownloads) {
tracking.output.resume();
}
saveQueue(downloadQueue);
emitQueueUpdated(true);
mainWindow?.webContents.send('download-started');
return true;
}
downloadQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' } : item);
const hasPendingItems = downloadQueue.some(item => item.status === 'pending');
@@ -6780,31 +6926,37 @@ ipcMain.handle('start-download', async () => {
});
ipcMain.handle('pause-download', () => {
if (!isDownloading) return false;
if (!isDownloading || queuePaused) return false;
pauseRequested = true;
// Kill queue downloads only — cutter/merger/splitter use currentEditorProcess
// and aren't affected by pause-download. Per-item cancel state lives in
// cancelledItemIds — every active item gets added below.
for (const [id, tracking] of activeDownloads) {
cancelledItemIds.add(id);
if (tracking.process) {
tracking.process.kill();
queuePaused = true;
for (const [, tracking] of activeDownloads) {
tracking.output.pause();
}
for (const item of downloadQueue) {
if (item.status === 'downloading') {
item.status = 'paused';
item.speed = '';
item.eta = '';
item.progressStatus = tBackend('downloadPaused');
}
}
saveQueue(downloadQueue);
emitQueueUpdated(true);
mainWindow?.webContents.send('download-paused');
return true;
});
ipcMain.handle('cancel-download', () => {
ipcMain.handle('cancel-download', async () => {
isDownloading = false;
pauseRequested = false;
// Kill queue downloads only — see pause-download note above.
for (const [id, tracking] of activeDownloads) {
queuePaused = false;
await Promise.all([...activeDownloads].map(async ([id, tracking]) => {
cancelledItemIds.add(id);
if (tracking.process) {
tracking.process.kill();
await tracking.output.cancel();
partialDownloadRegistry.discard(tracking.partialFilename);
}
}
}));
return true;
});
@@ -6921,7 +7073,12 @@ ipcMain.handle('open-external', async (_, url: string) => {
// Tracks active standalone clip downloads so cancel-download / window-all-closed
// can kill them. Separate from activeDownloads (queue) because clip downloads
// don't go through the queue scheduler.
const activeClipProcesses = new Map<string, ChildProcess>();
interface ActiveClipDownloadTracking {
process: ChildProcess;
output: PausableOutput;
partialFilename: string;
}
const activeClipProcesses = new Map<string, ActiveClipDownloadTracking>();
ipcMain.handle('download-clip', async (_, clipUrl: string) => {
let clipId = '';
@@ -6959,51 +7116,70 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
return new Promise<{ success: boolean; error?: string; filename?: string }>((resolve) => {
const streamlinkCmd = getStreamlinkCommand();
const partialFilename = partialDownloadRegistry.begin(filename);
const proc = spawn(streamlinkCmd.command, [
...streamlinkCmd.prefixArgs,
`https://clips.twitch.tv/${clipId}`,
getStreamlinkStreamArg(),
'-o', filename,
'--force'
'--stdout'
], { windowsHide: true });
if (!proc.stdout) {
partialDownloadRegistry.discard(partialFilename);
releaseClaimedFilenamesForItem(clipId);
resolve({ success: false, error: tBackend('unknownDownloadError') });
return;
}
const output = createPausableOutput(proc.stdout, fs.createWriteStream(partialFilename, { flags: 'w' }));
const outputFinished = output.finished.then(() => null, (error) => error);
activeClipProcesses.set(clipId, proc);
activeClipProcesses.set(clipId, { process: proc, output, partialFilename });
appendDebugLog('clip-download-start', { clipId, broadcaster: safeBroadcaster, filename });
proc.on('close', (code) => {
proc.on('close', async (code) => {
activeClipProcesses.delete(clipId);
releaseClaimedFilenamesForItem(clipId);
const outputError = await outputFinished;
if (code !== 0 || !fs.existsSync(filename)) {
if (outputError || code !== 0 || !fs.existsSync(partialFilename)) {
partialDownloadRegistry.discard(partialFilename);
appendDebugLog('clip-download-failed', { clipId, code });
resolve({ success: false, error: tBackend('downloadFailedExitCode', { code: String(code ?? -1) }) });
resolve({ success: false, error: outputError ? String(outputError) : tBackend('downloadFailedExitCode', { code: String(code ?? -1) }) });
return;
}
// Integrity: clips are short but should still be at least a few KB
// and parse as a video stream via ffprobe. Empty/zero-byte files
// were previously reported as "success" because exit code was 0.
const stats = fs.statSync(filename);
const stats = fs.statSync(partialFilename);
if (stats.size < 16 * 1024) {
try { fs.unlinkSync(filename); } catch { }
partialDownloadRegistry.discard(partialFilename);
appendDebugLog('clip-download-too-small', { clipId, bytes: stats.size });
resolve({ success: false, error: tBackend('clipFileTooSmall', { bytes: String(stats.size) }) });
return;
}
const integrity = validateDownloadedFileIntegrity(filename, null);
const integrity = validateDownloadedFileIntegrity(partialFilename, null);
if (!integrity.success) {
try { fs.unlinkSync(filename); } catch { }
partialDownloadRegistry.discard(partialFilename);
appendDebugLog('clip-download-integrity-failed', { clipId, error: integrity.error });
resolve({ success: false, error: integrity.error || tBackend('integrityFailedGeneric') });
return;
}
try {
partialDownloadRegistry.commit(partialFilename, filename);
} catch (error) {
partialDownloadRegistry.discard(partialFilename);
resolve({ success: false, error: String(error) });
return;
}
appendDebugLog('clip-download-success', { clipId, bytes: stats.size, filename });
resolve({ success: true, filename });
});
proc.on('error', () => {
proc.on('error', async () => {
await output.cancel();
partialDownloadRegistry.discard(partialFilename);
activeClipProcesses.delete(clipId);
releaseClaimedFilenamesForItem(clipId);
resolve({ success: false, error: tBackend('streamlinkNotFound') });
@@ -7036,6 +7212,10 @@ ipcMain.handle('get-streamer-profile', async (_, login: string, forceRefresh?: b
return await getStreamerProfile(login, forceRefresh === true);
});
ipcMain.handle('get-streamer-display-names', async (_, logins: string[]): Promise<Record<string, string>> => {
return await getStreamerDisplayNames(Array.isArray(logins) ? logins : []);
});
ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise<VodStoryboard | null> => {
return await getVodStoryboard(vodId);
});
@@ -7128,7 +7308,7 @@ ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => {
return isDownloadPathWritable(folderPath);
});
ipcMain.handle('is-downloading', () => isDownloading);
ipcMain.handle('is-downloading', () => isDownloading && !queuePaused);
ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot());
@@ -7316,7 +7496,10 @@ let appDb: DbHandle | null = null;
export function getAppDb(): DbHandle | null { return appDb; }
app.whenReady().then(() => {
app.setAppUserModelId('com.twitch.vodmanager');
const removedPartialDownloads = partialDownloadRegistry.cleanup();
if (removedPartialDownloads.length > 0) {
appendDebugLog('partial-downloads-cleaned-on-startup', { count: removedPartialDownloads.length });
}
refreshBundledToolPaths(true);
startMetadataCacheCleanup();
startDebugLogFlushTimer();
@@ -7342,6 +7525,7 @@ app.whenReady().then(() => {
restartLiveStatusPoller();
restartAutoCleanupTimer();
createWindow();
startDevelopmentReload();
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
app.on('activate', () => {
@@ -7357,8 +7541,10 @@ app.whenReady().then(() => {
// a single idempotent helper means any future tweak (e.g. flushing a new
// debug stream) lands once and applies on every quit path.
let shutdownCleanupDone = false;
let quitAfterCleanup = false;
let shutdownPromise: Promise<void> | null = null;
function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
async function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): Promise<void> {
if (shutdownCleanupDone) return;
shutdownCleanupDone = true;
@@ -7376,16 +7562,20 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
// and any in-flight cutter/merger/splitter ffmpeg. before-quit used to
// skip this entirely; window-all-closed did it but only via direct
// kill() (no try/catch around the queue process kill).
for (const [, tracking] of activeDownloads) {
await Promise.all([...activeDownloads.values()].map(async (tracking) => {
if (tracking.process) {
try { tracking.process.kill(); } catch { /* already exited */ }
}
}
try { await tracking.output.cancel(); } catch { }
try { partialDownloadRegistry.discard(tracking.partialFilename); } catch { }
}));
activeDownloads.clear();
for (const [, proc] of activeClipProcesses) {
try { proc.kill(); } catch { /* already exited */ }
}
await Promise.all([...activeClipProcesses.values()].map(async (tracking) => {
try { tracking.process.kill(); } catch { }
try { await tracking.output.cancel(); } catch { }
try { partialDownloadRegistry.discard(tracking.partialFilename); } catch { }
}));
activeClipProcesses.clear();
if (currentEditorProcess) {
@@ -7409,12 +7599,17 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
}
app.on('window-all-closed', () => {
shutdownCleanup('window-all-closed');
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', () => {
shutdownCleanup('before-quit');
app.on('before-quit', (event) => {
if (quitAfterCleanup) return;
event.preventDefault();
if (shutdownPromise) return;
shutdownPromise = shutdownCleanup('before-quit').finally(() => {
quitAfterCleanup = true;
app.quit();
});
});
+41
View File
@@ -0,0 +1,41 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as ResEdit from 'resedit';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { prepareWindowsDevExecutable } from './dev-executable';
let tempDirectory: string;
beforeEach(() => {
tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-dev-exe-'));
});
afterEach(() => {
fs.rmSync(tempDirectory, { recursive: true, force: true });
});
describe.runIf(process.platform === 'win32')('prepareWindowsDevExecutable', () => {
it('bettet App-Icon und Produktnamen in die gestartete Entwicklungs-EXE ein', async () => {
const sourcePath = path.join(process.env.WINDIR || 'C:\\Windows', 'System32', 'where.exe');
const destinationPath = path.join(tempDirectory, 'Twitch VOD Manager.exe');
const iconPath = path.resolve('build/icon.ico');
await prepareWindowsDevExecutable({
sourcePath,
destinationPath,
iconPath,
version: '1.0.3'
});
const executable = ResEdit.NtExecutable.from(fs.readFileSync(destinationPath));
const resources = ResEdit.NtExecutableResource.from(executable);
const iconGroups = ResEdit.Resource.IconGroupEntry.fromEntries(resources.entries);
const versionInfo = ResEdit.Resource.VersionInfo.fromEntries(resources.entries)[0];
const strings = versionInfo.getStringValues(versionInfo.getAllLanguagesForStringValues()[0]);
expect(iconGroups[0].icons).toHaveLength(6);
expect(strings.ProductName).toBe('Twitch VOD Manager');
expect(strings.FileDescription).toBe('Twitch VOD Manager');
});
});
+71
View File
@@ -0,0 +1,71 @@
import * as fs from 'fs';
import * as path from 'path';
import * as ResEdit from 'resedit';
import { writeFileAtomicSync } from './infra/fs-atomic';
export interface WindowsDevExecutableOptions {
sourcePath: string;
destinationPath: string;
iconPath: string;
version: string;
}
export async function prepareWindowsDevExecutable(options: WindowsDevExecutableOptions): Promise<string> {
const source = path.resolve(options.sourcePath);
const destination = path.resolve(options.destinationPath);
const icon = path.resolve(options.iconPath);
const stampPath = `${destination}.json`;
const sourceStats = fs.statSync(source);
const iconStats = fs.statSync(icon);
const fingerprint = JSON.stringify({
sourceSize: sourceStats.size,
sourceModified: sourceStats.mtimeMs,
iconSize: iconStats.size,
iconModified: iconStats.mtimeMs,
version: options.version
});
if (fs.existsSync(destination) && fs.existsSync(stampPath) && fs.readFileSync(stampPath, 'utf8') === fingerprint) {
return destination;
}
fs.mkdirSync(path.dirname(destination), { recursive: true });
const temporaryDestination = `${destination}.tmp`;
fs.copyFileSync(source, temporaryDestination);
try {
const executable = ResEdit.NtExecutable.from(fs.readFileSync(temporaryDestination), { ignoreCert: true });
const resources = ResEdit.NtExecutableResource.from(executable);
const versionEntries = ResEdit.Resource.VersionInfo.fromEntries(resources.entries);
const versionInfo = versionEntries[0] || ResEdit.Resource.VersionInfo.createEmpty();
const languages = versionInfo.getAllLanguagesForStringValues();
const language = languages[0] || { lang: 0x0409, codepage: 1200 };
versionInfo.setStringValues(language, {
FileDescription: 'Twitch VOD Manager',
ProductName: 'Twitch VOD Manager',
InternalName: 'Twitch VOD Manager',
OriginalFilename: 'Twitch VOD Manager.exe'
});
versionInfo.setFileVersion(options.version);
versionInfo.setProductVersion(options.version);
versionInfo.outputToResourceEntries(resources.entries);
const iconFile = ResEdit.Data.IconFile.from(fs.readFileSync(icon));
ResEdit.Resource.IconGroupEntry.replaceIconsForResource(
resources.entries,
1,
language.lang,
iconFile.icons.map((entry) => entry.data)
);
resources.outputResource(executable);
fs.writeFileSync(temporaryDestination, Buffer.from(executable.generate()));
fs.rmSync(destination, { force: true });
fs.renameSync(temporaryDestination, destination);
writeFileAtomicSync(stampPath, fingerprint);
return destination;
} catch (error) {
fs.rmSync(temporaryDestination, { force: true });
throw error;
}
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, test } from 'vitest';
import { isRendererReloadTarget } from './dev-reload';
describe('isRendererReloadTarget', () => {
test('reloads renderer output and static renderer assets', () => {
expect(isRendererReloadTarget('renderer.js')).toBe(true);
expect(isRendererReloadTarget('renderer-settings.js')).toBe(true);
expect(isRendererReloadTarget('index.html')).toBe(true);
expect(isRendererReloadTarget('styles.css')).toBe(true);
});
test('does not reload for main-process output', () => {
expect(isRendererReloadTarget('main.js')).toBe(false);
expect(isRendererReloadTarget('preload.js')).toBe(false);
expect(isRendererReloadTarget('main/domain/config.js')).toBe(false);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { watch, type FSWatcher } from 'node:fs';
const staticRendererAssets = new Set(['index.html', 'styles.css', 'workspace.css']);
export function isRendererReloadTarget(fileName: string): boolean {
const normalized = fileName.replaceAll('\\', '/');
const baseName = normalized.split('/').at(-1) ?? '';
return staticRendererAssets.has(baseName) || /^renderer(?:[-.].+)?\.js$/.test(baseName);
}
export function watchRendererChanges(
outputDirectory: string,
sourceDirectory: string,
reload: () => void,
): () => void {
let reloadTimer: NodeJS.Timeout | undefined;
const scheduleReload = (fileName: string | Buffer | null): void => {
if (!fileName || !isRendererReloadTarget(fileName.toString())) return;
if (reloadTimer) clearTimeout(reloadTimer);
reloadTimer = setTimeout(reload, 125);
};
const watchers: FSWatcher[] = [
watch(outputDirectory, { recursive: true }, (_, fileName) => scheduleReload(fileName)),
watch(sourceDirectory, { recursive: true }, (_, fileName) => scheduleReload(fileName)),
];
return () => {
if (reloadTimer) clearTimeout(reloadTimer);
for (const watcher of watchers) watcher.close();
};
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, test } from 'vitest';
import { getWindowsAppIdentity } from './app-identity';
describe('getWindowsAppIdentity', () => {
test('trennt Hot-Dev von der veröffentlichten Windows-Identität', () => {
expect(getWindowsAppIdentity(true)).toEqual({
name: 'Twitch VOD Manager',
appUserModelId: 'io.github.sucukdeluxe.twitch-vod-manager.development'
});
expect(getWindowsAppIdentity(false)).toEqual({
name: 'Twitch VOD Manager',
appUserModelId: 'io.github.sucukdeluxe.twitch-vod-manager'
});
});
});
+13
View File
@@ -0,0 +1,13 @@
export interface WindowsAppIdentity {
name: string;
appUserModelId: string;
}
export function getWindowsAppIdentity(isDevelopment: boolean): WindowsAppIdentity {
return {
name: 'Twitch VOD Manager',
appUserModelId: isDevelopment
? 'io.github.sucukdeluxe.twitch-vod-manager.development'
: 'io.github.sucukdeluxe.twitch-vod-manager'
};
}
+8
View File
@@ -41,6 +41,14 @@ describe('tBackend', () => {
}
});
test('German backend messages use native umlauts', () => {
const text = Object.values(BACKEND_MESSAGES.de).join('\n').toLocaleLowerCase('de-DE');
const forbidden = ['ungueltig', 'integritaetspruefung', 'fur ', 'benoetigt', 'prufe '];
for (const token of forbidden) {
expect(text).not.toContain(token);
}
});
test('no template literal left after substitution for typical params', () => {
// attemptFailed has {attempt}, {max}, {errorClass}, {error}
const result = tBackend('attemptFailed', { attempt: 1, max: 3, errorClass: 'network', error: 'ETIMEDOUT' }, 'en');
+11 -11
View File
@@ -1,10 +1,10 @@
// Backend-Messages (User-visible aus main.ts produziert). Pure: Sprache wird
// als Parameter uebergeben statt aus globalem config geholt.
// als Parameter übergeben statt aus globalem config geholt.
export const BACKEND_MESSAGES = {
de: {
invalidVodUrl: 'Ungueltige VOD-URL',
invalidClipUrl: 'Ungueltige Clip-URL',
invalidVodUrl: 'Ungültige VOD-URL',
invalidClipUrl: 'Ungültige Clip-URL',
clipNotFound: 'Clip nicht gefunden',
streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.',
streamlinkMissing: 'Streamlink fehlt.',
@@ -15,10 +15,10 @@ export const BACKEND_MESSAGES = {
ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.',
fileTooSmall: 'Datei zu klein ({bytes} Bytes)',
clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.',
integrityNoVideo: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.',
integrityTooShort: 'Integritaetspruefung fehlgeschlagen: Dauer zu kurz ({duration}s).',
integrityDurationMismatch: 'Integritaetspruefung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
integrityFailedGeneric: 'Integritaetspruefung fehlgeschlagen.',
integrityNoVideo: 'Integritätsprüfung fehlgeschlagen: Kein Videostream gefunden.',
integrityTooShort: 'Integritätsprüfung fehlgeschlagen: Dauer zu kurz ({duration}s).',
integrityDurationMismatch: 'Integritätsprüfung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
integrityFailedGeneric: 'Integritätsprüfung fehlgeschlagen.',
downloadCancelled: 'Download wurde abgebrochen.',
downloadPaused: 'Download wurde pausiert.',
downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})',
@@ -26,12 +26,12 @@ export const BACKEND_MESSAGES = {
notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.',
mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.',
diskSpaceShortFor: 'Zu wenig Speicherplatz fur {context}: frei {free}, benoetigt ~{required}.',
diskSpaceShortFor: 'Zu wenig Speicherplatz für {context}: frei {free}, benötigt ~{required}.',
diskSpaceShortGeneric: 'Zu wenig Speicherplatz.',
attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}',
retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...',
statusCheckingTools: 'Prufe Download-Tools...',
statusDownloadStarted: 'Download gestartet',
statusCheckingTools: 'Prüfe Download-Tools...',
statusDownloadStarted: 'Download wird gestartet',
statusBytesDownloaded: '{bytes} heruntergeladen',
statusFetchingChatReplay: 'Chat-Replay wird heruntergeladen...',
statusChatMessagesFetched: 'Chat-Nachrichten geladen: {count}',
@@ -70,7 +70,7 @@ export const BACKEND_MESSAGES = {
attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}',
retryingIn: 'Retrying in {seconds}s ({errorClass})...',
statusCheckingTools: 'Checking download tools...',
statusDownloadStarted: 'Download started',
statusDownloadStarted: 'Starting download ',
statusBytesDownloaded: '{bytes} downloaded',
statusFetchingChatReplay: 'Fetching chat replay...',
statusChatMessagesFetched: 'Chat messages fetched: {count}',
+1 -1
View File
@@ -36,7 +36,7 @@ const CONFIG_KV_KEYS = [
'auto_record_poll_seconds', 'filename_template_vod', 'filename_template_parts',
'filename_template_clip', 'smart_queue_scheduler', 'prevent_duplicate_downloads',
'persist_queue_on_restart', 'auto_resume_queue_on_startup',
'notify_on_each_completion',
'notify_on_each_completion', 'sidebar_split_view',
] as const;
function backupOnce(srcPath: string): void {
+64
View File
@@ -0,0 +1,64 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { PartialDownloadRegistry } from './partial-download';
let tempDirectory: string;
let registryPath: string;
beforeEach(() => {
tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-partial-'));
registryPath = path.join(tempDirectory, 'partial-downloads.json');
});
afterEach(() => {
fs.rmSync(tempDirectory, { recursive: true, force: true });
});
describe('PartialDownloadRegistry', () => {
it('veröffentlicht erst nach erfolgreichem Commit den endgültigen Dateinamen', () => {
const registry = new PartialDownloadRegistry(registryPath);
const finalPath = path.join(tempDirectory, 'video.mp4');
const partialPath = registry.begin(finalPath);
fs.writeFileSync(partialPath, 'vollständig');
expect(partialPath).toBe(`${finalPath}.tvm-part`);
expect(fs.existsSync(finalPath)).toBe(false);
registry.commit(partialPath, finalPath);
expect(fs.readFileSync(finalPath, 'utf8')).toBe('vollständig');
expect(fs.existsSync(partialPath)).toBe(false);
expect(fs.existsSync(registryPath)).toBe(false);
});
it('entfernt eine abgebrochene Teil-Datei', () => {
const registry = new PartialDownloadRegistry(registryPath);
const finalPath = path.join(tempDirectory, 'abbruch.mp4');
const partialPath = registry.begin(finalPath);
fs.writeFileSync(partialPath, 'unvollständig');
registry.discard(partialPath);
expect(fs.existsSync(partialPath)).toBe(false);
expect(fs.existsSync(finalPath)).toBe(false);
expect(fs.existsSync(registryPath)).toBe(false);
});
it('räumt nach einem simulierten Crash registrierte Teil-Dateien beim nächsten Start auf', () => {
const firstRun = new PartialDownloadRegistry(registryPath);
const finalPath = path.join(tempDirectory, 'crash.mp4');
const partialPath = firstRun.begin(finalPath);
fs.writeFileSync(partialPath, 'unvollständig');
const secondRun = new PartialDownloadRegistry(registryPath);
const removed = secondRun.cleanup();
expect(removed).toEqual([partialPath]);
expect(fs.existsSync(partialPath)).toBe(false);
expect(fs.existsSync(finalPath)).toBe(false);
expect(fs.existsSync(registryPath)).toBe(false);
});
});
+83
View File
@@ -0,0 +1,83 @@
import * as fs from 'fs';
import * as path from 'path';
import { writeFileAtomicSync } from '../infra/fs-atomic';
const PARTIAL_SUFFIX = '.tvm-part';
export class PartialDownloadRegistry {
private readonly paths = new Set<string>();
constructor(private readonly registryPath: string) {
this.load(this.registryPath);
this.load(`${this.registryPath}.tmp`);
}
begin(finalPath: string): string {
const partialPath = path.resolve(`${finalPath}${PARTIAL_SUFFIX}`);
if (fs.existsSync(partialPath)) fs.rmSync(partialPath, { force: true });
this.paths.add(partialPath);
this.persist();
return partialPath;
}
commit(partialPath: string, finalPath: string): void {
const normalizedPartialPath = this.normalize(partialPath);
const normalizedFinalPath = path.resolve(finalPath);
if (!fs.existsSync(normalizedPartialPath)) throw new Error(`Teil-Datei fehlt: ${normalizedPartialPath}`);
if (fs.existsSync(normalizedFinalPath)) throw new Error(`Zieldatei existiert bereits: ${normalizedFinalPath}`);
fs.renameSync(normalizedPartialPath, normalizedFinalPath);
this.paths.delete(normalizedPartialPath);
this.persist();
}
discard(partialPath: string): void {
const normalizedPartialPath = this.normalize(partialPath);
if (fs.existsSync(normalizedPartialPath)) fs.rmSync(normalizedPartialPath, { force: true });
this.paths.delete(normalizedPartialPath);
this.persist();
}
cleanup(): string[] {
const removed: string[] = [];
for (const partialPath of this.paths) {
if (fs.existsSync(partialPath)) {
fs.rmSync(partialPath, { force: true });
removed.push(partialPath);
}
}
this.paths.clear();
this.persist();
return removed;
}
private normalize(filePath: string): string {
const normalizedPath = path.resolve(filePath);
if (!normalizedPath.endsWith(PARTIAL_SUFFIX)) throw new Error(`Ungültiger Teil-Dateipfad: ${normalizedPath}`);
return normalizedPath;
}
private load(filePath: string): void {
try {
if (!fs.existsSync(filePath)) return;
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const values = Array.isArray(parsed) ? parsed : parsed?.paths;
if (!Array.isArray(values)) return;
for (const value of values) {
if (typeof value !== 'string') continue;
try {
this.paths.add(this.normalize(value));
} catch { }
}
} catch { }
}
private persist(): void {
if (this.paths.size === 0) {
fs.rmSync(this.registryPath, { force: true });
fs.rmSync(`${this.registryPath}.tmp`, { force: true });
return;
}
fs.mkdirSync(path.dirname(this.registryPath), { recursive: true });
writeFileAtomicSync(this.registryPath, JSON.stringify({ paths: [...this.paths] }, null, 2));
}
}
+89
View File
@@ -0,0 +1,89 @@
import { PassThrough, Writable } from 'stream';
import { describe, expect, it } from 'vitest';
import { createPausableOutput } from './pausable-output';
function waitForTurn(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
describe('createPausableOutput', () => {
it('setzt denselben Ausgabestrom ohne Datenverlust fort', async () => {
const source = new PassThrough();
const chunks: Buffer[] = [];
const target = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback();
}
});
const output = createPausableOutput(source, target);
source.write('erster-');
await waitForTurn();
output.pause();
source.write('zweiter-');
await waitForTurn();
expect(Buffer.concat(chunks).toString()).toBe('erster-');
output.resume();
source.end('dritter');
await output.finished;
expect(Buffer.concat(chunks).toString()).toBe('erster-zweiter-dritter');
expect(output.isPaused()).toBe(false);
});
it('schließt einen während der Pause beendeten Quellstrom erst nach dem Fortsetzen ab', async () => {
const source = new PassThrough();
const chunks: Buffer[] = [];
const target = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback();
}
});
const output = createPausableOutput(source, target);
let finished = false;
void output.finished.then(() => {
finished = true;
});
source.write('vorher-');
await waitForTurn();
output.pause();
source.end('nachher');
await waitForTurn();
expect(finished).toBe(false);
expect(Buffer.concat(chunks).toString()).toBe('vorher-');
output.resume();
await output.finished;
expect(Buffer.concat(chunks).toString()).toBe('vorher-nachher');
});
it('schließt Quell- und Zielstrom bei einem Abbruch zuverlässig', async () => {
const source = new PassThrough();
const chunks: Buffer[] = [];
const target = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback();
}
});
const output = createPausableOutput(source, target);
source.write('behalten');
await waitForTurn();
output.pause();
await output.cancel();
await output.finished;
expect(source.destroyed).toBe(true);
expect(target.destroyed).toBe(true);
expect(Buffer.concat(chunks).toString()).toBe('behalten');
});
});
+74
View File
@@ -0,0 +1,74 @@
import { Readable, Writable } from 'stream';
export interface PausableOutput {
pause(): void;
resume(): void;
cancel(): Promise<void>;
isPaused(): boolean;
finished: Promise<void>;
}
export function createPausableOutput(source: Readable, target: Writable): PausableOutput {
let paused = false;
let settled = false;
let resolveFinished: () => void = () => {};
let rejectFinished: (error: Error) => void = () => {};
let resolveClosed: () => void = () => {};
const finished = new Promise<void>((resolve, reject) => {
resolveFinished = resolve;
rejectFinished = reject;
});
const closed = new Promise<void>((resolve) => {
resolveClosed = resolve;
});
const attach = () => source.pipe(target, { end: false });
const finish = () => {
if (!settled) target.end();
};
target.once('finish', () => {
settled = true;
resolveFinished();
});
target.once('close', () => {
resolveClosed();
if (!settled) {
settled = true;
resolveFinished();
}
});
target.once('error', (error) => {
settled = true;
source.destroy(error);
rejectFinished(error);
});
source.once('end', finish);
source.once('error', (error) => target.destroy(error));
attach();
return {
pause() {
if (paused || settled) return;
paused = true;
source.unpipe(target);
source.pause();
},
resume() {
if (!paused || settled) return;
paused = false;
attach();
source.resume();
},
async cancel() {
if (!settled) {
paused = false;
source.unpipe(target);
source.destroy();
target.destroy();
}
await closed;
},
isPaused: () => paused,
finished
};
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from 'vitest';
import { buildVodPreviewFrameUrls } from './vod-preview';
describe('buildVodPreviewFrameUrls', () => {
test('builds four full-HD frame URLs from a Twitch thumb0 URL', () => {
const input = 'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb0-1920x1080.jpg';
expect(buildVodPreviewFrameUrls(input)).toEqual([
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb0-1920x1080.jpg',
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb1-1920x1080.jpg',
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb2-1920x1080.jpg',
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb3-1920x1080.jpg'
]);
});
test('rejects URLs that are not Twitch full-HD VOD thumbnails', () => {
expect(buildVodPreviewFrameUrls('https://example.invalid/thumb0-320x180.jpg')).toEqual([]);
});
});
+20
View File
@@ -0,0 +1,20 @@
const TWITCH_VOD_THUMBNAIL_PATTERN = /thumb0-1920x1080\.jpg$/;
export function buildVodPreviewFrameUrls(thumbnailUrl: string): string[] {
let parsed: URL;
try {
parsed = new URL(thumbnailUrl);
} catch {
return [];
}
if (parsed.protocol !== 'https:' || parsed.hostname !== 'static-cdn.jtvnw.net' || !TWITCH_VOD_THUMBNAIL_PATTERN.test(parsed.pathname)) {
return [];
}
return [0, 1, 2, 3].map((index) => {
const frameUrl = new URL(parsed.toString());
frameUrl.pathname = frameUrl.pathname.replace('thumb0-1920x1080.jpg', `thumb${index}-1920x1080.jpg`);
return frameUrl.toString();
});
}
+1 -1
View File
@@ -32,7 +32,7 @@ const DEFAULT_SUCCESS = `<!doctype html><html><head><meta charset="utf-8"><title
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px}
h1{color:#9146FF;margin:0 0 0.5rem}</style></head>
<body><div class="box"><h1>Login erfolgreich</h1><p>Du kannst dieses Fenster jetzt schliessen.</p></div></body></html>`;
<body><div class="box"><h1>Login erfolgreich</h1><p>Du kannst dieses Fenster jetzt schließen.</p></div></body></html>`;
const DEFAULT_ERROR = `<!doctype html><html><head><meta charset="utf-8"><title>Fehler</title>
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
+4
View File
@@ -93,6 +93,7 @@ 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),
getStreamerDisplayNames: (logins: string[]) => ipcRenderer.invoke('get-streamer-display-names', logins),
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) => {
@@ -151,6 +152,9 @@ contextBridge.exposeInMainWorld('api', {
onDownloadStarted: (callback: () => void) => {
ipcRenderer.on('download-started', () => callback());
},
onDownloadPaused: (callback: () => void) => {
ipcRenderer.on('download-paused', () => callback());
},
onDownloadFinished: (callback: () => void) => {
ipcRenderer.on('download-finished', () => callback());
},
+7
View File
@@ -3,10 +3,12 @@ interface AppConfig {
client_secret?: string;
download_path?: string;
streamers?: string[];
streamer_display_names?: Record<string, string>;
theme?: string;
download_mode?: 'parts' | 'full';
part_minutes?: number;
language?: 'de' | 'en';
sidebar_split_view?: boolean;
filename_template_vod?: string;
filename_template_parts?: string;
filename_template_clip?: string;
@@ -257,6 +259,9 @@ interface StreamerProfile {
interface VodStoryboard {
vodId: string;
spriteDataUrl: string;
frameDataUrls: string[];
frameWidth: number;
frameHeight: number;
cols: number;
rows: number;
cellWidth: number;
@@ -346,6 +351,7 @@ interface ApiBridge {
getStorageStats(): Promise<StorageStatsResult>;
getArchiveStats(): Promise<ArchiveStats>;
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
getStreamerDisplayNames(logins: string[]): Promise<Record<string, string>>;
getVodStoryboard(vodId: string): Promise<VodStoryboard | null>;
getLiveStatusSnapshot(): Promise<Record<string, boolean>>;
onLiveStatusBatchUpdate(callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void): void;
@@ -388,6 +394,7 @@ interface ApiBridge {
onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void;
onDownloadStarted(callback: () => void): void;
onDownloadPaused(callback: () => void): void;
onDownloadFinished(callback: () => void): void;
onCutProgress(callback: (percent: number) => void): void;
onMergeProgress(callback: (percent: number) => void): void;
+130 -117
View File
@@ -4,7 +4,7 @@ const UI_TEXT_DE = {
navVods: 'Twitch VODs',
navClips: 'Twitch Clips',
navCutter: 'Video schneiden',
navMerge: 'Videos zusammenfugen',
navMerge: 'Videos zusammenfügen',
navSettings: 'Einstellungen',
queueTitle: 'Warteschlange',
retryFailed: 'Wiederholen',
@@ -14,7 +14,7 @@ const UI_TEXT_DE = {
healthWarn: 'System: Warnung',
healthBad: 'System: Problem',
systemStatus: 'Systemstatus',
openSettings: 'Einstellungen offnen',
openSettings: 'Einstellungen öffnen',
refreshVods: 'VODs aktualisieren',
downloadClip: 'Clip herunterladen',
mainNavigationAria: 'Hauptnavigation',
@@ -32,12 +32,12 @@ const UI_TEXT_DE = {
settingsSearchPlaceholder: 'Einstellungen durchsuchen…',
clearQueue: 'Leeren',
refresh: 'Aktualisieren',
streamerPlaceholder: 'Streamer hinzufugen…',
streamerPlaceholder: 'Streamer hinzufügen…',
clipsHeading: 'Twitch Clip-Download',
clipsInfoTitle: 'Info',
clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.',
cutterSelectTitle: 'Video auswahlen',
cutterPreviewPlaceholder: 'Video auswahlen um Vorschau zu sehen',
cutterSelectTitle: 'Video auswählen',
cutterPreviewPlaceholder: 'Video auswählen, um eine Vorschau zu sehen',
cutterBrowse: 'Durchsuchen',
commandPaletteTitle: 'Befehlspalette',
commandPaletteAria: 'Befehlspalette',
@@ -54,10 +54,10 @@ const UI_TEXT_DE = {
settings: { label: 'Einstellungen', keywords: 'einstellungen settings config' }
},
commandPaletteSearchPlaceholder: 'Befehl suchen...',
commandPaletteHint: 'Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen',
mergeTitle: 'Videos zusammenfugen',
mergeDesc: 'Wahle mehrere Videos aus, um sie zu einem Video zusammenzufugen. Die Reihenfolge kann geandert werden.',
mergeAdd: '+ Videos hinzufugen',
commandPaletteHint: 'Auf/Ab zum Navigieren, Enter zum Ausführen, Esc zum Schließen',
mergeTitle: 'Videos zusammenfügen',
mergeDesc: 'Wähle mehrere Videos aus, um sie zu einem Video zusammenzufügen. Die Reihenfolge kann geändert werden.',
mergeAdd: '+ Videos hinzufügen',
designTitle: 'Design',
themeLabel: 'Theme',
themeLight: 'Hell',
@@ -65,12 +65,15 @@ const UI_TEXT_DE = {
themeSystem: 'System',
languageLabel: 'Sprache',
languageDe: 'Deutsch',
languageEn: 'Englisch',
languageEn: 'English',
apiTitle: 'Twitch API',
clientIdLabel: 'Client ID',
clientSecretLabel: 'Client Secret',
saveSettings: 'Speichern & Verbinden',
downloadSettingsTitle: 'Download-Einstellungen',
downloadBasicsTitle: 'Download und Qualität',
queueAutomationTitle: 'Queue und Automatisierung',
recordingMetadataTitle: 'Aufnahmen und Metadaten',
storageLabel: 'Speicherort',
selectFolder: 'Ordner',
openFolder: 'Offnen',
@@ -86,11 +89,13 @@ const UI_TEXT_DE = {
performanceModeBalanced: 'Ausgewogen',
performanceModeSpeed: 'Max Geschwindigkeit',
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren',
smartSchedulerHint: 'Bevorzugt kuerzere VODs und aeltere Queue-Eintraege zuerst, damit der Durchsatz gleichmaessig bleibt. Deaktivieren = strikte Einfuegereihenfolge.',
streamerInvalid: 'Twitch-Username ungueltig (4-25 Zeichen, Buchstaben/Zahlen/Unterstrich).',
smartSchedulerHint: 'Bevorzugt kürzere VODs und ältere Queue-Einträge zuerst, damit der Durchsatz gleichmäßig bleibt. Deaktivieren = strikte Einfügereihenfolge.',
sidebarSplitViewLabel: 'Streamer und Queue gleichzeitig anzeigen',
sidebarSplitViewHint: 'Teilt die linke Seitenleiste gleichmäßig auf. Deaktiviert erscheinen wieder die Umschaltflächen.',
streamerInvalid: 'Twitch-Username ungültig (4-25 Zeichen, Buchstaben/Zahlen/Unterstrich).',
apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.',
apiHelpLinkText: 'dev.twitch.tv/console/apps',
openDebugLogFile: 'Log-Datei oeffnen',
openDebugLogFile: 'Log-Datei öffnen',
storageCardTitle: 'Speicher',
storageCardIntro: 'Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.',
storageRefresh: 'Aktualisieren',
@@ -103,10 +108,10 @@ const UI_TEXT_DE = {
storageColumnLive: 'Live',
storageColumnChat: 'Chat',
storageColumnActionsAria: 'Aktionen',
storageOpen: 'Oeffnen',
storageOpen: 'Öffnen',
storageOtherFolders: 'Andere Ordner im Download-Pfad',
cleanupTitle: 'Auto-Cleanup',
cleanupIntro: 'Aufnahmen aelter als X Tage in einen Archiv-Ordner verschieben oder loeschen. Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) werden mit der Aufnahme bewegt.',
cleanupIntro: 'Aufnahmen älter als X Tage in einen Archiv-Ordner verschieben oder löschen. Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) werden mit der Aufnahme bewegt.',
cleanupEnabledLabel: 'Auto-Cleanup aktivieren',
cleanupDaysLabel: 'Tage-Schwelle',
cleanupTargetLabel: 'Bereich',
@@ -114,42 +119,42 @@ const UI_TEXT_DE = {
cleanupTargetAll: 'Alle Aufnahmen',
cleanupActionLabel: 'Aktion',
cleanupActionArchive: 'In Archiv verschieben',
cleanupActionDelete: 'Loeschen',
cleanupActionDelete: 'Löschen',
cleanupDryRun: 'Vorschau',
cleanupRunNow: 'Jetzt ausfuehren',
cleanupReportPreview: 'Wuerde {count} Dateien betreffen (~{size}). Es wurden keine Dateien verschoben oder geloescht.',
cleanupRunNow: 'Jetzt ausführen',
cleanupReportPreview: 'Würde {count} Dateien betreffen (~{size}). Es wurden keine Dateien verschoben oder gelöscht.',
cleanupReportDone: '{count} Dateien verarbeitet, ~{size} frei.{failed}',
cleanupReportFailedSuffix: ' {failed} fehlgeschlagen.',
cleanupReportEmpty: 'Keine Aufnahmen aelter als {days} Tage gefunden.',
cleanupReportEmpty: 'Keine Aufnahmen älter als {days} Tage gefunden.',
discordCardTitle: 'Discord-Webhook',
discordCardIntro: 'Sende Benachrichtigungen an einen Discord-Channel via Webhook - nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.',
discordCardIntro: 'Sende Benachrichtigungen an einen Discord-Channel via Webhook - nützlich für Multi-Device-Setups oder eine dedizierte Archiv-Maschine.',
discordWebhookUrlLabel: 'Webhook-URL',
discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen',
discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen',
discordNotifyVodAutoQueuedLabel: 'Bei automatisch eingereihten VODs benachrichtigen',
autoResumeLiveRecordingLabel: 'Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)',
autoMergeResumedPartsLabel: 'Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat, kein Re-Encode)',
deletePartsAfterMergeLabel: 'Einzelne Parts nach erfolgreichem Merge loeschen',
autoMergeResumedPartsLabel: 'Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfügen (ffmpeg concat, kein Re-Encode)',
deletePartsAfterMergeLabel: 'Einzelne Parts nach erfolgreichem Merge löschen',
autoVodCardTitle: 'Auto-VOD-Download',
autoVodCardIntro: 'Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.',
autoVodCardIntro: 'Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprüft. Neue VODs innerhalb des Altersfensters werden automatisch zur Download-Queue hinzugefügt.',
autoVodPollMinutesLabel: 'Poll-Intervall (Minuten)',
autoVodMaxAgeHoursLabel: 'Max. Alter (Stunden)',
autoVodScanNow: 'Jetzt scannen',
autoRecordScanNow: 'Live-Status pruefen',
autoRecordScanNow: 'Live-Status prüfen',
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 über den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.',
statsRefresh: 'Aktualisieren',
statsScanning: 'Scanne...',
statsScannedAt: 'Letzter Scan',
statsSummaryTitle: 'Uebersicht',
statsTopStreamersTitle: 'Top Streamer (nach Groesse)',
statsActivityTitle: 'Aktivitaet (letzte 30 Tage)',
statsSizeBucketsTitle: 'Aufnahme-Groessen-Verteilung',
statsSummaryTitle: 'Übersicht',
statsTopStreamersTitle: 'Top Streamer (nach Größe)',
statsActivityTitle: 'Aktivität (letzte 30 Tage)',
statsSizeBucketsTitle: 'Aufnahme-Größen-Verteilung',
statsTotalRecordings: 'Aufnahmen gesamt',
statsLiveRecordings: 'Live-Aufnahmen',
statsVodRecordings: 'VOD-Downloads',
statsStreamers: 'Streamer',
statsAvgSize: 'Durchschn. Groesse',
statsAvgSize: 'Durchschn. Größe',
statsChatFiles: 'Chat-Dateien',
statsFiles: 'Dateien',
statsActivityEmpty: 'Keine Aufnahmen in den letzten 30 Tagen.',
@@ -159,65 +164,65 @@ const UI_TEXT_DE = {
navStats: 'Statistik',
navArchive: 'Archiv',
archiveTitle: 'Archiv durchsuchen',
archiveIntro: 'Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.',
archiveIntro: 'Suche nach Dateinamen, Streamern oder Datumsangaben. Treffer zeigen Aufnahmen (Live + VOD); zugehörige Chat- und Event-Dateien werden als zusätzliche Schaltflächen angeboten.',
archiveAllTypes: 'Alle Typen',
archiveTypeLive: 'Live-Aufnahmen',
archiveTypeVod: 'VOD-Downloads',
archiveAllStreamers: 'Alle Streamer',
archiveSortDateDesc: 'Neueste zuerst',
archiveSortDateAsc: 'Aelteste zuerst',
archiveSortSizeDesc: 'Groesste zuerst',
archiveSortDateAsc: 'Älteste zuerst',
archiveSortSizeDesc: 'Größte zuerst',
archiveSortSizeAsc: 'Kleinste zuerst',
archiveSortNameAsc: 'Name (A-Z)',
archiveSearchBtn: 'Suchen',
archiveSearching: 'Scanne...',
archiveSummary: '{matchCount} Treffer (gescannt: {scanned} Dateien)',
archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche fuer mehr)',
archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche für mehr)',
archiveNoMatches: 'Keine Treffer.',
archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
archiveSearchPlaceholder: 'Suche…',
archiveSearchAria: 'Archiv durchsuchen',
archiveResults: 'Ergebnisse',
archiveOpen: 'Oeffnen',
archiveOpen: 'Öffnen',
archiveShowInFolder: 'Ordner',
archiveViewChat: 'Chat',
archiveViewEvents: 'Events',
discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen',
backupCardTitle: 'Sicherung & Wartung',
backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.',
backupCardIntro: 'Konfiguration sichern, auf einem anderen Gerät wiederherstellen oder die Liste der bereits heruntergeladenen VODs zurücksetzen.',
exportConfig: 'Konfiguration exportieren',
importConfig: 'Konfiguration importieren',
resetDownloadedIds: 'Downloaded-VODs zuruecksetzen',
resetDownloadedIds: 'Downloaded-VODs zurücksetzen',
configExported: 'Konfiguration exportiert.',
configExportFailed: 'Export der Konfiguration fehlgeschlagen.',
configImported: 'Konfiguration importiert. Einige Aenderungen erfordern evtl. einen Neustart.',
configImportFailed: 'Import der Konfiguration fehlgeschlagen.',
resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zuruecksetzen? Karten verlieren das gruene Haekchen, es werden aber keine Dateien geloescht.',
resetDownloadedDone: '{count} Eintraege aus der Downloaded-Liste entfernt.',
resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zurücksetzen? Karten verlieren das grüne Häkchen, es werden aber keine Dateien gelöscht.',
resetDownloadedDone: '{count} Einträge aus der Downloaded-Liste entfernt.',
duplicatePreventionLabel: 'Duplikate in Queue verhindern',
persistQueueLabel: 'Queue zwischen App-Starts speichern',
autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen',
autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.',
autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Einträge hat, starten Downloads ~5 Sekunden nach dem Fensteröffnen. Deaktivieren = Start-Klick nötig.',
notifyEachCompletionLabel: 'Benachrichtigung bei jedem fertigen Download',
notifyEachCompletionHint: 'Standardmaessig aus — bei langen Queues wuerde das System-Notifications-Panel sonst zugespammt. Die Queue-End-Zusammenfassung erscheint trotzdem.',
streamlinkDisableAdsLabel: 'Twitch-Ads beim Download ueberspringen',
notifyEachCompletionHint: 'Standardmäßig aus — bei langen Queues würde das System-Notifications-Panel sonst zugespammt. Die Queue-End-Zusammenfassung erscheint trotzdem.',
streamlinkDisableAdsLabel: 'Twitch-Ads beim Download überspringen',
streamlinkDisableAdsHint: 'Gibt --twitch-disable-ads an streamlink weiter, damit Mid-Roll-Ads nicht ins VOD eingebettet werden. Empfohlen aktiv lassen.',
downloadChatReplayLabel: 'Chat-Replay parallel zum VOD speichern (.chat.json)',
downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der oeffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behaelt Chat-Replays nur solange wie das VOD selbst.',
captureLiveChatLabel: 'Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)',
captureLiveChatHint: 'Oeffnet waehrend einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch frueheren Inhalt nicht korrumpiert).',
downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der öffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behält Chat-Replays nur solange wie das VOD selbst.',
captureLiveChatLabel: 'Live-Chat während der Aufnahme mitschneiden (.chat.jsonl)',
captureLiveChatHint: 'Oeffnet während einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch früheren Inhalt nicht korrumpiert).',
logStreamEventsLabel: 'Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)',
logStreamEventsHint: 'Pollt den Streamer einmal pro Minute und schreibt Title-/Game-Wechsel in eine .events.jsonl-Datei neben dem Video. Hilfreich beim Suchen in langen archivierten Streams ("wann hat er auf CS:GO gewechselt?"). Sehr guenstig — ein zusaetzlicher Helix/GQL-Call pro Minute pro aktiver Aufnahme.',
streamlinkQualityLabel: 'Stream-Qualitaet',
streamlinkQualityHint: 'Streamlink versucht erst diese Qualitaet; falls das VOD sie nicht anbietet, faellt es auf "best" zurueck.',
logStreamEventsHint: 'Pollt den Streamer einmal pro Minute und schreibt Title-/Game-Wechsel in eine .events.jsonl-Datei neben dem Video. Hilfreich beim Suchen in langen archivierten Streams ("wann hat er auf CS:GO gewechselt?"). Sehr günstig — ein zusätzlicher Helix/GQL-Call pro Minute pro aktiver Aufnahme.',
streamlinkQualityLabel: 'Stream-Qualität',
streamlinkQualityHint: 'Streamlink versucht erst diese Qualität; falls das VOD sie nicht anbietet, fällt es auf "best" zurück.',
streamlinkQualityBest: 'Best (Standard)',
streamlinkQualitySource: 'Source (Original)',
streamlinkQualityAudio: 'Nur Audio',
downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Waehle einen anderen Ordner oder pruefe die Schreibrechte.',
downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Wähle einen anderen Ordner oder prüfe die Schreibrechte.',
streamerSectionTitle: 'Streamer',
streamerListFilterPlaceholder: 'Filtern...',
streamerListFilterAria: 'Streamer-Liste filtern',
streamerAddAriaLabel: 'Streamer hinzufuegen',
streamerAddAriaLabel: 'Streamer hinzufügen',
streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)',
streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?',
streamerBulkRemoveFiltered: 'Die {count} passenden Streamer aus der Liste entfernen?',
@@ -234,20 +239,20 @@ const UI_TEXT_DE = {
templateLintWarn: 'Unbekannte Platzhalter',
templateGuideButton: 'Template Guide',
templateGuideTitle: 'Dateinamen-Template Guide',
templateGuideIntro: 'Nutze Platzhalter fur Dateinamen und teste dein Muster mit einer Live-Vorschau.',
templateGuideIntro: 'Nutze Platzhalter für Dateinamen und teste dein Muster mit einer Live-Vorschau.',
templateGuideTemplateLabel: 'Template',
templateGuideOutputLabel: 'Live-Vorschau',
templateGuideVarsTitle: 'Verfugbare Platzhalter',
templateGuideVarsTitle: 'Verfügbare Platzhalter',
templateGuideVarCol: 'Platzhalter',
templateGuideDescCol: 'Beschreibung',
templateGuideExampleCol: 'Beispiel',
templateGuideUseVod: 'VOD-Template nutzen',
templateGuideUseParts: 'Teile-Template nutzen',
templateGuideUseClip: 'Clip-Template nutzen',
templateGuideClose: 'Schliessen',
templateGuideContextVod: 'Kontext: Beispiel fur kompletten VOD-Download',
templateGuideContextParts: 'Kontext: Beispiel fur VOD-Teil',
templateGuideContextClip: 'Kontext: Beispiel fur Clip-Zuschnitt',
templateGuideClose: 'Schließen',
templateGuideContextVod: 'Kontext: Beispiel für kompletten VOD-Download',
templateGuideContextParts: 'Kontext: Beispiel für VOD-Teil',
templateGuideContextClip: 'Kontext: Beispiel für Clip-Zuschnitt',
templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog',
runtimeMetricsTitle: 'Runtime Metrics',
runtimeMetricsRefresh: 'Aktualisieren',
@@ -274,7 +279,7 @@ const UI_TEXT_DE = {
preflightRun: 'Check ausfuhren',
preflightFix: 'Auto-Fix Tools',
preflightEmpty: 'Noch kein Check ausgefuhrt.',
preflightChecking: 'Prufe...',
preflightChecking: 'Prüfe...',
preflightFixing: 'Fixe...',
preflightReady: 'Alles bereit.',
preflightInternet: 'Internet',
@@ -288,16 +293,18 @@ const UI_TEXT_DE = {
notConnected: 'Nicht verbunden'
},
status: {
noLogin: 'Ohne Login (Public Modus)',
noLogin: 'Public-Modus · öffentliche VODs verfügbar',
publicHint: 'Ohne Twitch-API-Verbindung funktionieren öffentliche VODs und Downloads. Mit API-Verbindung stehen vollständigere Metadaten, mehr Verlauf und zuverlässigere Abfragen bereit.',
connecting: 'Verbinde...',
connected: 'Verbunden',
connectFailedPublic: 'Verbindung fehlgeschlagen - Public Modus aktiv'
connected: 'Twitch-API verbunden',
connectedHint: 'Die Twitch-API ist verbunden. Vollständige Metadaten und der erweiterte VOD-Verlauf sind verfügbar.',
connectFailedPublic: 'API nicht erreichbar · Public-Modus aktiv'
},
tabs: {
vods: 'VODs',
clips: 'Clips',
cutter: 'Video schneiden',
merge: 'Videos zusammenfugen',
merge: 'Videos zusammenfügen',
stats: 'Statistik',
archive: 'Archiv',
settings: 'Einstellungen'
@@ -312,24 +319,24 @@ const UI_TEXT_DE = {
resume: 'Fortsetzen',
statusDone: 'Abgeschlossen',
statusFailed: 'Fehlgeschlagen',
statusRunning: 'Laeuft',
statusRunning: 'Läuft',
statusPaused: 'Pausiert',
statusWaiting: 'Wartet',
progressError: 'Fehler',
progressReady: 'Bereit',
progressLoading: 'Lade...',
readyToDownload: 'Bereit zum Download',
started: 'Download gestartet',
started: 'Download wird gestartet',
done: 'Fertig',
failed: 'Download fehlgeschlagen',
speed: 'Geschwindigkeit',
eta: 'Restzeit',
part: 'Teil',
emptyAlert: 'Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.',
emptyAlert: 'Die Warteschlange ist leer. Füge zuerst ein VOD oder einen Clip hinzu.',
duplicateSkipped: 'Dieser Eintrag ist bereits aktiv in der Warteschlange.',
openFile: 'Datei oeffnen',
openFile: 'Datei öffnen',
showInFolder: 'Im Ordner zeigen',
openFileFailed: 'Datei konnte nicht geoeffnet werden (evtl. verschoben oder geloescht).',
openFileFailed: 'Datei konnte nicht geöffnet werden (evtl. verschoben oder gelöscht).',
outputFilesLabel: '{count} Ausgabedateien',
retryItem: 'Diesen Eintrag erneut versuchen',
viewChat: 'Chat ansehen',
@@ -338,7 +345,7 @@ const UI_TEXT_DE = {
chatViewerFilterPlaceholder: 'Chat filtern...',
chatViewerFilterAria: 'Chatnachrichten filtern',
viewChatCount: '{count} Nachrichten',
viewChatTruncatedSuffix: ' (gekuerzt)',
viewChatTruncatedSuffix: ' (gekürzt)',
viewEvents: 'Events ansehen',
viewEventsCount: '{count} Events',
viewEventsEmpty: 'Keine Events aufgezeichnet.',
@@ -349,13 +356,16 @@ const UI_TEXT_DE = {
statusBarSummary: '{downloading} aktiv, {pending} wartet',
ctxMoveTop: 'Nach oben verschieben',
ctxMoveBottom: 'Nach unten verschieben',
ctxSelectForMerge: 'Für Zusammenfügen auswählen',
ctxRemoveFromMerge: 'Aus Zusammenfügen-Auswahl entfernen',
mergeSelectionPosition: 'Zusammenfügen: Position {position}',
ctxCopyUrl: 'URL kopieren',
ctxOpenOnTwitch: 'Auf Twitch oeffnen',
ctxOpenOnTwitch: 'Auf Twitch öffnen',
ctxRemove: 'Aus Queue entfernen',
ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
liveRecordingTitle: 'Live-Aufnahme - laeuft bis der Stream endet',
liveRecordingTitle: 'Live-Aufnahme - läuft bis der Stream endet',
recordingHealth: {
ok: 'Gesund - Bytes fliessen',
ok: 'Gesund - Bytes fließen',
stale: 'Stillstand - keine Bytes mehr (Netz-Hickser oder Stream endet)',
unknown: 'Warte auf ersten Segment'
},
@@ -367,48 +377,51 @@ const UI_TEXT_DE = {
affiliate: 'Affiliate',
followers: 'Follower',
vods: 'VODs',
vodsTooltip: 'Ueber die Twitch-API sichtbare VODs dieses Kanals',
vodsTooltip: 'Über die Twitch-API sichtbare VODs dieses Kanals',
lastStream: 'Letzter Stream',
openTwitch: 'Auf Twitch oeffnen',
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen',
openTwitch: 'Auf Twitch öffnen',
openTwitchTooltip: 'Diesen Kanal auf twitch.tv öffnen',
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',
agoDay: 'vor 1 Tag',
agoDays: 'vor {n} Tagen',
agoMonth: 'vor 1 Monat',
agoMonths: 'vor {n} Monaten',
agoYear: 'vor 1 Jahr',
agoYears: 'vor {n} Jahren'
},
streamers: {
recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)',
liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.',
recordLiveTitle: 'Diesen Streamer live aufnehmen (läuft bis der Stream endet)',
liveRecordingStarted: 'Live-Aufnahme für {streamer} gestartet.',
liveRecordingOffline: '{streamer} ist gerade offline.',
liveRecordingAlreadyActive: 'Aufnahme von {streamer} laeuft bereits.',
liveRecordingAlreadyActive: 'Aufnahme von {streamer} läuft bereits.',
liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden',
autoRecordTitle: 'Auto-Aufnahme: wenn dieser Streamer live geht, nimmt die App automatisch auf',
autoRecordEnabled: 'Auto-Aufnahme aktiviert fuer {streamer}. Live-Status wird geprueft...',
autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.',
autoVodTitle: 'Neue VODs (kuerzlich veroeffentlicht) automatisch herunterladen',
autoVodEnabled: 'Auto-VOD aktiviert fuer {streamer}. Neue VODs werden automatisch geladen.',
autoVodDisabled: 'Auto-VOD fuer {streamer} deaktiviert.',
autoRecordEnabled: 'Auto-Aufnahme aktiviert für {streamer}. Live-Status wird geprüft...',
autoRecordDisabled: 'Auto-Aufnahme für {streamer} deaktiviert.',
autoVodTitle: 'Neue VODs (kürzlich veröffentlicht) automatisch herunterladen',
autoVodEnabled: 'Auto-VOD aktiviert für {streamer}. Neue VODs werden automatisch geladen.',
autoVodDisabled: 'Auto-VOD für {streamer} deaktiviert.',
autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.',
autoVodScanEmpty: 'Keine neuen VODs gefunden.',
autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.',
autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.',
liveNowTooltip: 'Aktuell live auf Twitch',
modalCloseAria: 'Dialog schliessen',
sidebarEmpty: 'Noch keine Streamer. Fuege oben rechts einen hinzu.',
modalCloseAria: 'Dialog schließen',
sidebarEmpty: 'Noch keine Streamer. Füge oben rechts einen hinzu.',
removeAria: 'Entfernen',
cutProgressAria: 'Schnitt-Fortschritt',
mergeProgressAria: 'Merge-Fortschritt',
updateProgressAria: 'Update-Download-Fortschritt'
},
vods: {
selectAriaLabel: 'VOD fuer Bulk-Aktion auswaehlen',
selectAriaLabel: 'VOD für Bulk-Aktion auswählen',
noneTitle: 'Keine VODs',
noneText: 'Wahle einen Streamer aus der Liste.',
noneText: 'Wähle einen Streamer aus der Liste.',
loading: 'Lade VODs...',
notFound: 'Streamer nicht gefunden',
noResultsTitle: 'Keine VODs gefunden',
@@ -419,22 +432,22 @@ const UI_TEXT_DE = {
trimButton: 'VOD zuschneiden',
filterPlaceholder: 'Nach Titel filtern... (Strg+F)',
filterAria: 'VOD-Titel filtern',
filterClearTitle: 'Filter loeschen (Esc)',
filterClearTitle: 'Filter löschen (Esc)',
filterNoMatchTitle: 'Keine Treffer',
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.',
filterMatchCount: '{shown} von {total} VODs',
sortLabel: 'Sortierung:',
sortDateDesc: 'Neueste zuerst',
sortDateAsc: 'Aelteste zuerst',
sortDateAsc: 'Älteste zuerst',
sortViewsDesc: 'Meiste Aufrufe',
sortDurationDesc: 'Laengste zuerst',
sortDurationAsc: 'Kuerzeste zuerst',
bulkSelectedCount: '{count} ausgewaehlt',
sortDurationDesc: 'Längste zuerst',
sortDurationAsc: 'Kürzeste zuerst',
bulkSelectedCount: '{count} ausgewählt',
bulkAddToQueue: '+ Warteschlange',
bulkAdding: 'Fuege hinzu...',
bulkClear: 'Loeschen',
bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.',
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).',
bulkAdding: 'Füge hinzu...',
bulkClear: 'Löschen',
bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefügt.',
bulkAddSkipped: 'Keine VODs hinzugefügt (bereits in Queue oder ungültig).',
bulkMarkDownloaded: 'Als heruntergeladen markieren',
bulkUnmark: 'Markierung entfernen',
bulkMarkedDownloaded: '{count} VODs als heruntergeladen markiert.',
@@ -442,8 +455,8 @@ const UI_TEXT_DE = {
alreadyDownloaded: 'Bereits heruntergeladen',
hideDownloaded: 'Bereits geladene ausblenden',
hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind',
openOnTwitch: 'Auf Twitch oeffnen',
ctxOpenOnTwitch: 'Auf Twitch oeffnen',
openOnTwitch: 'Auf Twitch öffnen',
ctxOpenOnTwitch: 'Auf Twitch öffnen',
ctxCopyUrl: 'VOD-URL kopieren',
ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
ctxMarkDownloaded: 'Als heruntergeladen markieren',
@@ -456,17 +469,17 @@ const UI_TEXT_DE = {
dialogEnd: 'Ende:',
dialogEndTime: 'Endzeit (HH:MM:SS):',
dialogDuration: 'Dauer: ',
dialogPartLabel: 'Start Part-Nummer (optional, fur Fortsetzung):',
dialogPartLabel: 'Start Part-Nummer (optional, für Fortsetzung):',
dialogPartHint: 'Leer lassen = Teil 1',
dialogFormatLabel: 'Dateinamen-Format:',
dialogConfirm: 'Zur Queue hinzufuegen',
dialogConfirm: 'Zur Queue hinzufügen',
invalidDuration: 'Ungultig!',
invalidTime: 'Ungueltige Zeitangaben',
endBeforeStart: 'Endzeit muss grosser als Startzeit sein!',
outOfRange: 'Zeit ausserhalb des VOD-Bereichs!',
invalidTime: 'Ungültige Zeitangaben',
endBeforeStart: 'Endzeit muss größer als Startzeit sein!',
outOfRange: 'Zeit außerhalb des VOD-Bereichs!',
enterUrl: 'Bitte URL eingeben',
loadingButton: 'Lade...',
loadingStatus: 'Download laeuft...',
loadingStatus: 'Download läuft...',
downloadButton: 'Clip herunterladen',
success: 'Download erfolgreich!',
errorPrefix: 'Fehler: ',
@@ -484,43 +497,43 @@ const UI_TEXT_DE = {
cutter: {
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
previewLoading: 'Lade Vorschau...',
previewUnavailable: 'Vorschau nicht verfugbar',
previewUnavailable: 'Vorschau nicht verfügbar',
previewAlt: 'Vorschau',
cutting: 'Schneidet...',
cut: 'Schneiden',
cutSuccess: 'Video erfolgreich geschnitten!',
cutFailed: 'Fehler beim Schneiden des Videos.',
infoDuration: 'Dauer',
infoResolution: 'Aufloesung',
infoResolution: 'Auflösung',
infoFps: 'FPS',
infoSelection: 'Auswahl',
startLabel: 'Start:',
endLabel: 'Ende:',
filePathPlaceholder: 'Keine Datei ausgewaehlt…'
filePathPlaceholder: 'Keine Datei ausgewählt…'
},
merge: {
empty: 'Keine Videos ausgewahlt',
merging: 'Zusammenfugen...',
merge: 'Zusammenfugen',
merging: 'Zusammenfügen...',
merge: 'Zusammenfügen',
success: 'Videos erfolgreich zusammengefugt!',
failed: 'Fehler beim Zusammenfugen der Videos.',
failed: 'Fehler beim Zusammenfügen der Videos.',
moveUpAria: 'Nach oben verschieben',
moveDownAria: 'Nach unten verschieben',
removeAria: 'Aus Liste entfernen'
},
mergeGroup: {
btn: 'Zusammenfugen & Splitten',
btn: 'Zusammenfügen & Splitten',
phaseDownloading: 'VOD wird heruntergeladen',
phaseMerging: 'Zusammenfugen...',
phaseMerging: 'Zusammenfügen...',
phaseSplitting: 'Part wird erstellt',
phaseCleanup: 'Aufraumen...',
needMinTwo: 'Mindestens 2 VODs auswahlen',
needMinTwo: 'Mindestens 2 VODs auswählen',
titleTwo: 'Merge: {title1} + {title2}',
titleMany: 'Merge: {title1} + {count} weitere',
metaLabel: '{count} VODs',
},
updates: {
bannerDefault: 'Neue Version verfugbar!',
bannerDefault: 'Neue Version verfügbar!',
latest: 'Du hast die neueste Version!',
checking: 'Suche nach Updates...',
checkInProgress: 'Update-Prufung lauft bereits.',
@@ -529,13 +542,13 @@ const UI_TEXT_DE = {
downloading: 'Wird heruntergeladen...',
downloadInProgress: 'Update-Download lauft bereits.',
downloadFailed: 'Update-Download fehlgeschlagen.',
available: 'verfugbar!',
available: 'verfügbar!',
downloadNow: 'Jetzt herunterladen',
downloadLabel: 'Download',
ready: 'bereit zur Installation!',
installNow: 'Jetzt installieren & neu starten',
modalAvailableTitle: 'Update verfugbar',
modalAvailableMessage: 'Version {version} ist verfugbar. Jetzt herunterladen?',
modalAvailableTitle: 'Update verfügbar',
modalAvailableMessage: 'Version {version} ist verfügbar. Jetzt herunterladen?',
modalReadyTitle: 'Update bereit',
modalReadyMessage: 'Version {version} wurde heruntergeladen. Jetzt installieren und neu starten?',
modalDismiss: 'Nein',
@@ -543,11 +556,11 @@ const UI_TEXT_DE = {
dismissAria: 'Schließen',
modalDownloadConfirm: 'Ja, herunterladen',
modalInstallConfirm: 'Ja, installieren',
modalSkipVersion: 'Diese Version ueberspringen',
modalSkipVersion: 'Diese Version überspringen',
changelogLabel: 'Changelog',
showChangelog: 'Changelog anzeigen',
hideChangelog: 'Changelog ausblenden',
noChangelog: 'Kein Changelog verfugbar.',
noChangelog: 'Kein Changelog verfügbar.',
releasedLabel: 'Release'
}
} as const;
+18 -5
View File
@@ -64,13 +64,16 @@ const UI_TEXT_EN = {
themeDark: 'Dark',
themeSystem: 'System',
languageLabel: 'Language',
languageDe: 'German',
languageDe: 'Deutsch',
languageEn: 'English',
apiTitle: 'Twitch API',
clientIdLabel: 'Client ID',
clientSecretLabel: 'Client Secret',
saveSettings: 'Save & Connect',
downloadSettingsTitle: 'Download Settings',
downloadBasicsTitle: 'Download and quality',
queueAutomationTitle: 'Queue and automation',
recordingMetadataTitle: 'Recordings and metadata',
storageLabel: 'Storage Path',
selectFolder: 'Folder',
openFolder: 'Open',
@@ -87,6 +90,8 @@ const UI_TEXT_EN = {
performanceModeSpeed: 'Max Speed',
smartSchedulerLabel: 'Enable smart queue scheduler',
smartSchedulerHint: 'Prefers shorter VODs and older queue entries first so the queue throughput stays steady. Disable to drain in strict insertion order.',
sidebarSplitViewLabel: 'Show streamers and queue together',
sidebarSplitViewHint: 'Splits the left sidebar evenly. Disable it to restore the Streamer and Queue switches.',
streamerInvalid: 'Invalid Twitch username (4-25 chars, letters/digits/underscore).',
apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.',
apiHelpLinkText: 'dev.twitch.tv/console/apps',
@@ -288,10 +293,12 @@ const UI_TEXT_EN = {
notConnected: 'Not connected'
},
status: {
noLogin: 'No login (public mode)',
noLogin: 'Public mode · public VODs available',
publicHint: 'Public VODs and downloads work without a Twitch API connection. Connecting the API provides more complete metadata, more history, and more reliable requests.',
connecting: 'Connecting...',
connected: 'Connected',
connectFailedPublic: 'Connection failed - public mode active'
connected: 'Twitch API connected',
connectedHint: 'The Twitch API is connected. Complete metadata and the extended VOD history are available.',
connectFailedPublic: 'API unavailable · public mode active'
},
tabs: {
vods: 'VODs',
@@ -319,7 +326,7 @@ const UI_TEXT_EN = {
progressReady: 'Ready',
progressLoading: 'Loading...',
readyToDownload: 'Ready to download',
started: 'Download started',
started: 'Starting download ',
done: 'Done',
failed: 'Download failed',
speed: 'Speed',
@@ -349,6 +356,9 @@ const UI_TEXT_EN = {
statusBarSummary: '{downloading} dl, {pending} queued',
ctxMoveTop: 'Move to top',
ctxMoveBottom: 'Move to bottom',
ctxSelectForMerge: 'Select for merge',
ctxRemoveFromMerge: 'Remove from merge selection',
mergeSelectionPosition: 'Merge position {position}',
ctxCopyUrl: 'Copy URL',
ctxOpenOnTwitch: 'Open on Twitch',
ctxRemove: 'Remove from queue',
@@ -377,8 +387,11 @@ const UI_TEXT_EN = {
refresh: 'Refresh',
agoMinutes: '{n} min ago',
agoHours: '{n} h ago',
agoDay: '1 d ago',
agoDays: '{n} d ago',
agoMonth: '1 mo ago',
agoMonths: '{n} mo ago',
agoYear: '1 y ago',
agoYears: '{n} y ago'
},
streamers: {
+66 -3
View File
@@ -3,6 +3,9 @@
// full card once profile data is back. Smooth fade-in is in CSS.
let activeProfileRequestId = 0;
let activeProfileLogin = '';
const streamerProfileCache = new Map<string, StreamerProfile>();
const streamerProfileLoads = new Map<string, Promise<StreamerProfile | null>>();
function formatProfileFollowers(count: number | null): string {
if (count == null) return '';
@@ -20,14 +23,36 @@ function formatLastStreamAgo(iso: string | null): string {
const hours = Math.floor(minutes / 60);
if (hours < 24) return UI_TEXT.profile.agoHours.replace('{n}', String(hours));
const days = Math.floor(hours / 24);
if (days === 1) return UI_TEXT.profile.agoDay;
if (days < 30) return UI_TEXT.profile.agoDays.replace('{n}', String(days));
const months = Math.floor(days / 30);
if (months === 1) return UI_TEXT.profile.agoMonth;
if (months < 12) return UI_TEXT.profile.agoMonths.replace('{n}', String(months));
const years = Math.floor(days / 365);
if (years === 1) return UI_TEXT.profile.agoYear;
return UI_TEXT.profile.agoYears.replace('{n}', String(years));
}
function streamerProfilesMatch(left: StreamerProfile, right: StreamerProfile): boolean {
return left.login === right.login
&& left.displayName === right.displayName
&& left.avatarUrl === right.avatarUrl
&& left.bannerUrl === right.bannerUrl
&& left.description === right.description
&& left.broadcasterType === right.broadcasterType
&& left.followerCount === right.followerCount
&& left.vodCount === right.vodCount
&& left.lastStreamAt === right.lastStreamAt
&& left.isLive === right.isLive
&& left.currentTitle === right.currentTitle
&& left.currentGame === right.currentGame
&& left.currentStreamPreviewUrl === right.currentStreamPreviewUrl
&& left.currentStreamViewers === right.currentStreamViewers
&& left.twitchUrl === right.twitchUrl;
}
function hideStreamerProfileHeader(): void {
activeProfileLogin = '';
const el = document.getElementById('streamerProfileHeader');
if (!el) return;
el.classList.add('is-hidden');
@@ -166,10 +191,14 @@ async function loadStreamerProfile(login: string, forceRefresh = false): Promise
hideStreamerProfileHeader();
return;
}
const key = login.trim().toLowerCase();
activeProfileLogin = key;
const reqId = ++activeProfileRequestId;
renderStreamerProfileSkeleton(login);
const cached = streamerProfileCache.get(key);
if (cached) renderStreamerProfileCard(cached);
else renderStreamerProfileSkeleton(login);
try {
const profile = await window.api.getStreamerProfile(login, forceRefresh);
const profile = await fetchStreamerProfile(login, forceRefresh);
// Stale-request guard — user may have clicked another streamer
// while we were waiting on the API.
if (reqId !== activeProfileRequestId) return;
@@ -177,12 +206,44 @@ async function loadStreamerProfile(login: string, forceRefresh = false): Promise
hideStreamerProfileHeader();
return;
}
renderStreamerProfileCard(profile);
const rememberDisplayName = (window as unknown as { rememberStreamerDisplayName?: (login: string, displayName: string) => void }).rememberStreamerDisplayName;
if (typeof rememberDisplayName === 'function') rememberDisplayName(profile.login, profile.displayName);
if (!cached || !streamerProfilesMatch(cached, profile)) renderStreamerProfileCard(profile);
} catch (_) {
if (reqId === activeProfileRequestId) hideStreamerProfileHeader();
}
}
async function fetchStreamerProfile(login: string, forceRefresh = false): Promise<StreamerProfile | null> {
const key = login.trim().toLowerCase();
const cached = streamerProfileCache.get(key);
if (!forceRefresh && cached) return cached;
const pending = streamerProfileLoads.get(key);
if (pending) return pending;
const task = window.api.getStreamerProfile(login, forceRefresh).then((profile) => {
if (profile) streamerProfileCache.set(key, profile);
return profile;
});
streamerProfileLoads.set(key, task);
try {
return await task;
} finally {
if (streamerProfileLoads.get(key) === task) streamerProfileLoads.delete(key);
}
}
async function preloadStreamerProfiles(logins: string[]): Promise<void> {
await Promise.allSettled(logins.map((login) => fetchStreamerProfile(login)));
}
async function refreshStreamerProfilesInBackground(logins: string[]): Promise<void> {
await Promise.allSettled(logins.map(async (login) => {
const previous = streamerProfileCache.get(login.trim().toLowerCase());
const profile = await fetchStreamerProfile(login, true);
if (profile && activeProfileLogin === login.trim().toLowerCase() && (!previous || !streamerProfilesMatch(previous, profile))) renderStreamerProfileCard(profile);
}));
}
function refreshStreamerProfile(login: string): void {
void loadStreamerProfile(login, true);
}
@@ -216,3 +277,5 @@ function onProfileAvatarError(img: HTMLImageElement): void {
(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;
(window as unknown as { preloadStreamerProfiles: typeof preloadStreamerProfiles }).preloadStreamerProfiles = preloadStreamerProfiles;
(window as unknown as { refreshStreamerProfilesInBackground: typeof refreshStreamerProfilesInBackground }).refreshStreamerProfilesInBackground = refreshStreamerProfilesInBackground;
+49 -59
View File
@@ -212,6 +212,18 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
const isPending = item.status === 'pending' || item.status === 'paused';
const isFailed = item.status === 'error';
const isCompleted = item.status === 'completed';
const canSelectForMerge = item.status === 'pending' && !item.mergeGroup && !item.isLive;
if (canSelectForMerge) {
const isSelectedForMerge = selectedQueueIds.includes(item.id);
const mergeSelectionItem = makeItem(
isSelectedForMerge ? UI_TEXT.queue.ctxRemoveFromMerge : UI_TEXT.queue.ctxSelectForMerge,
() => toggleQueueSelection(item.id)
);
mergeSelectionItem.dataset.queueAction = 'merge-select';
menu.appendChild(mergeSelectionItem);
menu.appendChild(makeSeparator());
}
if (isPending) {
menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveTop, () => { void moveQueueItemTo(item.id, 'top'); }));
@@ -297,54 +309,30 @@ function getQueueStatusLabel(item: QueueItem): string {
return UI_TEXT.queue.statusWaiting;
}
function getQueueProgressText(item: QueueItem): string {
if (item.status === 'completed') return '100%';
if (item.status === 'error') return UI_TEXT.queue.progressError;
if (item.status === 'paused') return UI_TEXT.queue.progressReady;
if (item.status === 'pending') return UI_TEXT.queue.progressReady;
if (item.progress > 0) return `${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`;
return item.progressStatus || UI_TEXT.queue.progressLoading;
}
function getQueueMetaText(item: QueueItem): string {
function getQueueProgressStatusText(item: QueueItem): string {
if (item.status === 'error' && item.last_error) {
return item.last_error;
}
const parts: string[] = [];
if (item.currentPart && item.totalParts) {
parts.push(`${UI_TEXT.queue.part} ${item.currentPart}/${item.totalParts}`);
return `${UI_TEXT.queue.part} ${item.currentPart}/${item.totalParts}`;
}
if (item.speed) {
parts.push(`${UI_TEXT.queue.speed}: ${item.speed}`);
}
if (item.status === 'pending') return UI_TEXT.queue.readyToDownload;
if (item.status === 'paused') return UI_TEXT.queue.statusPaused;
if (item.status === 'downloading') return item.progressStatus || UI_TEXT.queue.started;
if (item.status === 'completed') return UI_TEXT.queue.done;
return UI_TEXT.queue.failed;
}
if (item.eta) {
parts.push(`${UI_TEXT.queue.eta}: ${item.eta}`);
function getQueueProgressMetricsText(item: QueueItem): string {
const parts: string[] = [];
if (item.status === 'completed') parts.push('100%');
if (item.status === 'downloading' && item.progress > 0) {
parts.push(`${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`);
}
if (!parts.length && item.status === 'pending') {
parts.push(UI_TEXT.queue.readyToDownload);
}
if (!parts.length && item.status === 'paused') {
parts.push(UI_TEXT.queue.statusPaused);
}
if (!parts.length && item.status === 'downloading') {
parts.push(item.progressStatus || UI_TEXT.queue.started);
}
if (!parts.length && item.status === 'completed') {
parts.push(UI_TEXT.queue.done);
}
if (!parts.length && item.status === 'error') {
parts.push(UI_TEXT.queue.failed);
}
if (item.speed) parts.push(item.speed);
if (item.eta) parts.push(item.eta);
return parts.join(' | ');
}
@@ -400,18 +388,18 @@ function updateQueueItemProgress(progress: DownloadProgress): void {
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;
const status = el.querySelector('.queue-progress-status') as HTMLElement | null;
const metrics = el.querySelector('.queue-progress-metrics') as HTMLElement | null;
if (bar) {
const isDeterminate = progress.progress > 0 && progress.progress <= 100;
const pct = isDeterminate ? Math.min(100, progress.progress) : 0;
const isDeterminate = item.progress > 0 && item.progress <= 100;
const pct = isDeterminate ? Math.min(100, item.progress) : 0;
bar.style.width = `${pct}%`;
bar.className = `queue-progress-bar${isDeterminate ? '' : ' indeterminate'}`;
bar.className = 'queue-progress-bar';
if (wrap) wrap.setAttribute('aria-valuenow', String(Math.round(pct)));
}
if (text) text.textContent = getQueueProgressText(item);
if (meta) meta.textContent = getQueueMetaText(item);
if (status) status.textContent = getQueueProgressStatusText(item);
if (metrics) metrics.textContent = getQueueProgressMetricsText(item);
}
function toggleQueueDetails(id: string): void {
@@ -513,19 +501,21 @@ function renderQueue(): void {
list.innerHTML = queue.map((item: QueueItem) => {
const safeTitle = escapeHtml(item.title || UI_TEXT.vods.untitled);
const safeStatusLabel = escapeHtml(getQueueStatusLabel(item));
const safeProgressText = escapeHtml(getQueueProgressText(item));
const safeMeta = escapeHtml(getQueueMetaText(item));
const safeProgressStatus = escapeHtml(getQueueProgressStatusText(item));
const safeProgressMetrics = escapeHtml(getQueueProgressMetricsText(item));
const safeDate = escapeHtml(formatUiDate(item.date));
const progressStatusClass = item.status === 'downloading' && item.progress <= 0 ? ' is-starting' : '';
const isClip = item.customClip ? '* ' : '';
const hasDeterminateProgress = item.progress > 0 && item.progress <= 100;
const progressValue = item.status === 'completed'
? 100
: (hasDeterminateProgress ? Math.max(0, Math.min(100, item.progress)) : 0);
const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : '';
const isMergeGroup = !!item.mergeGroup;
const showSelector = item.status === 'pending' && !isMergeGroup && !item.isLive;
const selectionIndex = selectedQueueIds.indexOf(item.id);
const isSelected = selectionIndex >= 0;
const selectionPosition = selectionIndex + 1;
const selectionTitle = escapeHtml(UI_TEXT.queue.mergeSelectionPosition.replace('{position}', String(selectionPosition)));
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> '
: '';
@@ -540,22 +530,23 @@ 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-item${isMergeGroup ? ' merge-group' : ''}${isSelected ? ' merge-selected' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${item.id}">
${isSelected ? `<span class="queue-selection-order" title="${selectionTitle}" aria-label="${selectionTitle}">${selectionPosition}</span>` : ''}
<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="queue-status-label">${safeStatusLabel}</div>
<span class="remove" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.streamers.removeAria)}" onclick="removeFromQueue('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();removeFromQueue('${item.id}');}">x</span>
</div>
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>
<div class="queue-meta"><span class="queue-date">${safeDate}</span>${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-bar${progressClass}" style="width: ${progressValue}%;"></div>
<div class="queue-progress-bar" style="width: ${progressValue}%;"></div>
</div>
<div class="queue-progress-info">
<span class="queue-progress-status${progressStatusClass}">${safeProgressStatus}</span>
<span class="queue-progress-metrics">${safeProgressMetrics}</span>
</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>
@@ -565,7 +556,6 @@ function renderQueue(): void {
</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>
</div>
`;
}).join('');
+62 -11
View File
@@ -19,14 +19,14 @@ async function connect(): Promise<void> {
const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim());
if (!hasCredentials) {
isConnected = false;
updateStatus(UI_TEXT.status.noLogin, false);
updateStatus(UI_TEXT.status.noLogin, false, 'public');
return;
}
updateStatus(UI_TEXT.status.connecting, false);
updateStatus(UI_TEXT.status.connecting, false, 'pending');
const success = await window.api.login();
isConnected = success;
updateStatus(success ? UI_TEXT.status.connected : UI_TEXT.status.connectFailedPublic, success);
updateStatus(success ? UI_TEXT.status.connected : UI_TEXT.status.connectFailedPublic, success, success ? 'connected' : 'public');
}
function formatBytesForMetrics(bytes: number): string {
@@ -172,19 +172,58 @@ function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
}
}
function updateStatus(text: string, connected: boolean): void {
byId('statusText').textContent = text;
type ConnectionStatusTone = 'connected' | 'public' | 'pending';
function updateStatus(text: string, connected: boolean, tone: ConnectionStatusTone = connected ? 'connected' : 'public'): void {
const statusText = byId('statusText');
statusText.textContent = text;
statusText.title = tone === 'connected'
? UI_TEXT.status.connectedHint
: tone === 'public'
? UI_TEXT.status.publicHint
: '';
const dot = byId('statusDot');
dot.classList.remove('connected', 'error');
dot.classList.add(connected ? 'connected' : 'error');
dot.classList.remove('connected', 'error', 'public');
if (tone === 'connected') dot.classList.add('connected');
if (tone === 'public') dot.classList.add('public');
}
function filterSettings(query: string): void {
const normalizedQuery = query.trim().toLocaleLowerCase(getIntlLocale());
document.querySelectorAll<HTMLElement>('#settingsTab .settings-card').forEach((card) => {
if (!normalizedQuery) return;
const match = Array.from(document.querySelectorAll<HTMLElement>('#settingsTab .settings-card[data-settings-pane]')).find((card) => {
const searchableText = (card.textContent || '').toLocaleLowerCase(getIntlLocale());
card.hidden = normalizedQuery.length > 0 && !searchableText.includes(normalizedQuery);
return searchableText.includes(normalizedQuery);
});
const pane = match?.dataset.settingsPane;
if (pane) setSettingsPane(pane);
}
function setSettingsPane(pane: string, source?: HTMLElement): void {
const tab = byId<HTMLElement>('settingsTab');
const cards = Array.from(tab.querySelectorAll<HTMLElement>('.settings-card[data-settings-pane]'));
const targetCard = cards.find((card) => card.dataset.settingsPane === pane);
if (!targetCard) return;
tab.dataset.settingsPane = pane;
cards.forEach((card) => {
card.hidden = card !== targetCard;
});
const panel = document.querySelector<HTMLElement>('[data-context-for="settings"]');
const buttons = Array.from(panel?.querySelectorAll<HTMLButtonElement>('.context-link[data-settings-pane]') || []);
const targetButton = source || buttons.find((button) => button.dataset.settingsPane === pane);
buttons.forEach((button) => {
const active = button === targetButton;
button.classList.toggle('active', active);
if (active) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
});
if (source) byId<HTMLInputElement>('settingsSearchInput').value = '';
tab.scrollTop = 0;
const list = panel?.querySelector<HTMLElement>('.context-list');
if (list) scheduleSegmentedIndicatorSync(list);
}
function changeLanguage(lang: string): void {
@@ -195,7 +234,12 @@ function changeLanguage(lang: string): void {
void window.api.saveConfig({ language: normalized });
const currentStatus = byId('statusText').textContent?.trim() || '';
updateStatus(localizeCurrentStatusText(currentStatus), isConnected);
const statusTone: ConnectionStatusTone = isConnected
? 'connected'
: byId('statusDot').classList.contains('public')
? 'public'
: 'pending';
updateStatus(localizeCurrentStatusText(currentStatus), isConnected, statusTone);
renderQueue();
renderStreamers();
@@ -228,6 +272,7 @@ function updateLanguagePicker(lang: string): void {
en.classList.toggle('active', !isDe);
de.setAttribute('aria-pressed', String(isDe));
en.setAttribute('aria-pressed', String(!isDe));
scheduleSegmentedIndicatorSync(byId<HTMLElement>('languagePicker'));
}
function selectLanguageOption(lang: string): void {
@@ -549,6 +594,7 @@ function syncPartMinutesFieldState(): void {
function collectDownloadSettingsPayload(): Partial<AppConfig> {
return {
sidebar_split_view: byId<HTMLInputElement>('sidebarSplitViewToggle').checked,
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120,
parallel_downloads: parseInt(byId<HTMLSelectElement>('parallelDownloads').value, 10) || 1,
@@ -596,7 +642,8 @@ function collectFilenameTemplatePayload(showAlert = false): Partial<AppConfig> |
function collectAutoSavePayload(): Partial<AppConfig> {
const payload: Partial<AppConfig> = {
...collectCredentialsPayload(),
...collectDownloadSettingsPayload()
...collectDownloadSettingsPayload(),
sidebar_split_view: byId<HTMLInputElement>('sidebarSplitViewToggle').checked
};
const templatePayload = collectFilenameTemplatePayload(false);
@@ -612,6 +659,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
return JSON.stringify([
effective.client_id ?? '',
effective.client_secret ?? '',
effective.sidebar_split_view !== false,
effective.download_mode ?? 'full',
effective.part_minutes ?? 120,
effective.parallel_downloads ?? 1,
@@ -650,6 +698,8 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
byId<HTMLInputElement>('sidebarSplitViewToggle').checked = config.sidebar_split_view !== false;
applySidebarLayoutPreference(config.sidebar_split_view !== false);
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120);
byId<HTMLSelectElement>('parallelDownloads').value = String((config.parallel_downloads as number) || 1);
@@ -784,6 +834,7 @@ function initSettingsAutoSave(): void {
const immediateSaveIds = [
'downloadMode',
'sidebarSplitViewToggle',
'parallelDownloads',
'performanceMode',
'smartSchedulerToggle',
+352 -131
View File
@@ -1,12 +1,90 @@
let selectStreamerRequestId = 0;
let vodRenderTaskId = 0;
let streamerIndicatorFrame: number | null = null;
const VOD_RENDER_CHUNK_SIZE = 64;
function scheduleStreamerActiveIndicatorSync(): void {
if (streamerIndicatorFrame !== null) return;
streamerIndicatorFrame = requestAnimationFrame(() => {
streamerIndicatorFrame = null;
const list = document.getElementById('streamerList');
const activeItem = list?.querySelector<HTMLElement>('.streamer-item.active');
if (!list || !activeItem) {
list?.classList.remove('streamer-indicator-visible');
return;
}
list.style.setProperty('--streamer-active-x', `${activeItem.offsetLeft}px`);
list.style.setProperty('--streamer-active-y', `${activeItem.offsetTop}px`);
list.style.setProperty('--streamer-active-width', `${activeItem.offsetWidth}px`);
list.style.setProperty('--streamer-active-height', `${activeItem.offsetHeight}px`);
list.classList.add('streamer-indicator-visible');
requestAnimationFrame(() => list.classList.add('streamer-indicator-ready'));
});
}
// 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>();
const streamerDisplayNames = new Map<string, string>();
function rememberStreamerDisplayName(login: string, displayName: string): void {
const normalizedLogin = login.trim().toLowerCase();
const normalizedDisplayName = displayName.trim();
if (!normalizedLogin || !normalizedDisplayName) return;
const changed = streamerDisplayNames.get(normalizedLogin) !== normalizedDisplayName;
if (changed) streamerDisplayNames.set(normalizedLogin, normalizedDisplayName);
if (currentStreamer?.toLowerCase() === normalizedLogin) {
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (typeof setTitle === 'function') setTitle(normalizedDisplayName);
}
if (changed) renderStreamers();
}
(window as unknown as { rememberStreamerDisplayName: typeof rememberStreamerDisplayName }).rememberStreamerDisplayName = rememberStreamerDisplayName;
async function hydrateStreamerDisplayNames(): Promise<void> {
const configuredNames = config.streamer_display_names || {};
let changed = false;
for (const [login, displayName] of Object.entries(configuredNames)) {
const normalizedLogin = login.trim().toLowerCase();
const normalizedDisplayName = displayName.trim();
if (normalizedLogin && normalizedDisplayName && streamerDisplayNames.get(normalizedLogin) !== normalizedDisplayName) {
streamerDisplayNames.set(normalizedLogin, normalizedDisplayName);
changed = true;
}
}
const streamers = (config.streamers ?? []) as string[];
if (streamers.length === 0) {
if (changed) renderStreamers();
return;
}
try {
const resolvedNames = await window.api.getStreamerDisplayNames(streamers);
for (const [login, displayName] of Object.entries(resolvedNames)) {
const normalizedLogin = login.trim().toLowerCase();
const normalizedDisplayName = displayName.trim();
if (normalizedLogin && normalizedDisplayName && streamerDisplayNames.get(normalizedLogin) !== normalizedDisplayName) {
streamerDisplayNames.set(normalizedLogin, normalizedDisplayName);
changed = true;
}
}
} catch { }
if (changed) {
if (currentStreamer) {
const displayName = streamerDisplayNames.get(currentStreamer.toLowerCase());
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (displayName && typeof setTitle === 'function') setTitle(displayName);
}
renderStreamers();
}
}
(window as unknown as { hydrateStreamerDisplayNames: typeof hydrateStreamerDisplayNames }).hydrateStreamerDisplayNames = hydrateStreamerDisplayNames;
async function initLiveStatusSubscription(): Promise<void> {
try {
@@ -37,6 +115,15 @@ async function initLiveStatusSubscription(): Promise<void> {
// the clear button. Shared across streamers (acts like a search bar).
let lastLoadedVods: VOD[] = [];
let lastLoadedStreamer: string | null = null;
interface CachedStreamerVods {
userId: string;
vods: VOD[];
updatedAt: number;
}
const streamerVodCache = new Map<string, CachedStreamerVods>();
const streamerVodLoads = new Map<string, Promise<CachedStreamerVods | null>>();
let streamerBackgroundRefreshTimer: number | null = null;
const STREAMER_BACKGROUND_REFRESH_MS = 5 * 60 * 1000;
let vodFilterQuery = '';
const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter';
@@ -222,7 +309,7 @@ function focusVodFilter(): void {
function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string>): string {
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
const date = formatUiDate(vod.created_at);
const date = formatVodCardDate(vod.created_at);
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
const safeUrlAttr = escapeHtml(vod.url);
const safeTitleAttr = escapeHtml(vod.title || '');
@@ -254,15 +341,14 @@ function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string
<input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} aria-label="${escapeHtml(UI_TEXT.vods.selectAriaLabel)}">
${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>'">
<img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" title="${escapeHtml(UI_TEXT.vods.selectAriaLabel)}" 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">
<span>${date}</span>
<span>${escapeHtml(vod.duration)}</span>
<span>${formatUiNumber(vod.view_count)} ${escapeHtml(UI_TEXT.vods.views)}</span>
<span class="vod-date"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M7 3v3M17 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v14H4V6a1 1 0 0 1 1-1z"></path></svg>${date}</span>
<span class="vod-views"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12z"></path><circle cx="12" cy="12" r="2.5"></circle></svg>${formatUiNumber(vod.view_count)} ${escapeHtml(UI_TEXT.vods.views)}</span>
</div>
</div>
<div class="vod-actions">
@@ -412,6 +498,76 @@ function initCutterDragDrop(): void {
});
}
let streamerContextMenu: HTMLDivElement | null = null;
let streamerContextMenuCleanup: (() => void) | null = null;
function dismissStreamerContextMenu(): void {
streamerContextMenuCleanup?.();
streamerContextMenuCleanup = null;
streamerContextMenu = null;
}
function showStreamerContextMenu(event: MouseEvent, streamer: string): void {
event.preventDefault();
event.stopPropagation();
dismissStreamerContextMenu();
const autoList = (config.auto_record_streamers as string[] | undefined) || [];
const vodList = (config.auto_vod_download_streamers as string[] | undefined) || [];
const menu = document.createElement('div');
menu.className = 'streamer-context-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', streamerDisplayNames.get(streamer.toLowerCase()) || streamer);
const appendAction = (action: 'auto' | 'vod' | 'record', label: string, active: boolean, handler: () => void): void => {
const button = document.createElement('button');
button.type = 'button';
button.dataset.streamerAction = action;
button.className = `streamer-context-action streamer-context-${action}${active ? ' is-active' : ''}`;
button.textContent = label;
button.setAttribute('role', 'menuitem');
button.setAttribute('aria-label', action === 'auto'
? UI_TEXT.streamers?.autoRecordTitle || 'Auto-record'
: action === 'vod'
? UI_TEXT.streamers?.autoVodTitle || 'Auto-download VODs'
: UI_TEXT.streamers?.recordLiveTitle || 'Record live now');
button.addEventListener('click', () => {
dismissStreamerContextMenu();
handler();
});
menu.appendChild(button);
};
appendAction('auto', 'AUTO', autoList.includes(streamer), () => { void toggleAutoRecord(streamer); });
appendAction('vod', 'VOD', vodList.includes(streamer), () => { void toggleAutoVodDownload(streamer); });
appendAction('record', 'REC', false, () => { void triggerLiveRecording(streamer); });
document.body.appendChild(menu);
const bounds = menu.getBoundingClientRect();
const margin = 8;
menu.style.left = `${Math.max(margin, Math.min(event.clientX, window.innerWidth - bounds.width - margin))}px`;
menu.style.top = `${Math.max(margin, Math.min(event.clientY, window.innerHeight - bounds.height - margin))}px`;
const onPointerDown = (pointerEvent: PointerEvent): void => {
if (!menu.contains(pointerEvent.target as Node)) dismissStreamerContextMenu();
};
const onKeyDown = (keyEvent: KeyboardEvent): void => {
if (keyEvent.key === 'Escape') dismissStreamerContextMenu();
};
const onResize = (): void => dismissStreamerContextMenu();
streamerContextMenu = menu;
streamerContextMenuCleanup = () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('resize', onResize);
menu.remove();
};
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKeyDown, true);
window.addEventListener('resize', onResize);
menu.querySelector<HTMLButtonElement>('button')?.focus();
}
function renderStreamers(): void {
const list = byId('streamerList');
list.replaceChildren();
@@ -436,6 +592,7 @@ function renderStreamers(): void {
if (counter) counter.textContent = '';
const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null;
if (bulkBtn) bulkBtn.classList.add('is-hidden');
scheduleStreamerActiveIndicatorSync();
return;
}
@@ -443,14 +600,7 @@ function renderStreamers(): void {
// 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);
}
counter.textContent = all.length === 0 ? '' : String(all.length);
}
const q = (streamerListFilterQuery || '').trim().toLowerCase();
@@ -487,75 +637,7 @@ function renderStreamers(): void {
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.
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
});
// VOD-auto-download toggle — periodic scan of this streamer's
// VOD list, auto-queues anything new within the age window.
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
});
// Live-record one-shot — triggers a recording immediately (server
// verifies the streamer is online before honoring the request).
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'
});
nameSpan.textContent = streamerDisplayNames.get(streamer.toLowerCase()) || streamer;
const removeSpan = document.createElement('span');
removeSpan.className = 'remove';
removeSpan.textContent = 'x';
@@ -573,7 +655,22 @@ function renderStreamers(): void {
void removeStreamer(streamer);
}
});
item.append(nameSpan, autoBtn, vodBtn, recBtn, removeSpan);
item.append(nameSpan, removeSpan);
item.addEventListener('contextmenu', (contextEvent) => {
showStreamerContextMenu(contextEvent, streamer);
});
item.addEventListener('keydown', (keyEvent) => {
if (keyEvent.key !== 'ContextMenu' && !(keyEvent.shiftKey && keyEvent.key === 'F10')) return;
keyEvent.preventDefault();
const rect = item.getBoundingClientRect();
showStreamerContextMenu(new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: rect.left + 12,
clientY: rect.top + 12
}), streamer);
});
item.addEventListener('click', () => {
// Skip click if drag was just released — drop fires after dragend
@@ -598,6 +695,7 @@ function renderStreamers(): void {
if (bulkBtn) bulkBtn.classList.toggle('is-hidden', all.length < STREAMER_FILTER_THRESHOLD);
initStreamerDragDrop();
scheduleStreamerActiveIndicatorSync();
}
function onStreamerListFilterChange(): void {
@@ -710,6 +808,7 @@ async function addStreamer(): Promise<void> {
config = await window.api.saveConfig({ streamers: config.streamers });
input.value = '';
renderStreamers();
void hydrateStreamerDisplayNames();
await selectStreamer(name);
}
@@ -728,6 +827,77 @@ async function removeStreamer(name: string): Promise<void> {
setVodGridEmptyState(byId('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText);
}
function normalizeStreamerCacheKey(name: string): string {
return name.trim().toLowerCase();
}
function vodCollectionsMatch(left: VOD[], right: VOD[]): boolean {
if (left.length !== right.length) return false;
return left.every((vod, index) => {
const other = right[index];
return Boolean(other)
&& vod.id === other.id
&& vod.title === other.title
&& vod.created_at === other.created_at
&& vod.duration === other.duration
&& vod.thumbnail_url === other.thumbnail_url
&& vod.url === other.url
&& vod.view_count === other.view_count;
});
}
async function loadStreamerVods(name: string, forceRefresh = false): Promise<CachedStreamerVods | null> {
const key = normalizeStreamerCacheKey(name);
const cached = streamerVodCache.get(key);
if (!forceRefresh && cached) return cached;
const pending = streamerVodLoads.get(key);
if (pending) return pending;
const task = (async () => {
const userId = await window.api.getUserId(name);
if (!userId) return null;
const vods = await window.api.getVODs(userId, forceRefresh);
const entry = { userId, vods: Array.isArray(vods) ? vods : [], updatedAt: Date.now() };
streamerVodCache.set(key, entry);
return entry;
})();
streamerVodLoads.set(key, task);
try {
return await task;
} finally {
if (streamerVodLoads.get(key) === task) streamerVodLoads.delete(key);
}
}
async function preloadConfiguredStreamerData(streamers: string[] = (config.streamers ?? []) as string[]): Promise<void> {
const profilePreloader = (window as unknown as { preloadStreamerProfiles?: (logins: string[]) => Promise<void> }).preloadStreamerProfiles;
const jobs: Promise<unknown>[] = streamers.map((name) => loadStreamerVods(name));
if (typeof profilePreloader === 'function') jobs.push(profilePreloader(streamers));
await Promise.allSettled(jobs);
}
async function refreshConfiguredStreamersInBackground(): Promise<void> {
const streamers = [...((config.streamers ?? []) as string[])];
const profileRefresher = (window as unknown as { refreshStreamerProfilesInBackground?: (logins: string[]) => Promise<void> }).refreshStreamerProfilesInBackground;
const activeKey = currentStreamer ? normalizeStreamerCacheKey(currentStreamer) : '';
const previousActiveVods = activeKey ? streamerVodCache.get(activeKey)?.vods ?? [] : [];
const jobs = streamers.map((name) => loadStreamerVods(name, true));
await Promise.allSettled([
...jobs,
...(typeof profileRefresher === 'function' ? [profileRefresher(streamers)] : [])
]);
if (!currentStreamer || normalizeStreamerCacheKey(currentStreamer) !== activeKey) return;
const updated = streamerVodCache.get(activeKey)?.vods;
if (updated && !vodCollectionsMatch(previousActiveVods, updated)) renderVODs(updated, currentStreamer, true);
}
function startStreamerBackgroundRefresh(): void {
if (streamerBackgroundRefreshTimer !== null) window.clearInterval(streamerBackgroundRefreshTimer);
streamerBackgroundRefreshTimer = window.setInterval(() => {
void refreshConfiguredStreamersInBackground();
}, STREAMER_BACKGROUND_REFRESH_MS);
}
async function selectStreamer(name: string, forceRefresh = false): Promise<void> {
// Save where we were on the OLD streamer before navigating away.
rememberCurrentVodScroll();
@@ -742,15 +912,16 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
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;
const displayName = streamerDisplayNames.get(name.toLowerCase()) || name;
if (typeof setTitle === 'function') setTitle(displayName);
else byId('pageTitle').textContent = displayName;
// Kick off the profile header load in parallel with VOD fetching.
// It's a separate request stream and not strictly needed for the VOD
// grid, so we don't await it here — the skeleton appears immediately.
const profileLoader = (window as unknown as { loadStreamerProfile?: (login: string) => Promise<void> }).loadStreamerProfile;
const profileLoader = (window as unknown as { loadStreamerProfile?: (login: string, forceRefresh?: boolean) => Promise<void> }).loadStreamerProfile;
if (typeof profileLoader === 'function') {
void profileLoader(name);
void profileLoader(name, forceRefresh);
}
if (!isConnected) {
@@ -764,36 +935,32 @@ 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>
const key = normalizeStreamerCacheKey(name);
const cached = streamerVodCache.get(key);
if (cached) {
renderVODs(cached.vods, name);
} else {
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>
</div>
`).join('');
const userId = await window.api.getUserId(name);
if (isStaleRequest()) {
return;
`).join('');
}
if (!userId) {
const loaded = await loadStreamerVods(name, forceRefresh);
if (isStaleRequest()) return;
if (!loaded) {
byId('vodGrid').innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.notFound}</h3></div>`;
return;
}
const vods = await window.api.getVODs(userId, forceRefresh);
if (isStaleRequest()) {
return;
}
renderVODs(vods, name);
if (!cached || !vodCollectionsMatch(cached.vods, loaded.vods)) renderVODs(loaded.vods, name, Boolean(cached));
}
function createVodEmptyStateIcon(): SVGSVGElement {
@@ -826,16 +993,17 @@ function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): v
grid.replaceChildren(wrap);
}
function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
function renderVODs(vods: VOD[] | null | undefined, streamer: string, animateChanges = false): void {
// Clear bulk-selection on streamer switch — selection is per-streamer
if (lastLoadedStreamer && lastLoadedStreamer !== streamer && selectedVodUrls.size > 0) {
selectedVodUrls.clear();
updateVodBulkBar();
}
const motion = animateChanges ? captureVodGridMotion() : undefined;
lastLoadedVods = Array.isArray(vods) ? vods : [];
lastLoadedStreamer = streamer;
initVodGridSelectionDelegation();
renderVodGridFromCurrentState();
renderVodGridFromCurrentState(motion);
// After the first chunk lands the grid has size, so scroll-restore can
// succeed. Use a small delay to let chunked rendering paint.
@@ -862,13 +1030,8 @@ function initVodGridSelectionDelegation(): void {
const target = e.target as HTMLElement;
// 1) Checkbox toggles (bulk-select)
if (target instanceof HTMLInputElement && target.classList.contains('vod-select-checkbox')) {
const url = target.dataset.vodUrl || '';
if (!url) return;
if (target.checked) selectedVodUrls.add(url);
else selectedVodUrls.delete(url);
const card = target.closest('.vod-card') as HTMLElement | null;
if (card) card.classList.toggle('selected', target.checked);
updateVodBulkBar();
if (card) setVodCardSelection(card, target.checked);
return;
}
@@ -886,14 +1049,10 @@ function initVodGridSelectionDelegation(): void {
return;
}
// 3) Click on thumbnail / title / meta -> open VOD on Twitch in the
// OS default browser. Convenient + non-destructive.
const card = target.closest('.vod-card') as HTMLElement | null;
if (!card) return;
if (target.closest('.vod-actions') || target.classList.contains('vod-select-checkbox')) return;
const ctx = readVodCardContext(card);
if (!ctx) return;
void window.api.openExternal(ctx.url);
toggleVodCardSelection(card);
});
grid.addEventListener('contextmenu', (e) => {
@@ -905,23 +1064,34 @@ function initVodGridSelectionDelegation(): void {
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);
toggleVodCardSelection(card);
});
}
function setVodCardSelection(card: HTMLElement, selected: boolean): void {
const checkbox = card.querySelector<HTMLInputElement>('.vod-select-checkbox');
const url = checkbox?.dataset.vodUrl || '';
if (!checkbox || !url) return;
checkbox.checked = selected;
card.classList.toggle('selected', selected);
if (selected) selectedVodUrls.add(url);
else selectedVodUrls.delete(url);
updateVodBulkBar();
}
function toggleVodCardSelection(card: HTMLElement): void {
const checkbox = card.querySelector<HTMLInputElement>('.vod-select-checkbox');
if (!checkbox) return;
setVodCardSelection(card, !checkbox.checked);
}
let activeVodContextMenu: HTMLElement | null = null;
function closeVodContextMenu(): void {
@@ -1175,7 +1345,48 @@ async function bulkAddSelectedVodsToQueue(): Promise<void> {
}
}
function renderVodGridFromCurrentState(): void {
interface VodGridMotion {
rects: Map<string, { left: number; top: number }>;
ids: Set<string>;
}
function captureVodGridMotion(): VodGridMotion {
const rects = new Map<string, { left: number; top: number }>();
const ids = new Set<string>();
document.querySelectorAll<HTMLElement>('#vodGrid .vod-card[data-vod-id]').forEach((card) => {
const id = card.dataset.vodId;
if (!id) return;
const rect = card.getBoundingClientRect();
rects.set(id, { left: rect.left, top: rect.top });
ids.add(id);
});
return { rects, ids };
}
function animateVodGridMotion(motion: VodGridMotion): void {
document.querySelectorAll<HTMLElement>('#vodGrid .vod-card[data-vod-id]').forEach((card, index) => {
const id = card.dataset.vodId || '';
const previous = motion.rects.get(id);
if (previous) {
const rect = card.getBoundingClientRect();
const x = previous.left - rect.left;
const y = previous.top - rect.top;
if (Math.abs(x) > 0.5 || Math.abs(y) > 0.5) {
card.animate([
{ transform: `translate(${x}px, ${y}px)` },
{ transform: 'translate(0, 0)' }
], { duration: 420, easing: 'cubic-bezier(.22, 1, .36, 1)' });
}
return;
}
card.animate([
{ opacity: 0, transform: 'translateY(16px) scale(.98)' },
{ opacity: 1, transform: 'translateY(0) scale(1)' }
], { duration: 360, delay: Math.min(index * 24, 144), easing: 'cubic-bezier(.22, 1, .36, 1)', fill: 'backwards' });
});
}
function renderVodGridFromCurrentState(motion?: VodGridMotion): void {
if (!lastLoadedStreamer) return;
const grid = byId('vodGrid');
@@ -1237,6 +1448,8 @@ function renderVodGridFromCurrentState(): void {
if (startIndex + chunk.length < filtered.length) {
scheduleNextChunk(startIndex + chunk.length);
} else if (motion) {
requestAnimationFrame(() => animateVodGridMotion(motion));
}
};
@@ -1250,3 +1463,11 @@ async function refreshVODs(): Promise<void> {
await selectStreamer(currentStreamer, true);
}
(window as unknown as {
preloadConfiguredStreamerData: typeof preloadConfiguredStreamerData;
refreshConfiguredStreamersInBackground: typeof refreshConfiguredStreamersInBackground;
startStreamerBackgroundRefresh: typeof startStreamerBackgroundRefresh;
}).preloadConfiguredStreamerData = preloadConfiguredStreamerData;
(window as unknown as { refreshConfiguredStreamersInBackground: typeof refreshConfiguredStreamersInBackground }).refreshConfiguredStreamersInBackground = refreshConfiguredStreamersInBackground;
(window as unknown as { startStreamerBackgroundRefresh: typeof startStreamerBackgroundRefresh }).startStreamerBackgroundRefresh = startStreamerBackgroundRefresh;
+20 -2
View File
@@ -13,13 +13,26 @@ function getIntlLocale(): string {
}
function formatUiDate(input: string | Date): string {
return formatVodCardDate(input);
}
function formatVodCardDate(input: string | Date): string {
const date = input instanceof Date ? input : new Date(input);
return date.toLocaleDateString(getIntlLocale());
if (Number.isNaN(date.getTime())) return '';
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${day}.${month}.${date.getFullYear()}`;
}
function formatUiDateTime(input: string | Date): string {
const date = input instanceof Date ? input : new Date(input);
return date.toLocaleString(getIntlLocale());
if (Number.isNaN(date.getTime())) return '';
const time = date.toLocaleTimeString(getIntlLocale(), {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
return `${formatVodCardDate(date)}, ${time}`;
}
function formatUiNumber(value: number): string {
@@ -185,6 +198,11 @@ function applyLanguageToStaticUI(): void {
setText('clientSecretLabel', UI_TEXT.static.clientSecretLabel);
setText('saveSettingsBtn', UI_TEXT.static.saveSettings);
setText('downloadSettingsTitle', UI_TEXT.static.downloadSettingsTitle);
setText('downloadBasicsTitle', UI_TEXT.static.downloadBasicsTitle);
setText('queueAutomationTitle', UI_TEXT.static.queueAutomationTitle);
setText('recordingMetadataTitle', UI_TEXT.static.recordingMetadataTitle);
setText('sidebarSplitViewLabel', UI_TEXT.static.sidebarSplitViewLabel);
setText('sidebarSplitViewHint', UI_TEXT.static.sidebarSplitViewHint);
setText('storageLabel', UI_TEXT.static.storageLabel);
setText('selectFolderBtn', UI_TEXT.static.selectFolder);
setText('openFolderBtn', UI_TEXT.static.openFolder);
+4
View File
@@ -115,6 +115,7 @@ function syncWorkspaceUpdateState(state: 'idle' | 'available' | 'downloading' |
const description = byId('updateText').textContent?.trim() || UI_TEXT.static.checkUpdates;
banner.dataset.updateState = state;
banner.hidden = state === 'idle';
button.dataset.updateState = state;
button.disabled = false;
if (state === 'downloading') {
@@ -671,6 +672,9 @@ window.api.onUpdateNotAvailable(() => {
shouldOpenUpdateModalOnAvailable = false;
manualUpdateCheckPending = false;
if (!updateReady) {
hideUpdateBanner();
}
});
window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
+23 -10
View File
@@ -26,7 +26,7 @@ const FRAMES_TO_CYCLE = 4;
// 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;
const MAX_CLIENT_STORYBOARD_CACHE = 12;
function rememberStoryboard(vodId: string, sb: VodStoryboard | null): void {
vodStoryboardClientCache.set(vodId, sb);
@@ -133,7 +133,8 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<v
const height = hostRect.height;
if (width <= 0 || height <= 0) return;
if (storyboard.cellWidth <= 0 || storyboard.cellHeight <= 0) return;
const frameDataUrls = Array.isArray(storyboard.frameDataUrls) ? storyboard.frameDataUrls.filter((url) => typeof url === 'string' && url.length > 0) : [];
if (frameDataUrls.length === 0 && (storyboard.cellWidth <= 0 || storyboard.cellHeight <= 0)) return;
// Position + Size voll inline gesetzt — kein CSS aspect-ratio mehr, das
// sich mit JS-Dimensionen streiten koennte (siehe styles.css, die Klasse
@@ -145,13 +146,26 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<v
// Skaliere X und Y unabhaengig, damit eine Cell die Overlay-Box exakt
// fuellt — Twitch-Cell-Aspect kann von 16:9 minimal abweichen.
const scaleX = width / storyboard.cellWidth;
const scaleY = height / storyboard.cellHeight;
overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`;
overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scaleX}px ${storyboard.rows * storyboard.cellHeight * scaleY}px`;
overlay.style.backgroundRepeat = 'no-repeat';
const first = cellsToShow[0];
overlay.style.backgroundPosition = `-${first.col * storyboard.cellWidth * scaleX}px -${first.row * storyboard.cellHeight * scaleY}px`;
let advanceFrame: (index: number) => void;
if (frameDataUrls.length > 0) {
overlay.style.backgroundSize = 'cover';
overlay.style.backgroundPosition = 'center';
advanceFrame = (index) => {
const frameUrl = frameDataUrls[index % frameDataUrls.length];
overlay.style.backgroundImage = `url("${frameUrl.replace(/"/g, '%22')}")`;
};
} else {
const scaleX = width / storyboard.cellWidth;
const scaleY = height / storyboard.cellHeight;
overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`;
overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scaleX}px ${storyboard.rows * storyboard.cellHeight * scaleY}px`;
advanceFrame = (index) => {
const cell = cellsToShow[index % cellsToShow.length];
overlay.style.backgroundPosition = `-${cell.col * storyboard.cellWidth * scaleX}px -${cell.row * storyboard.cellHeight * scaleY}px`;
};
}
advanceFrame(0);
host.appendChild(overlay);
// Trigger CSS transition to opacity:1 on the next frame.
@@ -159,8 +173,7 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<v
let frameIdx = 1;
const intervalId = window.setInterval(() => {
const cell = cellsToShow[frameIdx % cellsToShow.length];
overlay.style.backgroundPosition = `-${cell.col * storyboard.cellWidth * scaleX}px -${cell.row * storyboard.cellHeight * scaleY}px`;
advanceFrame(frameIdx);
frameIdx++;
}, FRAME_INTERVAL_MS);
+123 -1
View File
@@ -29,6 +29,7 @@ async function init(): Promise<void> {
byId<HTMLSelectElement>('themeSelect').value = config.theme ?? 'twitch';
byId<HTMLSelectElement>('languageSelect').value = config.language ?? 'en';
updateLanguagePicker(config.language ?? 'en');
setSettingsPane(byId<HTMLElement>('settingsTab').dataset.settingsPane || 'design');
byId<HTMLSelectElement>('downloadMode').value = config.download_mode ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String(config.part_minutes ?? 120);
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
@@ -42,6 +43,7 @@ async function init(): Promise<void> {
changeTheme(config.theme ?? 'twitch');
renderStreamers();
void hydrateStreamerDisplayNames();
renderQueue();
// Kick off live-status subscription so the sidebar dots populate.
@@ -75,6 +77,8 @@ async function init(): Promise<void> {
initCutterDragDrop();
// Restore last active tab from previous session (default 'vods')
initTopNavActiveIndicator();
initSegmentedIndicators();
showTab(loadPersistedActiveTab());
window.api.onQueueUpdated(async (q: QueueItem[]) => {
@@ -113,7 +117,7 @@ async function init(): Promise<void> {
}
item.status = 'downloading';
item.progress = progress.progress;
if (progress.progress > 0) item.progress = Math.max(item.progress, progress.progress);
item.speed = progress.speed;
item.eta = progress.eta;
item.currentPart = progress.currentPart;
@@ -142,6 +146,12 @@ async function init(): Promise<void> {
markQueueActivity();
});
window.api.onDownloadPaused(() => {
downloading = false;
updateDownloadButtonState();
markQueueActivity();
});
window.api.onDownloadFinished(() => {
downloading = false;
updateDownloadButtonState();
@@ -178,9 +188,14 @@ async function init(): Promise<void> {
}
if (config.streamers && config.streamers.length > 0) {
const preloader = (window as unknown as { preloadConfiguredStreamerData?: (streamers: string[]) => Promise<void> }).preloadConfiguredStreamerData;
if (typeof preloader === 'function') void preloader(config.streamers as string[]);
await selectStreamer(config.streamers[0]);
}
const startBackgroundRefresh = (window as unknown as { startStreamerBackgroundRefresh?: () => void }).startStreamerBackgroundRefresh;
if (typeof startBackgroundRefresh === 'function') startBackgroundRefresh();
setTimeout(() => {
void checkUpdateSilent();
}, 3000);
@@ -895,6 +910,33 @@ function syncWorkspaceContextSelection(panel: HTMLElement): void {
});
}
function setVodsWorkspace(view: 'streamers' | 'queue', source?: HTMLElement): void {
const panel = document.querySelector<HTMLElement>('[data-context-for="vods"]');
if (!panel) return;
panel.dataset.vodsWorkspace = view;
const buttons = Array.from(panel.querySelectorAll<HTMLButtonElement>('.context-switcher button'));
buttons.forEach((button) => {
const active = source ? button === source : button.id === `${view === 'queue' ? 'queue' : 'streamer'}WorkspaceSwitch`;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
});
const switcher = panel.querySelector<HTMLElement>('.context-switcher');
if (switcher) scheduleSegmentedIndicatorSync(switcher);
}
function applySidebarLayoutPreference(enabled: boolean): void {
const panel = document.querySelector<HTMLElement>('[data-context-for="vods"]');
if (!panel) return;
panel.dataset.vodsLayout = enabled ? 'split' : 'tabs';
const toggle = document.getElementById('sidebarSplitViewToggle') as HTMLInputElement | null;
if (toggle) toggle.checked = enabled;
if (!enabled) {
setVodsWorkspace(panel.dataset.vodsWorkspace === 'queue' ? 'queue' : 'streamers');
}
}
function focusWorkspaceTarget(id: string, source?: HTMLElement): void {
const target = document.getElementById(id) as HTMLElement | null;
if (!target) return;
@@ -912,6 +954,9 @@ function focusWorkspaceTarget(id: string, source?: HTMLElement): void {
button.setAttribute('aria-pressed', String(active));
}
});
if (group.matches('[data-context-for="settings"] .context-list')) {
scheduleSegmentedIndicatorSync(group);
}
}
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -943,8 +988,10 @@ function showTab(tab: string): void {
}
navItem.classList.add('active');
navItem.setAttribute('aria-current', 'page');
syncTopNavActiveIndicator();
byId(tab + 'Tab').classList.add('active');
syncWorkspaceChrome(tab);
scheduleSegmentedIndicatorsSync();
const titles: Record<string, string> = UI_TEXT.tabs;
@@ -1549,6 +1596,81 @@ async function downloadClip(): Promise<void> {
}
}
let topNavIndicatorFrame: number | null = null;
function syncTopNavActiveIndicator(): void {
const topNav = document.querySelector<HTMLElement>('.top-nav');
const activeItem = topNav?.querySelector<HTMLElement>('.top-nav-item.active');
if (!topNav || !activeItem) return;
const navRect = topNav.getBoundingClientRect();
const itemRect = activeItem.getBoundingClientRect();
topNav.style.setProperty('--top-nav-active-x', `${itemRect.left - navRect.left}px`);
topNav.style.setProperty('--top-nav-active-y', `${itemRect.top - navRect.top}px`);
topNav.style.setProperty('--top-nav-active-width', `${itemRect.width}px`);
topNav.style.setProperty('--top-nav-active-height', `${itemRect.height}px`);
}
function scheduleTopNavActiveIndicatorSync(): void {
if (topNavIndicatorFrame !== null) return;
topNavIndicatorFrame = requestAnimationFrame(() => {
topNavIndicatorFrame = null;
syncTopNavActiveIndicator();
});
}
function initTopNavActiveIndicator(): void {
const topNav = document.querySelector<HTMLElement>('.top-nav');
if (!topNav) return;
const observer = new ResizeObserver(scheduleTopNavActiveIndicatorSync);
observer.observe(topNav);
topNav.querySelectorAll<HTMLElement>('.top-nav-item').forEach((item) => observer.observe(item));
window.addEventListener('resize', scheduleTopNavActiveIndicatorSync);
}
const segmentedIndicatorFrames = new WeakMap<HTMLElement, number>();
function syncSegmentedIndicator(control: HTMLElement): void {
const activeButton = control.querySelector<HTMLElement>('button.active');
if (!activeButton) {
control.classList.remove('segmented-indicator-visible');
return;
}
control.style.setProperty('--segment-active-x', `${activeButton.offsetLeft}px`);
control.style.setProperty('--segment-active-y', `${activeButton.offsetTop}px`);
control.style.setProperty('--segment-active-width', `${activeButton.offsetWidth}px`);
control.style.setProperty('--segment-active-height', `${activeButton.offsetHeight}px`);
control.classList.add('segmented-indicator-visible');
requestAnimationFrame(() => control.classList.add('segmented-indicator-ready'));
}
function scheduleSegmentedIndicatorSync(control: HTMLElement): void {
if (segmentedIndicatorFrames.has(control)) return;
const frame = requestAnimationFrame(() => {
segmentedIndicatorFrames.delete(control);
syncSegmentedIndicator(control);
});
segmentedIndicatorFrames.set(control, frame);
}
function scheduleSegmentedIndicatorsSync(): void {
document.querySelectorAll<HTMLElement>('.context-switcher, .language-picker, [data-context-for="settings"] .context-list').forEach(scheduleSegmentedIndicatorSync);
}
function initSegmentedIndicators(): void {
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const control = entry.target.closest<HTMLElement>('.context-switcher, .language-picker, [data-context-for="settings"] .context-list');
if (control) scheduleSegmentedIndicatorSync(control);
});
});
document.querySelectorAll<HTMLElement>('.context-switcher, .language-picker, [data-context-for="settings"] .context-list').forEach((control) => {
observer.observe(control);
control.querySelectorAll<HTMLElement>('button').forEach((button) => observer.observe(button));
syncSegmentedIndicator(control);
});
window.addEventListener('resize', scheduleSegmentedIndicatorsSync);
}
async function loadCutterFromPath(filePath: string): Promise<void> {
if (!filePath) return;
+62 -62
View File
@@ -973,6 +973,7 @@ select option {
}
.queue-item {
position: relative;
display: flex;
align-items: flex-start;
gap: 10px;
@@ -1070,12 +1071,14 @@ select option {
display: flex;
align-items: center;
gap: 8px;
min-height: 16px;
}
.queue-status-label {
flex-shrink: 0;
font-size: 10px;
color: var(--text-secondary);
line-height: 16px;
}
.queue-meta {
@@ -1099,14 +1102,12 @@ select option {
.queue-progress-bar {
height: 100%;
width: 0;
background: linear-gradient(90deg, var(--accent) 0%, #b97aff 100%);
background: linear-gradient(90deg, #168f4a 0%, var(--success) 100%);
transition: width 0.3s ease;
position: relative;
overflow: hidden;
}
/* Moving shimmer overlay implies "active" without needing a different
border or pulse rule. Only visible because the bar is purple. */
.queue-progress-bar::after {
content: '';
position: absolute;
@@ -1121,26 +1122,52 @@ select option {
100% { transform: translateX(100%); }
}
.queue-progress-bar.indeterminate {
width: 35% !important;
animation: queue-indeterminate 1.3s ease-in-out infinite;
}
.queue-progress-text {
.queue-progress-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 3px;
font-size: 10px;
color: var(--text-secondary);
line-height: 14px;
}
@keyframes queue-indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(280%); }
.queue-progress-status,
.queue-progress-metrics {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-progress-status.is-starting {
animation: queue-starting-pulse 1.1s ease-in-out infinite;
}
@keyframes queue-starting-pulse {
0%, 100% { opacity: 0.62; }
50% { opacity: 1; }
}
.queue-progress-metrics {
margin-left: auto;
text-align: right;
font-variant-numeric: tabular-nums;
}
.queue-item .remove {
display: inline-flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
flex: 0 0 16px;
cursor: pointer;
color: var(--error);
opacity: 0.7;
font-size: 11px;
line-height: 16px;
}
.queue-item .remove:hover {
@@ -1175,27 +1202,29 @@ select option {
margin-bottom: 2px;
}
.queue-selector {
min-width: 22px;
height: 22px;
padding: 0 3px;
border: 2px solid var(--text-secondary);
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
display: flex;
.queue-selection-order {
position: absolute;
z-index: 2;
top: -5px;
left: -5px;
display: inline-flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
font-size: 11px;
border: 2px solid var(--bg-panel);
border-radius: 50%;
background: var(--success);
color: #fff;
font-size: 10px;
font-weight: 700;
color: var(--bg-primary);
line-height: 1;
font-variant-numeric: tabular-nums;
user-select: none;
transition: all 0.15s;
}
.queue-selector:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55);
.queue-item.merge-selected {
box-shadow: inset 0 0 0 1px rgba(0, 200, 83, 0.45);
}
.queue-item .title:focus-visible {
@@ -1204,15 +1233,6 @@ select option {
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.45);
}
.queue-selector.selected {
background: var(--accent);
border-color: var(--accent);
}
.queue-selector:hover {
border-color: var(--accent);
}
.queue-item.merge-group {
border-left: 3px solid var(--accent);
}
@@ -1721,12 +1741,12 @@ input[type="checkbox"].vod-select-checkbox {
}
.vod-btn.primary {
background: var(--accent);
background: #1f7a43;
color: white;
}
.vod-btn.primary:hover {
background: var(--accent-hover);
background: #186638;
}
.vod-btn.secondary {
@@ -1748,7 +1768,7 @@ input[type="checkbox"].vod-select-checkbox {
}
.vod-btn.primary:focus-visible {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(31, 122, 67, 0.65);
}
/* Settings */
@@ -1905,17 +1925,9 @@ input[type="checkbox"].vod-select-checkbox {
}
.flag-en {
background: repeating-linear-gradient(to bottom, #b22234 0 7.7%, #ffffff 7.7% 15.4%);
}
.flag-en::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 45%;
height: 56%;
background: #3c3b6e;
width: 18px;
background: none;
opacity: 1;
}
.form-row {
@@ -3884,16 +3896,6 @@ input[type="number"]::-webkit-outer-spin-button {
letter-spacing: 0.2px;
}
.streamer-section-counter-divider {
opacity: 0.5;
margin: 0 1px;
}
.streamer-section-counter-live {
color: #e91916;
font-weight: 600;
}
/* Empty-state hint inside the sidebar streamer list (no streamers
added yet). Subtler than the full-page .empty-state fits the
narrow sidebar context. */
@@ -3945,8 +3947,6 @@ input[type="number"]::-webkit-outer-spin-button {
background: rgba(0, 0, 0, 0.88);
}
.vod-card.preview-active .vod-duration-badge,
.vod-card.preview-active .vod-select-checkbox,
.vod-card.preview-active .vod-downloaded-badge {
opacity: 0;
transition: opacity 0.2s;
+1
View File
@@ -39,6 +39,7 @@ export interface QueueItem {
eta?: string;
downloadedBytes?: number;
totalBytes?: number;
progressStatus?: string;
last_error?: string;
customClip?: CustomClip;
mergeGroup?: MergeGroup;
+692 -27
View File
@@ -43,6 +43,25 @@
--border-soft: var(--workspace-border);
}
body {
-webkit-user-select: none;
user-select: none;
}
input:not([type]),
input[type="text"],
input[type="search"],
input[type="password"],
input[type="email"],
input[type="url"],
input[type="tel"],
input[type="number"],
textarea,
[contenteditable="true"] {
-webkit-user-select: text;
user-select: text;
}
body.theme-twitch,
body.theme-dark,
body.theme-discord,
@@ -238,6 +257,11 @@ textarea:disabled,
fill: none;
}
.topbar-brand img.topbar-brand-mark {
object-fit: cover;
border-radius: 5px;
}
.topbar-brand-name {
overflow: hidden;
color: var(--workspace-text);
@@ -249,6 +273,7 @@ textarea:disabled,
.top-nav {
display: flex;
position: relative;
min-width: 0;
height: 40px;
align-items: center;
@@ -260,8 +285,25 @@ textarea:disabled,
border-radius: var(--workspace-radius);
}
.top-nav::before {
content: '';
position: absolute;
z-index: 0;
top: 0;
left: 0;
width: var(--top-nav-active-width, 0px);
height: var(--top-nav-active-height, 0px);
pointer-events: none;
background: var(--workspace-primary);
border-radius: var(--workspace-radius-small);
transform: translate3d(var(--top-nav-active-x, 0px), var(--top-nav-active-y, 0px), 0);
transition: transform 420ms cubic-bezier(0.22, 0.76, 0.22, 1), width 420ms cubic-bezier(0.22, 0.76, 0.22, 1), height 420ms cubic-bezier(0.22, 0.76, 0.22, 1);
}
.top-nav-item.nav-item {
display: inline-flex;
position: relative;
z-index: 1;
width: 34px;
min-width: 34px;
height: 32px;
@@ -285,8 +327,12 @@ textarea:disabled,
.top-nav-item.nav-item.active {
color: var(--workspace-primary-text);
background: var(--workspace-primary);
border-color: var(--workspace-primary);
background: transparent;
border-color: transparent;
}
.top-nav-item.nav-item.active:hover {
background: transparent;
}
.top-nav-item.nav-item svg {
@@ -656,6 +702,7 @@ textarea:disabled,
}
.context-sidebar .streamers {
position: relative;
flex: 1 1 auto;
min-height: 0;
padding: 0 10px;
@@ -663,7 +710,32 @@ textarea:disabled,
overflow-y: auto;
}
.context-sidebar .streamers::before {
position: absolute;
z-index: 0;
top: 0;
left: 0;
width: var(--streamer-active-width, 0px);
height: var(--streamer-active-height, 0px);
pointer-events: none;
background: var(--workspace-control);
border-radius: var(--workspace-radius-small);
content: '';
opacity: 0;
transform: translate3d(var(--streamer-active-x, 0px), var(--streamer-active-y, 0px), 0);
}
.context-sidebar .streamers.streamer-indicator-visible::before {
opacity: 1;
}
.context-sidebar .streamers.streamer-indicator-ready::before {
transition: transform 360ms cubic-bezier(0.22, 0.76, 0.22, 1), width 360ms cubic-bezier(0.22, 0.76, 0.22, 1), height 360ms cubic-bezier(0.22, 0.76, 0.22, 1);
}
.context-sidebar .streamer-item {
position: relative;
z-index: 1;
min-height: 36px;
gap: 8px;
margin: 1px 0;
@@ -683,15 +755,95 @@ textarea:disabled,
.context-sidebar .streamer-item.active {
color: var(--workspace-primary);
background: var(--workspace-control);
background: transparent;
border-color: transparent;
box-shadow: none;
}
.context-sidebar .streamer-item.active:hover {
background: transparent;
}
.context-sidebar .streamer-item.active::after {
display: none;
}
.context-sidebar .streamer-item .remove {
display: inline-flex;
flex: 0 0 16px;
align-items: center;
justify-content: center;
margin-left: auto;
opacity: 0.75;
font-size: 14px;
font-weight: 600;
}
.streamer-context-menu {
position: fixed;
z-index: 1000;
display: grid;
min-width: 122px;
padding: 4px;
background: var(--workspace-panel-raised);
border: 1px solid var(--workspace-border);
border-radius: var(--workspace-radius-small);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
}
.streamer-context-action {
display: inline-flex;
min-height: 30px;
align-items: center;
justify-content: space-between;
padding: 0 9px;
color: var(--workspace-text);
background: transparent;
border: 0;
border-radius: 5px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
}
.streamer-context-action:hover,
.streamer-context-action:focus-visible {
color: var(--workspace-text);
background: var(--workspace-control-hover);
outline: none;
}
.streamer-context-action.is-active::after {
margin-left: 16px;
color: #55d68a;
content: "✓";
}
.streamer-context-record {
color: #ff7770;
}
.context-sidebar .streamer-section-counter {
display: inline-flex;
min-height: 20px;
align-items: center;
margin-left: auto;
margin-right: 6px;
padding: 0 7px;
color: var(--workspace-text-muted);
background: var(--workspace-control);
border: 1px solid var(--workspace-border);
border-radius: 999px;
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.context-sidebar .streamer-section-counter:empty {
display: none;
}
.context-sidebar .streamer-avatar,
.context-sidebar .streamer-avatar-fallback {
width: 24px;
@@ -709,6 +861,19 @@ textarea:disabled,
border-top: 1px solid var(--workspace-border);
}
.context-sidebar .streamer-section {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
}
.context-sidebar .streamer-section .streamers {
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
}
.context-sidebar .queue-header {
min-height: 30px;
padding: 0 2px;
@@ -727,6 +892,59 @@ textarea:disabled,
overflow-y: auto;
}
.context-sidebar .queue-count {
display: inline-flex;
min-width: 20px;
min-height: 20px;
align-items: center;
justify-content: center;
padding: 0 7px;
color: #ffffff;
background: #1f7a43;
border: 1px solid #1f7a43;
border-radius: 999px;
font-weight: 700;
}
.context-panel[data-context-for="vods"][data-vods-layout="tabs"][data-vods-workspace="streamers"] .queue-section {
display: none;
}
.context-panel[data-context-for="vods"][data-vods-layout="tabs"][data-vods-workspace="queue"] .streamer-section {
display: none;
}
.context-panel[data-context-for="vods"][data-vods-layout="tabs"][data-vods-workspace="queue"] .queue-section {
flex: 1 1 auto;
max-height: none;
}
.context-panel[data-context-for="vods"][data-vods-layout="tabs"][data-vods-workspace="queue"] .queue-list {
max-height: none;
}
.context-panel[data-context-for="vods"][data-vods-layout="split"] .context-switcher {
display: none;
}
.context-panel[data-context-for="vods"][data-vods-layout="split"] .streamer-section,
.context-panel[data-context-for="vods"][data-vods-layout="split"] .queue-section {
min-height: 0;
flex: 1 1 50%;
max-height: none;
}
.context-panel[data-context-for="vods"][data-vods-layout="split"] .queue-section {
display: flex;
flex-direction: column;
}
.context-panel[data-context-for="vods"][data-vods-layout="split"] .queue-list {
min-height: 0;
max-height: none;
flex: 1 1 auto;
}
.context-sidebar .queue-item {
margin: 3px 0;
padding: 7px;
@@ -951,6 +1169,7 @@ textarea:disabled,
height: 100%;
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
padding: 16px;
background: var(--workspace-panel);
}
@@ -1202,13 +1421,13 @@ textarea:focus {
input[type="checkbox"] {
width: 15px;
height: 15px;
background: var(--workspace-panel-raised);
background-color: var(--workspace-panel-raised);
border: 1px solid var(--workspace-border-strong);
border-radius: 3px;
}
input[type="checkbox"]:checked {
background: var(--workspace-primary);
background-color: var(--workspace-primary);
border-color: var(--workspace-primary);
}
@@ -1318,14 +1537,101 @@ input[type="range"] {
margin-bottom: 10px;
}
.vod-filter-row {
flex-wrap: nowrap;
}
.vod-filter-row .vod-filter-count {
flex: 0 0 auto;
min-width: 0;
}
.vod-filter-row .vod-hide-downloaded {
flex: 0 0 auto;
margin-left: auto;
white-space: nowrap;
}
.vod-filter-row .vod-sort-label {
flex: 0 0 auto;
margin-left: 4px;
}
.vod-filter-row #vodSortSelect {
flex: 0 0 auto;
}
.vod-bulk-bar {
padding: 8px 10px;
position: fixed;
z-index: 30;
bottom: 38px;
left: calc((100vw + var(--workspace-sidebar-width) + var(--workspace-content-gap)) / 2);
width: max-content;
max-width: calc(100vw - var(--workspace-sidebar-width) - 56px);
margin: 0;
padding: 9px 10px;
flex-wrap: nowrap;
color: var(--workspace-text);
background: var(--workspace-panel-raised);
border: 1px solid var(--workspace-primary);
border-radius: var(--workspace-radius-small);
box-shadow: none;
animation: none;
background: rgba(31, 31, 31, 0.96);
border: 1px solid var(--workspace-border-strong);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.48), 0 0 0 1px rgba(255, 255, 255, 0.04);
backdrop-filter: blur(12px);
transform: translateX(-50%);
animation: vod-bulk-dock-in 240ms cubic-bezier(0.22, 0.76, 0.22, 1);
}
.vod-bulk-bar .vod-bulk-count {
width: 112px;
flex: 0 0 112px;
padding: 0 8px 0 4px;
box-sizing: border-box;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.streamer-profile-stat {
min-height: 18px;
line-height: 18px;
}
.streamer-profile-stat strong {
line-height: inherit;
}
.streamer-profile-stat svg {
display: block;
flex: 0 0 14px;
}
.vod-bulk-bar .vod-bulk-spacer {
display: none;
}
.vod-bulk-bar .btn-pill {
white-space: nowrap;
}
.vod-bulk-bar #vodBulkAddBtn {
color: #fff;
background: #16803d;
border-color: #1c9a4a;
}
.vod-bulk-bar #vodBulkAddBtn:hover:not(:disabled) {
background: #1a9146;
border-color: #22c55e;
}
@keyframes vod-bulk-dock-in {
from {
opacity: 0;
transform: translate(-50%, 12px) scale(0.98);
}
to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}
.vod-grid {
@@ -1347,8 +1653,35 @@ input[type="range"] {
}
.vod-card.selected {
border-color: var(--workspace-primary);
box-shadow: inset 0 0 0 1px var(--workspace-primary);
border-color: #22c55e;
box-shadow: inset 0 0 0 1px #22c55e, 0 0 0 1px rgba(34, 197, 94, 0.24);
}
input[type="checkbox"].vod-select-checkbox {
width: 20px;
height: 20px;
z-index: 4;
opacity: 1;
background-color: rgba(18, 22, 20, 0.94);
border: 2px solid rgba(255, 255, 255, 0.82);
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.85);
}
input[type="checkbox"].vod-select-checkbox:hover:not(:disabled) {
border-color: #86efac;
}
input[type="checkbox"].vod-select-checkbox:checked {
background-color: rgba(18, 36, 25, 0.96);
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2322c55e' stroke-width='2.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3.5 8.5 6.5 11.5 12.5 5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
background-size: 14px;
border-color: #22c55e;
}
input[type="checkbox"].vod-select-checkbox:focus-visible {
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.28), 0 1px 5px rgba(0, 0, 0, 0.85);
}
.vod-thumbnail,
@@ -1357,20 +1690,73 @@ input[type="range"] {
}
.vod-info {
min-width: 0;
padding: 10px;
}
.vod-title {
display: block;
min-width: 0;
margin-bottom: 7px;
overflow: hidden;
color: var(--workspace-text);
font-size: 12px;
font-weight: 600;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.vod-meta,
.vod-date,
.vod-duration {
.vod-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.vod-meta > span {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 5px;
overflow: hidden;
color: var(--workspace-text-muted);
font-size: 11px;
font-size: 12px;
font-weight: 500;
text-overflow: ellipsis;
}
.vod-meta svg {
width: 13px;
height: 13px;
flex: 0 0 auto;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.8;
}
.vod-views {
text-align: right;
}
.vod-duration-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 22px;
padding: 3px 8px;
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.72);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.9), 0 0 0 1px rgba(0, 0, 0, 0.7);
font-size: 12px;
font-weight: 700;
line-height: 1.2;
z-index: 4;
opacity: 1;
}
.streamer-profile-header {
@@ -1383,6 +1769,13 @@ input[type="range"] {
border-radius: var(--workspace-radius);
box-shadow: none;
animation: none;
width: 100%;
min-height: 94px;
box-sizing: border-box;
}
.streamer-profile-header.is-hidden {
min-height: 0;
}
.streamer-profile-header::before {
@@ -1530,6 +1923,11 @@ input[type="range"] {
border-radius: 0;
}
#settingsTab .settings-card[data-settings-pane] {
margin-bottom: 0;
border-bottom: 0;
}
#settingsTab .settings-card:last-child {
margin-bottom: 0;
border-bottom: 0;
@@ -1669,6 +2067,10 @@ input[type="range"] {
box-shadow: none;
}
.status-dot.public {
background: #f59e0b;
}
.update-banner {
position: static;
display: none;
@@ -1770,6 +2172,14 @@ input[type="range"] {
border-color: var(--workspace-border-strong);
}
.update-modal-actions button {
min-height: 44px;
height: auto;
padding: 7px 12px;
line-height: 1.25;
white-space: normal;
}
.update-modal-eyebrow,
.update-changelog-toggle,
.template-guide-preview-label {
@@ -2167,6 +2577,20 @@ input[type="range"] {
}
@media (prefers-reduced-motion: reduce) {
.top-nav::before {
transition: transform 420ms cubic-bezier(0.22, 0.76, 0.22, 1), width 420ms cubic-bezier(0.22, 0.76, 0.22, 1), height 420ms cubic-bezier(0.22, 0.76, 0.22, 1) !important;
}
.context-sidebar .streamers.streamer-indicator-ready::before {
transition: transform 360ms cubic-bezier(0.22, 0.76, 0.22, 1), width 360ms cubic-bezier(0.22, 0.76, 0.22, 1), height 360ms cubic-bezier(0.22, 0.76, 0.22, 1) !important;
}
.context-switcher.segmented-indicator-ready::before,
.language-picker.segmented-indicator-ready::before,
[data-context-for="settings"] .context-list.segmented-indicator-ready::before {
transition: transform 360ms cubic-bezier(0.22, 0.76, 0.22, 1), width 360ms cubic-bezier(0.22, 0.76, 0.22, 1), height 360ms cubic-bezier(0.22, 0.76, 0.22, 1) !important;
}
*,
*::before,
*::after {
@@ -2295,7 +2719,7 @@ input[type="range"] {
.update-banner.workspace-update {
position: relative;
display: flex;
display: none;
width: auto;
min-width: 90px;
height: 40px;
@@ -2309,7 +2733,7 @@ input[type="range"] {
transform: none;
}
.update-banner.workspace-update.show {
.update-banner.workspace-update.show:not([hidden]) {
display: flex;
}
@@ -2422,6 +2846,8 @@ input[type="range"] {
.context-switcher {
display: flex;
position: relative;
isolation: isolate;
flex: 0 0 auto;
width: calc(100% - 24px);
min-height: 36px;
@@ -2433,8 +2859,39 @@ input[type="range"] {
border-radius: 6px;
}
.context-switcher::before,
.language-picker::before,
[data-context-for="settings"] .context-list::before {
position: absolute;
z-index: 0;
top: 0;
left: 0;
width: var(--segment-active-width, 0px);
height: var(--segment-active-height, 0px);
pointer-events: none;
background: var(--workspace-primary);
border-radius: 5px;
content: '';
opacity: 0;
transform: translate3d(var(--segment-active-x, 0px), var(--segment-active-y, 0px), 0);
}
.context-switcher.segmented-indicator-visible::before,
.language-picker.segmented-indicator-visible::before,
[data-context-for="settings"] .context-list.segmented-indicator-visible::before {
opacity: 1;
}
.context-switcher.segmented-indicator-ready::before,
.language-picker.segmented-indicator-ready::before,
[data-context-for="settings"] .context-list.segmented-indicator-ready::before {
transition: transform 360ms cubic-bezier(0.22, 0.76, 0.22, 1), width 360ms cubic-bezier(0.22, 0.76, 0.22, 1), height 360ms cubic-bezier(0.22, 0.76, 0.22, 1);
}
.context-switcher button {
display: inline-flex;
position: relative;
z-index: 1;
flex: 1 1 0;
height: 28px;
align-items: center;
@@ -2455,7 +2912,53 @@ input[type="range"] {
.context-switcher button.active {
color: var(--workspace-primary-text);
background: var(--workspace-primary);
background: transparent;
}
.context-switcher button.active:hover {
background: transparent;
}
#settingsTab .language-picker {
display: grid;
position: relative;
isolation: isolate;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
padding: 4px;
overflow: hidden;
background: var(--workspace-border);
border-radius: 6px;
}
#settingsTab .language-picker .lang-option {
position: relative;
z-index: 1;
justify-content: center;
min-width: 0;
background: transparent;
border-color: transparent;
box-shadow: none;
}
#settingsTab .language-picker .lang-option > span:not(.flag-icon) {
color: #fff;
mix-blend-mode: difference;
}
#settingsTab .language-picker .lang-option:hover {
background: var(--workspace-control);
}
#settingsTab .language-picker .lang-option.active {
color: var(--workspace-primary-text);
background: transparent;
border-color: transparent;
box-shadow: none;
}
#settingsTab .language-picker .lang-option.active:hover {
background: transparent;
}
.context-panel-heading {
@@ -2483,6 +2986,16 @@ input[type="range"] {
overflow-y: auto;
}
[data-context-for="settings"] .context-list {
position: relative;
isolation: isolate;
}
[data-context-for="settings"] .context-list::before {
background: var(--workspace-control);
border-radius: 6px;
}
.context-link {
display: flex;
flex: 0 0 auto;
@@ -2530,6 +3043,16 @@ input[type="range"] {
white-space: nowrap;
}
[data-context-for="settings"] .context-link {
position: relative;
z-index: 1;
}
[data-context-for="settings"] .context-link.active,
[data-context-for="settings"] .context-link.active:hover {
background: transparent;
}
.context-sidebar .section-title,
.context-sidebar .streamer-item,
.context-sidebar .queue-title {
@@ -2880,8 +3403,8 @@ input[type="range"] {
.top-nav-item.nav-item.active:hover {
color: var(--workspace-primary-text);
background: var(--workspace-primary-hover);
border-color: var(--workspace-primary-hover);
background: transparent;
border-color: transparent;
}
.top-nav .top-nav-item:focus-visible {
@@ -2891,7 +3414,7 @@ input[type="range"] {
}
#settingsTab .settings-card:has(#downloadPath) {
width: min(720px, 100%);
width: 100%;
}
#settingsTab .form-row:has(#downloadPath) {
@@ -2909,6 +3432,114 @@ input[type="range"] {
white-space: nowrap;
}
.download-settings-storage {
margin-bottom: 14px;
}
.download-settings-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 16px;
}
.download-settings-column {
display: grid;
min-width: 0;
gap: 16px;
}
#settingsTab .download-settings-section {
min-width: 0;
padding: 16px;
background: var(--workspace-panel-raised);
border: 1px solid var(--workspace-border);
border-radius: var(--workspace-radius);
}
#settingsTab .download-settings-section h4 {
margin: 0 0 14px;
font-size: 14px;
line-height: 20px;
}
.download-settings-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
#settingsTab .download-settings-section-heading h4 {
margin: 0;
}
.download-settings-section .form-group:last-child,
.download-settings-section .filename-template-grid:last-child {
margin-bottom: 0;
}
.download-settings-toggles {
display: grid;
gap: 5px;
}
#settingsTab .download-settings-toggles .toggle-row {
min-height: 32px;
gap: 10px;
margin: 0;
padding: 5px 7px;
border-radius: var(--workspace-radius-small);
font-size: 13px;
line-height: 18px;
}
#settingsTab .download-settings-toggles .toggle-row:hover {
background: var(--workspace-control-hover);
}
#settingsTab .download-settings-toggles input[type="checkbox"],
#settingsTab .sidebar-layout-setting input[type="checkbox"] {
width: 20px;
height: 20px;
flex: 0 0 20px;
background-size: 15px;
}
.metadata-cache-setting {
margin-top: 14px;
margin-bottom: 0;
padding-top: 14px;
border-top: 1px solid var(--workspace-border);
}
.template-presets {
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
#settingsTab .download-settings-section #filenameTemplateHint {
margin-top: 9px;
}
.sidebar-layout-setting {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--workspace-border);
}
#settingsTab .sidebar-layout-setting .toggle-row {
gap: 10px;
font-size: 13px;
font-weight: 600;
}
#settingsTab .sidebar-layout-setting .form-note {
margin: 7px 0 0 30px;
}
#clipsTab.active {
display: grid;
grid-template-columns: minmax(420px, 1.35fr) minmax(280px, 0.65fr);
@@ -3111,9 +3742,9 @@ input[type="range"] {
@media (min-width: 1180px) {
.topbar-navigation-cluster {
flex: 0 1 1000px;
width: 1000px;
max-width: calc(100vw - 254px);
flex: 1 1 auto;
width: auto;
max-width: none;
}
.topbar-navigation-cluster .topbar-brand {
@@ -3141,7 +3772,7 @@ input[type="range"] {
}
.topbar-navigation-cluster .top-nav-item.nav-item {
flex: 1 1 auto;
flex: 1 0 var(--top-nav-item-width, 100px);
width: auto;
min-width: 44px;
max-width: none;
@@ -3165,6 +3796,34 @@ input[type="range"] {
border: 0;
font-size: 12px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="vods"] {
--top-nav-item-width: 110px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="clips"] {
--top-nav-item-width: 110px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="cutter"] {
--top-nav-item-width: 138px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="merge"] {
--top-nav-item-width: 180px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="stats"] {
--top-nav-item-width: 110px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="archive"] {
--top-nav-item-width: 90px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="settings"] {
--top-nav-item-width: 120px;
}
}
@media (max-width: 1179px) {
@@ -3195,6 +3854,12 @@ input[type="range"] {
}
}
@media (max-width: 1200px) {
.download-settings-layout {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 820px) {
#clipsTab.active {
grid-template-columns: minmax(0, 1fr);