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:
+200
-214
@@ -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 & 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 & 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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
|
||||
Vendored
+7
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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}')">↻</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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface QueueItem {
|
||||
eta?: string;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
progressStatus?: string;
|
||||
last_error?: string;
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
|
||||
+692
-27
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user