Compare commits

..

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

17 changed files with 431 additions and 838 deletions

4
package-lock.json generated
View File

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

View File

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

View File

@ -10,23 +10,23 @@
<body class="theme-twitch"> <body class="theme-twitch">
<div class="update-banner" id="updateBanner"> <div class="update-banner" id="updateBanner">
<span id="updateText">Neue Version verfügbar!</span> <span id="updateText">Neue Version verfügbar!</span>
<div id="updateProgress" class="update-banner-progress-wrap is-hidden"> <div id="updateProgress" class="update-banner-progress-wrap" style="display: none;">
<div class="update-banner-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Update download" id="updateProgressGauge"> <div class="update-banner-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Update download" id="updateProgressGauge">
<div id="updateProgressBar" class="update-banner-progress-bar"></div> <div id="updateProgressBar" class="update-banner-progress-bar"></div>
</div> </div>
</div> </div>
<button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button> <button id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
</div> </div>
<div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)"> <div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)">
<div class="modal update-modal"> <div class="modal update-modal">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button> <button class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button>
<div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div> <div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div>
<h2 id="updateModalTitle">Update verfugbar</h2> <h2 id="updateModalTitle">Update verfugbar</h2>
<p class="update-modal-message" id="updateModalMessage">Version 0.0.0 ist verfugbar. Jetzt herunterladen?</p> <p class="update-modal-message" id="updateModalMessage">Version 0.0.0 ist verfugbar. Jetzt herunterladen?</p>
<div class="update-modal-meta is-hidden" id="updateModalMeta"></div> <div class="update-modal-meta" id="updateModalMeta" style="display:none;"></div>
<div class="update-changelog-card is-hidden" id="updateChangelogCard"> <div class="update-changelog-card" id="updateChangelogCard" style="display:none;">
<div class="update-changelog-header"> <div class="update-changelog-header">
<span class="update-changelog-label" id="updateChangelogLabel">Changelog</span> <span class="update-changelog-label" id="updateChangelogLabel">Changelog</span>
<button type="button" class="update-changelog-toggle" id="updateChangelogToggle" onclick="toggleUpdateChangelog()">Changelog anzeigen</button> <button type="button" class="update-changelog-toggle" id="updateChangelogToggle" onclick="toggleUpdateChangelog()">Changelog anzeigen</button>
@ -48,7 +48,7 @@
<!-- Clip Dialog Modal --> <!-- Clip Dialog Modal -->
<div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-labelledby="clipDialogTitle"> <div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-labelledby="clipDialogTitle">
<div class="modal clip-modal"> <div class="modal clip-modal">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeClipDialog()">x</button> <button class="modal-close modal-close-localizable" aria-label="Close" onclick="closeClipDialog()">x</button>
<h2 class="clip-modal-title" id="clipDialogTitle">VOD zuschneiden</h2> <h2 class="clip-modal-title" id="clipDialogTitle">VOD zuschneiden</h2>
<div class="clip-modal-field"> <div class="clip-modal-field">
@ -99,16 +99,16 @@
<span id="formatTemplate" class="clip-radio-label">{date}_{part}.mp4 (benutzerdefiniert)</span> <span id="formatTemplate" class="clip-radio-label">{date}_{part}.mp4 (benutzerdefiniert)</span>
</label> </label>
<div id="clipFilenameTemplateWrap" class="clip-template-wrap"> <div id="clipFilenameTemplateWrap" class="clip-template-wrap" style="display:none;">
<input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4" placeholder="{date}_{part}.mp4" class="clip-modal-template-input" oninput="updateFilenameExamples()"> <input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4" placeholder="{date}_{part}.mp4" class="clip-modal-template-input" oninput="updateFilenameExamples()">
<div id="clipTemplateHelp" class="clip-modal-hint">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div> <div id="clipTemplateHelp" class="clip-modal-hint">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<div id="clipTemplateLint" class="template-lint ok">Template-Check: OK</div> <div id="clipTemplateLint" class="template-lint ok" style="margin-top: 4px;">Template-Check: OK</div>
<button type="button" class="btn-secondary" id="clipTemplateGuideBtn" onclick="openTemplateGuide('clip')">Template Guide</button> <button class="btn-secondary" id="clipTemplateGuideBtn" style="margin-top: 8px;" onclick="openTemplateGuide('clip')">Template Guide</button>
</div> </div>
</div> </div>
<div class="clip-modal-actions"> <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 class="btn-pill success" id="clipDialogConfirmBtn" style="padding: 12px 30px;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
</div> </div>
</div> </div>
</div> </div>
@ -116,9 +116,9 @@
<!-- Events Viewer Modal --> <!-- Events Viewer Modal -->
<div class="modal-overlay" id="eventsViewerModal" role="dialog" aria-modal="true" aria-labelledby="eventsViewerTitle"> <div class="modal-overlay" id="eventsViewerModal" role="dialog" aria-modal="true" aria-labelledby="eventsViewerTitle">
<div class="modal viewer-modal viewer-modal-events"> <div class="modal viewer-modal viewer-modal-events">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeEventsViewer()">x</button> <button class="modal-close modal-close-localizable" aria-label="Close" onclick="closeEventsViewer()">x</button>
<h2 id="eventsViewerTitle" class="viewer-modal-title"></h2> <h2 id="eventsViewerTitle" class="viewer-modal-title"></h2>
<div id="eventsViewerStatus" class="viewer-modal-status" role="status" aria-live="polite"></div> <div id="eventsViewerStatus" class="viewer-modal-status"></div>
<div id="eventsViewerList" class="viewer-modal-list"></div> <div id="eventsViewerList" class="viewer-modal-list"></div>
</div> </div>
</div> </div>
@ -126,11 +126,11 @@
<!-- Chat Replay Viewer Modal --> <!-- Chat Replay Viewer Modal -->
<div class="modal-overlay" id="chatViewerModal" role="dialog" aria-modal="true" aria-labelledby="chatViewerTitle"> <div class="modal-overlay" id="chatViewerModal" role="dialog" aria-modal="true" aria-labelledby="chatViewerTitle">
<div class="modal viewer-modal viewer-modal-chat"> <div class="modal viewer-modal viewer-modal-chat">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeChatViewer()">x</button> <button class="modal-close modal-close-localizable" aria-label="Close" onclick="closeChatViewer()">x</button>
<h2 id="chatViewerTitle" class="viewer-modal-title"></h2> <h2 id="chatViewerTitle" class="viewer-modal-title"></h2>
<div class="viewer-modal-filter-row"> <div class="viewer-modal-filter-row">
<input type="text" id="chatViewerFilter" class="viewer-modal-filter-input" placeholder="Filter..." oninput="onChatViewerFilterChange()"> <input type="text" id="chatViewerFilter" class="viewer-modal-filter-input" placeholder="Filter..." oninput="onChatViewerFilterChange()">
<span id="chatViewerStatus" class="viewer-modal-status viewer-modal-status-inline" role="status" aria-live="polite"></span> <span id="chatViewerStatus" class="viewer-modal-status viewer-modal-status-inline"></span>
</div> </div>
<div id="chatViewerList" class="viewer-modal-list viewer-modal-list-chat"></div> <div id="chatViewerList" class="viewer-modal-list viewer-modal-list-chat"></div>
</div> </div>
@ -139,17 +139,17 @@
<!-- Template Guide Modal --> <!-- Template Guide Modal -->
<div class="modal-overlay" id="templateGuideModal" role="dialog" aria-modal="true" aria-labelledby="templateGuideTitle"> <div class="modal-overlay" id="templateGuideModal" role="dialog" aria-modal="true" aria-labelledby="templateGuideTitle">
<div class="modal template-guide-modal"> <div class="modal template-guide-modal">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button> <button class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button>
<h2 id="templateGuideTitle">Template Guide</h2> <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 fur Dateinamen und prufe das Ergebnis als Live-Vorschau.</p>
<div class="template-guide-actions"> <div class="template-guide-actions">
<button type="button" class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button> <button class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button>
<button type="button" class="btn-secondary" id="templateGuideUseParts" onclick="setTemplateGuidePreset('parts')">VOD Part Template</button> <button class="btn-secondary" id="templateGuideUseParts" onclick="setTemplateGuidePreset('parts')">VOD Part Template</button>
<button type="button" class="btn-secondary" id="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button> <button class="btn-secondary" id="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button>
</div> </div>
<label id="templateGuideTemplateLabel" for="templateGuideInput" class="template-guide-label">Template</label> <label id="templateGuideTemplateLabel" class="template-guide-label">Template</label>
<input type="text" id="templateGuideInput" class="template-guide-input" oninput="updateTemplateGuidePreview()" placeholder="{title}.mp4"> <input type="text" id="templateGuideInput" class="template-guide-input" oninput="updateTemplateGuidePreview()" placeholder="{title}.mp4">
<div class="template-guide-preview-box"> <div class="template-guide-preview-box">
@ -160,12 +160,12 @@
<h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfugbare Variablen</h3> <h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfugbare Variablen</h3>
<div class="template-guide-table-wrap"> <div class="template-guide-table-wrap">
<table class="template-guide-table" aria-labelledby="templateGuideVarsTitle"> <table class="template-guide-table">
<thead> <thead>
<tr> <tr>
<th id="templateGuideVarCol" scope="col">Variable</th> <th id="templateGuideVarCol">Variable</th>
<th id="templateGuideDescCol" scope="col">Beschreibung</th> <th id="templateGuideDescCol">Beschreibung</th>
<th id="templateGuideExampleCol" scope="col">Beispiel</th> <th id="templateGuideExampleCol">Beispiel</th>
</tr> </tr>
</thead> </thead>
<tbody id="templateGuideBody"></tbody> <tbody id="templateGuideBody"></tbody>
@ -173,7 +173,7 @@
</div> </div>
<div class="template-guide-footer"> <div class="template-guide-footer">
<button type="button" class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button> <button class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button>
</div> </div>
</div> </div>
</div> </div>
@ -181,49 +181,49 @@
<div class="app"> <div class="app">
<aside class="sidebar"> <aside class="sidebar">
<div class="logo"> <div class="logo">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg> <svg viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
<span id="logoText">Twitch VOD Manager</span> <span id="logoText">Twitch VOD Manager</span>
</div> </div>
<nav class="nav"> <nav class="nav">
<div class="nav-item active" role="button" tabindex="0" aria-current="page" data-tab="vods" onclick="showTab('vods')"> <div class="nav-item active" role="button" tabindex="0" aria-current="page" data-tab="vods" onclick="showTab('vods')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
<span id="navVodsText">Twitch VODs</span> <span id="navVodsText">Twitch VODs</span>
</div> </div>
<div class="nav-item" role="button" tabindex="0" data-tab="clips" onclick="showTab('clips')"> <div class="nav-item" role="button" tabindex="0" data-tab="clips" onclick="showTab('clips')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
<span id="navClipsText">Twitch Clips</span> <span id="navClipsText">Twitch Clips</span>
</div> </div>
<div class="nav-item" role="button" tabindex="0" data-tab="cutter" onclick="showTab('cutter')"> <div class="nav-item" role="button" tabindex="0" data-tab="cutter" onclick="showTab('cutter')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg>
<span id="navCutterText">Video schneiden</span> <span id="navCutterText">Video schneiden</span>
</div> </div>
<div class="nav-item" role="button" tabindex="0" data-tab="merge" onclick="showTab('merge')"> <div class="nav-item" role="button" tabindex="0" data-tab="merge" onclick="showTab('merge')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg>
<span id="navMergeText">Videos zusammenfugen</span> <span id="navMergeText">Videos zusammenfugen</span>
</div> </div>
<div class="nav-item" role="button" tabindex="0" data-tab="stats" onclick="showTab('stats')"> <div class="nav-item" role="button" tabindex="0" data-tab="stats" onclick="showTab('stats')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v8H3zm4-7h2v15H7zm4 4h2v11h-2zm4 4h2v7h-2zm4-8h2v15h-2z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v8H3zm4-7h2v15H7zm4 4h2v11h-2zm4 4h2v7h-2zm4-8h2v15h-2z"/></svg>
<span id="navStatsText">Statistik</span> <span id="navStatsText">Statistik</span>
</div> </div>
<div class="nav-item" role="button" tabindex="0" data-tab="archive" onclick="showTab('archive')"> <div class="nav-item" role="button" tabindex="0" data-tab="archive" onclick="showTab('archive')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<span id="navArchiveText">Archiv</span> <span id="navArchiveText">Archiv</span>
</div> </div>
<div class="nav-item" role="button" tabindex="0" data-tab="settings" onclick="showTab('settings')"> <div class="nav-item" role="button" tabindex="0" data-tab="settings" onclick="showTab('settings')">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
<span id="navSettingsText">Einstellungen</span> <span id="navSettingsText">Einstellungen</span>
</div> </div>
</nav> </nav>
<div class="section-title" id="streamerSectionTitle"> <div class="section-title" id="streamerSectionTitle" style="display:flex; align-items:center; gap:6px; justify-content:space-between;">
<span class="section-title-label"> <span style="display:flex; align-items:baseline; gap:8px;">
<span id="streamerSectionTitleText">Streamer</span> <span id="streamerSectionTitleText">Streamer</span>
<span id="streamerSectionCounter" class="streamer-section-counter"></span> <span id="streamerSectionCounter" class="streamer-section-counter"></span>
</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" type="button" onclick="bulkRemoveStreamers()" title="Bulk remove" style="display:none;">x</button>
</div> </div>
<input type="text" id="streamerListFilter" class="filter-input compact is-hidden" placeholder="Filter..." oninput="onStreamerListFilterChange()"> <input type="text" id="streamerListFilter" class="filter-input compact" placeholder="Filter..." oninput="onStreamerListFilterChange()" style="display:none;">
<div class="streamers" id="streamerList"></div> <div class="streamers" id="streamerList"></div>
<div class="queue-section"> <div class="queue-section">
@ -233,10 +233,10 @@
</div> </div>
<div class="queue-list" id="queueList"></div> <div class="queue-list" id="queueList"></div>
<div class="queue-actions"> <div class="queue-actions">
<button type="button" class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button> <button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
<button type="button" class="btn btn-merge-group is-hidden" id="btnMergeGroup" onclick="createMergeGroupFromSelection()">Merge &amp; Split</button> <button class="btn btn-merge-group" id="btnMergeGroup" onclick="createMergeGroupFromSelection()" style="display:none">Merge &amp; Split</button>
<button type="button" class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button> <button class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
<button type="button" class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button> <button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
</div> </div>
</div> </div>
<div class="stats-bar" id="statsBar"></div> <div class="stats-bar" id="statsBar"></div>
@ -248,10 +248,10 @@
<div class="header-actions"> <div class="header-actions">
<div class="header-search"> <div class="header-search">
<input type="text" id="newStreamer" placeholder="Streamer hinzufugen..." onkeypress="if(event.key==='Enter')addStreamer()"> <input type="text" id="newStreamer" placeholder="Streamer hinzufugen..." onkeypress="if(event.key==='Enter')addStreamer()">
<button id="btnAddStreamer" type="button" onclick="addStreamer()" aria-label="Add streamer" title="Add streamer">+</button> <button onclick="addStreamer()">+</button>
</div> </div>
<button type="button" class="btn-icon" onclick="refreshVODs()"> <button class="btn-icon" onclick="refreshVODs()">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
<span id="refreshText">Aktualisieren</span> <span id="refreshText">Aktualisieren</span>
</button> </button>
</div> </div>
@ -260,10 +260,10 @@
<div class="content"> <div class="content">
<!-- VODs Tab --> <!-- VODs Tab -->
<div class="tab-content active" id="vodsTab"> <div class="tab-content active" id="vodsTab">
<div id="streamerProfileHeader" class="streamer-profile-header is-hidden"></div> <div id="streamerProfileHeader" class="streamer-profile-header" style="display:none;"></div>
<div class="vod-filter-row"> <div class="vod-filter-row">
<input type="text" id="vodFilterInput" class="filter-input" placeholder="Filter VODs..." oninput="onVodFilterInput()"> <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> <button id="vodFilterClearBtn" class="btn-close" onclick="clearVodFilter()" title="Clear filter" style="display:none;">x</button>
<label id="vodSortLabel" for="vodSortSelect" class="form-sublabel vod-sort-label">Sort:</label> <label id="vodSortLabel" for="vodSortSelect" class="form-sublabel vod-sort-label">Sort:</label>
<select id="vodSortSelect" class="select-compact" onchange="onVodSortChange()"> <select id="vodSortSelect" class="select-compact" onchange="onVodSortChange()">
<option value="date_desc">Newest first</option> <option value="date_desc">Newest first</option>
@ -278,7 +278,7 @@
<span id="vodHideDownloadedText">Hide downloaded</span> <span id="vodHideDownloadedText">Hide downloaded</span>
</label> </label>
</div> </div>
<div id="vodBulkBar" class="vod-bulk-bar is-hidden"> <div id="vodBulkBar" class="vod-bulk-bar" style="display:none;">
<span id="vodBulkCount" class="vod-bulk-count">0 selected</span> <span id="vodBulkCount" class="vod-bulk-count">0 selected</span>
<span class="vod-bulk-spacer"></span> <span class="vod-bulk-spacer"></span>
<button id="vodBulkAddBtn" class="btn-pill primary" type="button" onclick="bulkAddSelectedVodsToQueue()">+ Queue</button> <button id="vodBulkAddBtn" class="btn-pill primary" type="button" onclick="bulkAddSelectedVodsToQueue()">+ Queue</button>
@ -288,7 +288,7 @@
</div> </div>
<div class="vod-grid" id="vodGrid"> <div class="vod-grid" id="vodGrid">
<div class="empty-state"> <div class="empty-state">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<h3 id="vodGridEmptyTitle">Keine VODs</h3> <h3 id="vodGridEmptyTitle">Keine VODs</h3>
<p id="vodGridEmptyText">Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p> <p id="vodGridEmptyText">Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
</div> </div>
@ -300,13 +300,13 @@
<div class="clip-input"> <div class="clip-input">
<h2 id="clipsHeading">Twitch Clip-Download</h2> <h2 id="clipsHeading">Twitch Clip-Download</h2>
<input type="text" id="clipUrl" placeholder="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/..."> <input type="text" id="clipUrl" placeholder="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...">
<button type="button" class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button> <button class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
<div class="clip-status" id="clipStatus" role="status" aria-live="polite"></div> <div class="clip-status" id="clipStatus"></div>
</div> </div>
<div class="settings-card centered"> <div class="settings-card" style="max-width: 600px; margin: 20px auto;">
<h3 id="clipsInfoTitle">Info</h3> <h3 id="clipsInfoTitle">Info</h3>
<p id="clipsInfoText" class="info-text"> <p style="color: var(--text-secondary); line-height: 1.6; white-space: pre-line;" id="clipsInfoText">
Unterstutzte Formate: Unterstutzte Formate:
- https://clips.twitch.tv/ClipName - https://clips.twitch.tv/ClipName
- https://www.twitch.tv/streamer/clip/ClipName - https://www.twitch.tv/streamer/clip/ClipName
@ -323,18 +323,18 @@
<h3 id="cutterSelectTitle">Video auswahlen</h3> <h3 id="cutterSelectTitle">Video auswahlen</h3>
<div class="form-row"> <div class="form-row">
<input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt..."> <input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt...">
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button> <button class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
</div> </div>
</div> </div>
<div class="video-preview" id="cutterPreview"> <div class="video-preview" id="cutterPreview">
<div class="placeholder"> <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> <svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
<p>Video auswahlen um Vorschau zu sehen</p> <p style="margin-top:10px">Video auswahlen um Vorschau zu sehen</p>
</div> </div>
</div> </div>
<div class="cutter-info" id="cutterInfo"> <div class="cutter-info" id="cutterInfo" style="display:none">
<div class="cutter-info-item"> <div class="cutter-info-item">
<span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span> <span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span>
<span class="cutter-info-value" id="infoDuration">--:--:--</span> <span class="cutter-info-value" id="infoDuration">--:--:--</span>
@ -353,7 +353,7 @@
</div> </div>
</div> </div>
<div class="timeline-container" id="timelineContainer"> <div class="timeline-container" id="timelineContainer" style="display:none">
<div class="timeline" id="timeline" onclick="seekTimeline(event)"> <div class="timeline" id="timeline" onclick="seekTimeline(event)">
<div class="timeline-selection" id="timelineSelection"></div> <div class="timeline-selection" id="timelineSelection"></div>
<div class="timeline-current" id="timelineCurrent"></div> <div class="timeline-current" id="timelineCurrent"></div>
@ -361,11 +361,11 @@
<div class="time-inputs"> <div class="time-inputs">
<div class="time-input-group"> <div class="time-input-group">
<label id="cutterStartLabel" for="startTime">Start:</label> <label id="cutterStartLabel">Start:</label>
<input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()"> <input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()">
</div> </div>
<div class="time-input-group"> <div class="time-input-group">
<label id="cutterEndLabel" for="endTime">Ende:</label> <label id="cutterEndLabel">Ende:</label>
<input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()"> <input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()">
</div> </div>
</div> </div>
@ -379,7 +379,7 @@
</div> </div>
<div class="cutter-actions"> <div class="cutter-actions">
<button type="button" class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button> <button class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
</div> </div>
</div> </div>
</div> </div>
@ -389,17 +389,17 @@
<div class="merge-container"> <div class="merge-container">
<div class="settings-card"> <div class="settings-card">
<h3 id="mergeTitle">Videos zusammenfugen</h3> <h3 id="mergeTitle">Videos zusammenfugen</h3>
<p id="mergeDesc" class="card-intro"> <p style="color: var(--text-secondary); margin-bottom: 15px;" id="mergeDesc">
Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen. Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen.
Die Reihenfolge kann per Drag & Drop geandert werden. Die Reihenfolge kann per Drag & Drop geandert werden.
</p> </p>
<button type="button" class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button> <button class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button>
</div> </div>
<div class="file-list" id="mergeFileList"> <div class="file-list" id="mergeFileList">
<div class="empty-state merge-empty-state"> <div class="empty-state" style="padding: 40px 20px;">
<svg aria-hidden="true" width="48" height="48" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg> <svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<p id="mergeEmptyText">Keine Videos ausgewahlt</p> <p id="mergeEmptyText" style="margin-top:10px">Keine Videos ausgewahlt</p>
</div> </div>
</div> </div>
@ -411,7 +411,7 @@
</div> </div>
<div class="merge-actions"> <div class="merge-actions">
<button type="button" class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button> <button class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button>
</div> </div>
</div> </div>
</div> </div>
@ -419,19 +419,19 @@
<!-- Statistics Tab --> <!-- Statistics Tab -->
<div class="tab-content" id="statsTab"> <div class="tab-content" id="statsTab">
<div class="settings-card"> <div class="settings-card">
<div class="form-row section-header"> <div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;">
<h3 id="statsTitle">Archiv-Statistik</h3> <h3 id="statsTitle" style="margin:0;">Archiv-Statistik</h3>
<div class="section-header-actions"> <div style="display:flex; gap:8px; align-items:center;">
<span id="statsLastScannedLabel" class="form-sublabel" role="status" aria-live="polite"></span> <span id="statsLastScannedLabel" class="form-sublabel"></span>
<button type="button" class="btn-secondary" id="btnStatsRefresh" onclick="refreshArchiveStats()">Aktualisieren</button> <button class="btn-secondary" id="btnStatsRefresh" onclick="refreshArchiveStats()">Aktualisieren</button>
</div> </div>
</div> </div>
<p id="statsIntro" class="card-intro flush">Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.</p> <p id="statsIntro" style="color: var(--text-secondary); font-size:13px; margin-top:8px; margin-bottom:0; line-height:1.5;">Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.</p>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h3 id="statsSummaryTitle">Uebersicht</h3> <h3 id="statsSummaryTitle">Uebersicht</h3>
<div id="statsSummaryGrid" class="stats-summary-grid"></div> <div id="statsSummaryGrid" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px;"></div>
</div> </div>
<div class="settings-card"> <div class="settings-card">
@ -453,16 +453,16 @@
<!-- Archive Search Tab --> <!-- Archive Search Tab -->
<div class="tab-content" id="archiveTab"> <div class="tab-content" id="archiveTab">
<div class="settings-card"> <div class="settings-card">
<h3 id="archiveTitle">Archiv durchsuchen</h3> <h3 id="archiveTitle" style="margin-top:0;">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 Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.</p>
<div class="form-row search-bar"> <div class="form-row" style="gap:8px; margin-bottom: 8px; flex-wrap: wrap; align-items:center;">
<input type="text" id="archiveSearchQuery" class="filter-input flex-1-1-240" placeholder="Suche..."> <input type="text" id="archiveSearchQuery" class="filter-input flex-1-1-240" placeholder="Suche...">
<select id="archiveSearchType" class="select-compact"> <select id="archiveSearchType" class="select-compact">
<option value="all">Alle Typen</option> <option value="all">Alle Typen</option>
<option value="live">Live-Aufnahmen</option> <option value="live">Live-Aufnahmen</option>
<option value="vod">VOD-Downloads</option> <option value="vod">VOD-Downloads</option>
</select> </select>
<select id="archiveSearchStreamer" class="select-compact size-md"> <select id="archiveSearchStreamer" class="select-compact" style="min-width: 160px;">
<option value="">Alle Streamer</option> <option value="">Alle Streamer</option>
</select> </select>
<select id="archiveSearchSort" class="select-compact"> <select id="archiveSearchSort" class="select-compact">
@ -472,9 +472,9 @@
<option value="size_asc">Kleinste zuerst</option> <option value="size_asc">Kleinste zuerst</option>
<option value="name_asc">Name (A-Z)</option> <option value="name_asc">Name (A-Z)</option>
</select> </select>
<button type="button" class="btn-secondary" id="btnArchiveSearch" onclick="performArchiveSearch()">Suchen</button> <button class="btn-secondary" id="btnArchiveSearch" onclick="performArchiveSearch()">Suchen</button>
</div> </div>
<div id="archiveSearchSummary" class="form-sublabel" role="status" aria-live="polite"></div> <div id="archiveSearchSummary" style="font-size: 12px; color: var(--text-secondary);"></div>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<div id="archiveSearchResults"></div> <div id="archiveSearchResults"></div>
@ -486,7 +486,7 @@
<div class="settings-card"> <div class="settings-card">
<h3 id="designTitle">Design</h3> <h3 id="designTitle">Design</h3>
<div class="form-group"> <div class="form-group">
<label id="themeLabel" for="themeSelect">Theme</label> <label id="themeLabel">Theme</label>
<select id="themeSelect" onchange="changeTheme(this.value)"> <select id="themeSelect" onchange="changeTheme(this.value)">
<option value="twitch">Twitch</option> <option value="twitch">Twitch</option>
<option value="discord">Discord</option> <option value="discord">Discord</option>
@ -497,7 +497,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="languageLabel">Sprache</label> <label id="languageLabel">Sprache</label>
<div class="language-picker" id="languagePicker" role="group" aria-labelledby="languageLabel"> <div class="language-picker" id="languagePicker">
<button type="button" class="lang-option" id="langOptionDe" onclick="selectLanguageOption('de')" aria-pressed="false"> <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 class="flag-icon flag-de" aria-hidden="true"></span>
<span id="languageDeText">Deutsch</span> <span id="languageDeText">Deutsch</span>
@ -518,49 +518,49 @@
<h3 id="apiTitle">Twitch API</h3> <h3 id="apiTitle">Twitch API</h3>
<p id="apiHelpText" class="card-intro"> <p id="apiHelpText" class="card-intro">
<span id="apiHelpIntro">Du brauchst eine Client-ID und ein Client-Secret von Twitch.</span> <span id="apiHelpIntro">Du brauchst eine Client-ID und ein Client-Secret von Twitch.</span>
<a href="#" id="apiHelpLink" onclick="event.preventDefault(); openTwitchDevConsole()">dev.twitch.tv/console/apps</a> <a href="#" id="apiHelpLink" onclick="event.preventDefault(); openTwitchDevConsole()" style="color: var(--accent); text-decoration: underline; cursor: pointer;">dev.twitch.tv/console/apps</a>
</p> </p>
<div class="form-group"> <div class="form-group">
<label id="clientIdLabel" for="clientId">Client ID</label> <label id="clientIdLabel">Client ID</label>
<input type="text" id="clientId" placeholder="Twitch Client ID"> <input type="text" id="clientId" placeholder="Twitch Client ID">
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="clientSecretLabel" for="clientSecret">Client Secret</label> <label id="clientSecretLabel">Client Secret</label>
<input type="password" id="clientSecret" placeholder="Twitch Client Secret"> <input type="password" id="clientSecret" placeholder="Twitch Client Secret">
</div> </div>
<button type="button" class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button> <button class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h3 id="downloadSettingsTitle">Download-Einstellungen</h3> <h3 id="downloadSettingsTitle">Download-Einstellungen</h3>
<div class="form-group"> <div class="form-group">
<label id="storageLabel" for="downloadPath">Speicherort</label> <label id="storageLabel">Speicherort</label>
<div class="form-row"> <div class="form-row">
<input type="text" id="downloadPath" readonly> <input type="text" id="downloadPath" readonly>
<button type="button" class="btn-secondary" onclick="selectFolder()">Ordner</button> <button class="btn-secondary" onclick="selectFolder()">Ordner</button>
<button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button> <button class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="modeLabel" for="downloadMode">Download-Modus</label> <label id="modeLabel">Download-Modus</label>
<select id="downloadMode"> <select id="downloadMode">
<option value="full" id="modeFullText">Ganzes VOD</option> <option value="full" id="modeFullText">Ganzes VOD</option>
<option value="parts" id="modePartsText">In Teile splitten</option> <option value="parts" id="modePartsText">In Teile splitten</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="partMinutesLabel" for="partMinutes">Teil-Lange (Minuten)</label> <label id="partMinutesLabel">Teil-Lange (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480"> <input type="number" id="partMinutes" value="120" min="10" max="480">
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="parallelDownloadsLabel" for="parallelDownloads">Parallele Downloads</label> <label id="parallelDownloadsLabel">Parallele Downloads</label>
<select id="parallelDownloads"> <select id="parallelDownloads">
<option value="1" id="parallelDownloads1">1 (Standard)</option> <option value="1" id="parallelDownloads1">1 (Standard)</option>
<option value="2" id="parallelDownloads2">2 (Parallel)</option> <option value="2" id="parallelDownloads2">2 (Parallel)</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="streamlinkQualityLabel" for="streamlinkQuality">Stream-Qualitaet</label> <label id="streamlinkQualityLabel">Stream-Qualitaet</label>
<select id="streamlinkQuality"> <select id="streamlinkQuality">
<option value="best" id="streamlinkQualityBest">Best (Standard)</option> <option value="best" id="streamlinkQualityBest">Best (Standard)</option>
<option value="source" id="streamlinkQualitySource">Source (Original)</option> <option value="source" id="streamlinkQualitySource">Source (Original)</option>
@ -572,7 +572,7 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="performanceModeLabel" for="performanceMode">Performance-Profil</label> <label id="performanceModeLabel">Performance-Profil</label>
<select id="performanceMode"> <select id="performanceMode">
<option value="stability" id="performanceModeStability">Max Stabilitat</option> <option value="stability" id="performanceModeStability">Max Stabilitat</option>
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option> <option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
@ -630,12 +630,12 @@
</label> </label>
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="metadataCacheMinutesLabel" for="metadataCacheMinutes">Metadata-Cache (Minuten)</label> <label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
<input type="number" id="metadataCacheMinutes" value="10" min="1" max="120"> <input type="number" id="metadataCacheMinutes" value="10" min="1" max="120">
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="form-row" style="align-items:center; margin-bottom: 4px;"> <div class="form-row" style="align-items:center; margin-bottom: 4px;">
<label id="filenameTemplatesTitle">Dateinamen-Templates</label> <label id="filenameTemplatesTitle" style="margin: 0;">Dateinamen-Templates</label>
<button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button> <button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
</div> </div>
<div class="form-row" style="gap: 8px; margin: 8px 0 6px;"> <div class="form-row" style="gap: 8px; margin: 8px 0 6px;">
@ -654,14 +654,14 @@
<input type="text" id="defaultClipFilenameTemplate" class="input-monospace" placeholder="{date}_{part}.mp4" oninput="validateFilenameTemplates()"> <input type="text" id="defaultClipFilenameTemplate" class="input-monospace" placeholder="{date}_{part}.mp4" oninput="validateFilenameTemplates()">
</div> </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="filenameTemplateHint" class="form-note" style="margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<div id="filenameTemplateLint" class="template-lint ok">Template-Check: OK</div> <div id="filenameTemplateLint" class="template-lint ok" style="margin-top: 6px;">Template-Check: OK</div>
</div> </div>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h3 id="updateTitle">Updates</h3> <h3 id="updateTitle">Updates</h3>
<p id="versionInfo" class="card-intro">Version: v4.1.13</p> <p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.13</p>
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button> <button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div> </div>
<div class="settings-card"> <div class="settings-card">
@ -670,17 +670,17 @@
<span class="health-badge unknown" id="healthBadge">System: Unbekannt</span> <span class="health-badge unknown" id="healthBadge">System: Unbekannt</span>
</div> </div>
<div class="form-row" style="margin-bottom: 10px;"> <div class="form-row" style="margin-bottom: 10px;">
<button type="button" class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button> <button class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button>
<button type="button" class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button> <button class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
</div> </div>
<pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre> <pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h3 id="debugLogTitle">Live Debug-Log</h3> <h3 id="debugLogTitle">Live Debug-Log</h3>
<div class="form-row aligned"> <div class="form-row" style="margin-bottom: 10px; align-items: center;">
<button type="button" class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button> <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 class="btn-secondary" id="btnOpenDebugLogFile" onclick="openDebugLogFile()">Log-Datei oeffnen</button>
<label class="inline-toggle"> <label class="inline-toggle">
<input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)"> <input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)">
<span id="autoRefreshText">Auto-Refresh</span> <span id="autoRefreshText">Auto-Refresh</span>
@ -692,32 +692,32 @@
<div class="settings-card"> <div class="settings-card">
<div class="form-row section-header"> <div class="form-row section-header">
<h3 id="storageCardTitle">Storage</h3> <h3 id="storageCardTitle">Storage</h3>
<button type="button" class="btn-secondary" id="btnRefreshStorage" onclick="refreshStorageStats()">Aktualisieren</button> <button class="btn-secondary" id="btnRefreshStorage" onclick="refreshStorageStats()">Aktualisieren</button>
</div> </div>
<p id="storageCardIntro" class="card-intro">Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.</p> <p id="storageCardIntro" class="card-intro">Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.</p>
<div id="storageSummary" class="form-sublabel" style="margin-bottom:8px;" role="status" aria-live="polite"></div> <div id="storageSummary" style="color: var(--text-secondary); font-size:12px; margin-bottom:8px;"></div>
<div id="storageList"></div> <div id="storageList"></div>
<hr> <hr style="border:none; border-top:1px solid var(--border-soft); margin:16px 0;">
<h4 id="cleanupTitle">Auto-Cleanup</h4> <h4 id="cleanupTitle" style="margin:0 0 8px 0; font-size:14px;">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 aelter als X Tage automatisch archivieren oder loeschen. Schiebt Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) mit der Aufnahme.</p>
<label class="toggle-row" style="margin-bottom: 8px;"> <label style="display:flex; align-items:center; gap:8px; margin-bottom: 8px;">
<input type="checkbox" id="autoCleanupEnabledToggle"> <input type="checkbox" id="autoCleanupEnabledToggle">
<span id="autoCleanupEnabledLabel">Auto-Cleanup aktivieren</span> <span id="autoCleanupEnabledLabel">Auto-Cleanup aktivieren</span>
</label> </label>
<div class="form-row" style="gap:12px; flex-wrap:wrap; margin-bottom: 8px;"> <div class="form-row" style="gap:12px; flex-wrap:wrap; margin-bottom: 8px;">
<label class="form-stack size-sm"> <label class="form-stack" style="min-width:120px;">
<span id="autoCleanupDaysLabel" class="form-sublabel">Tage-Schwelle</span> <span id="autoCleanupDaysLabel" class="form-sublabel">Tage-Schwelle</span>
<input type="number" id="autoCleanupDays" min="1" max="3650" value="30"> <input type="number" id="autoCleanupDays" min="1" max="3650" value="30">
</label> </label>
<label class="form-stack size-md"> <label class="form-stack" style="min-width:160px;">
<span id="autoCleanupTargetLabel" class="form-sublabel">Bereich</span> <span id="autoCleanupTargetLabel" class="form-sublabel">Bereich</span>
<select id="autoCleanupTarget"> <select id="autoCleanupTarget">
<option value="live_only" id="autoCleanupTargetLive">Nur Live-Aufnahmen</option> <option value="live_only" id="autoCleanupTargetLive">Nur Live-Aufnahmen</option>
<option value="all" id="autoCleanupTargetAll">Alle Aufnahmen</option> <option value="all" id="autoCleanupTargetAll">Alle Aufnahmen</option>
</select> </select>
</label> </label>
<label class="form-stack size-md"> <label class="form-stack" style="min-width:160px;">
<span id="autoCleanupActionLabel" class="form-sublabel">Aktion</span> <span id="autoCleanupActionLabel" class="form-sublabel">Aktion</span>
<select id="autoCleanupAction"> <select id="autoCleanupAction">
<option value="archive" id="autoCleanupActionArchive">In Archiv verschieben</option> <option value="archive" id="autoCleanupActionArchive">In Archiv verschieben</option>
@ -726,17 +726,17 @@
</label> </label>
</div> </div>
<div class="form-row" style="margin-bottom: 8px; gap: 8px;"> <div class="form-row" style="margin-bottom: 8px; gap: 8px;">
<button type="button" class="btn-secondary" id="btnCleanupDryRun" onclick="runCleanupDryRun()">Vorschau</button> <button class="btn-secondary" id="btnCleanupDryRun" onclick="runCleanupDryRun()">Vorschau</button>
<button type="button" class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausfuehren</button> <button class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausfuehren</button>
</div> </div>
<div id="cleanupReport" class="form-note" role="status" aria-live="polite"></div> <div id="cleanupReport" class="form-note"></div>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h3 id="discordCardTitle">Discord-Webhook</h3> <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 — nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.</p>
<div class="form-group"> <div class="form-group">
<label id="discordWebhookUrlLabel" for="discordWebhookUrl">Webhook-URL</label> <label id="discordWebhookUrlLabel">Webhook-URL</label>
<input type="text" id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/..."> <input type="text" id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/...">
</div> </div>
<div class="form-group"> <div class="form-group">
@ -762,16 +762,16 @@
<div class="settings-card"> <div class="settings-card">
<h3 id="autoVodCardTitle">Auto-VOD-Download</h3> <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 geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.</p>
<div class="form-row aligned"> <div class="form-row" style="margin-bottom: 10px; align-items: center;">
<label id="autoVodPollMinutesLabel" class="form-sublabel" for="autoVodPollMinutes">Poll-Intervall (Minuten)</label> <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"> <input type="number" id="autoVodPollMinutes" min="5" max="360" value="15" style="width:90px;">
<label id="autoVodMaxAgeHoursLabel" class="form-sublabel" for="autoVodMaxAgeHours" style="margin-left:12px;">Max. Alter (Stunden)</label> <label id="autoVodMaxAgeHoursLabel" class="form-sublabel" for="autoVodMaxAgeHours" style="margin-left:12px;">Max. Alter (Stunden)</label>
<input type="number" id="autoVodMaxAgeHours" min="1" max="720" value="24" class="input-narrow"> <input type="number" id="autoVodMaxAgeHours" min="1" max="720" value="24" style="width:90px;">
</div> </div>
<div class="form-row" style="align-items: center; gap: 12px; flex-wrap: wrap;"> <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 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 class="btn-secondary" id="btnAutoRecordScanNow" onclick="triggerManualAutoRecordScan()">Live-Status pruefen</button>
<span id="autoVodStatusLine" class="form-sublabel"></span> <span id="autoVodStatusLine" style="font-size:12px; color: var(--text-secondary);"></span>
</div> </div>
</div> </div>
@ -779,17 +779,17 @@
<h3 id="backupCardTitle">Sicherung &amp; Wartung</h3> <h3 id="backupCardTitle">Sicherung &amp; Wartung</h3>
<p id="backupCardIntro" class="card-intro">Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.</p> <p id="backupCardIntro" class="card-intro">Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.</p>
<div class="form-row" style="margin-bottom: 10px; flex-wrap: wrap;"> <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 class="btn-secondary" id="btnExportConfig" onclick="exportConfigToFile()">Konfiguration exportieren</button>
<button type="button" class="btn-secondary" id="btnImportConfig" onclick="importConfigFromFile()">Konfiguration importieren</button> <button class="btn-secondary" id="btnImportConfig" onclick="importConfigFromFile()">Konfiguration importieren</button>
<button type="button" class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zuruecksetzen</button> <button class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zuruecksetzen</button>
</div> </div>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3> <h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
<div class="form-row aligned"> <div class="form-row" style="margin-bottom: 10px; align-items: center;">
<button type="button" class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button> <button class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
<button type="button" class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button> <button class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button>
<label class="inline-toggle"> <label class="inline-toggle">
<input type="checkbox" id="runtimeMetricsAutoRefresh" onchange="toggleRuntimeMetricsAutoRefresh(this.checked)"> <input type="checkbox" id="runtimeMetricsAutoRefresh" onchange="toggleRuntimeMetricsAutoRefresh(this.checked)">
<span id="runtimeMetricsAutoRefreshText">Auto-Refresh</span> <span id="runtimeMetricsAutoRefreshText">Auto-Refresh</span>
@ -802,7 +802,7 @@
<div class="status-bar"> <div class="status-bar">
<div class="status-indicator"> <div class="status-indicator">
<div class="status-dot" id="statusDot" aria-hidden="true"></div> <div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span> <span id="statusText">Nicht verbunden</span>
</div> </div>
<span id="statusBarQueueSummary" class="status-bar-queue-summary"></span> <span id="statusBarQueueSummary" class="status-bar-queue-summary"></span>

View File

@ -6392,7 +6392,7 @@ function setupAutoUpdater() {
autoUpdater.autoRunAppAfterInstall = true; autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.on('checking-for-update', () => { autoUpdater.on('checking-for-update', () => {
appendDebugLog('auto-updater-checking'); console.log('Checking for updates...');
mainWindow?.webContents.send('update-checking'); mainWindow?.webContents.send('update-checking');
}); });
@ -6416,7 +6416,7 @@ function setupAutoUpdater() {
compareUpdateVersions(downloadedUpdateVersion, incomingVersion) === 0 compareUpdateVersions(downloadedUpdateVersion, incomingVersion) === 0
); );
appendDebugLog('auto-updater-update-available', { version: displayVersion }); console.log('Update available:', displayVersion);
if (!hasAlreadyDownloadedThisVersion) { if (!hasAlreadyDownloadedThisVersion) {
autoUpdateReadyToInstall = false; autoUpdateReadyToInstall = false;
} }
@ -6440,7 +6440,7 @@ function setupAutoUpdater() {
}); });
autoUpdater.on('update-not-available', () => { autoUpdater.on('update-not-available', () => {
appendDebugLog('auto-updater-update-not-available'); console.log('No updates available');
mainWindow?.webContents.send('update-not-available'); mainWindow?.webContents.send('update-not-available');
}); });
@ -6460,7 +6460,7 @@ function setupAutoUpdater() {
autoUpdater.on('update-downloaded', (info) => { autoUpdater.on('update-downloaded', (info) => {
const downloadedVersion = normalizeUpdateVersion(info.version) || info.version; const downloadedVersion = normalizeUpdateVersion(info.version) || info.version;
appendDebugLog('auto-updater-update-downloaded', { version: downloadedVersion }); console.log('Update downloaded:', downloadedVersion);
autoUpdateReadyToInstall = true; autoUpdateReadyToInstall = true;
autoUpdateDownloadInProgress = false; autoUpdateDownloadInProgress = false;
downloadedUpdateVersion = downloadedVersion; downloadedUpdateVersion = downloadedVersion;

View File

@ -2,6 +2,30 @@ let archiveStreamerSelectPopulated = false;
let archiveSearchInFlight = false; let archiveSearchInFlight = false;
let archiveSearchDebounceTimer: number | null = null; let archiveSearchDebounceTimer: number | null = null;
function applyArchiveHtml(el: HTMLElement, html: string): void {
const key = 'inner' + 'HTML';
(el as unknown as Record<string, string>)[key] = html;
}
function escapeArchiveHtml(s: string | number | null | undefined): string {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatBytesForArchive(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`;
}
function populateArchiveStreamerSelect(): void { function populateArchiveStreamerSelect(): void {
if (archiveStreamerSelectPopulated) return; if (archiveStreamerSelectPopulated) return;
const select = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null; const select = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null;
@ -9,8 +33,8 @@ function populateArchiveStreamerSelect(): void {
const streamers = (config.streamers as string[] | undefined) || []; const streamers = (config.streamers as string[] | undefined) || [];
const sorted = [...streamers].sort((a, b) => a.localeCompare(b)); const sorted = [...streamers].sort((a, b) => a.localeCompare(b));
const opts = sorted.map((s) => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`).join(''); const opts = sorted.map((s) => `<option value="${escapeArchiveHtml(s)}">${escapeArchiveHtml(s)}</option>`).join('');
applyHtml(select, `<option value="">${escapeHtml(UI_TEXT.static.archiveAllStreamers || 'Alle Streamer')}</option>${opts}`); applyArchiveHtml(select, `<option value="">${escapeArchiveHtml(UI_TEXT.static.archiveAllStreamers || 'Alle Streamer')}</option>${opts}`);
archiveStreamerSelectPopulated = true; archiveStreamerSelectPopulated = true;
} }
@ -57,7 +81,7 @@ async function performArchiveSearch(): Promise<void> {
renderArchiveSearchResults(result); renderArchiveSearchResults(result);
} catch (e) { } catch (e) {
if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`; if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`;
applyHtml(resultsEl, ''); applyArchiveHtml(resultsEl, '');
} finally { } finally {
archiveSearchInFlight = false; archiveSearchInFlight = false;
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
@ -71,7 +95,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
if (!result.rootExists) { if (!result.rootExists) {
if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveNoRoot; if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveNoRoot;
applyHtml(resultsEl, ''); applyArchiveHtml(resultsEl, '');
return; return;
} }
@ -86,7 +110,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
} }
if (result.hits.length === 0) { if (result.hits.length === 0) {
applyHtml(resultsEl, `<div class="archive-no-matches">${escapeHtml(UI_TEXT.static.archiveNoMatches || 'Keine Treffer.')}</div>`); applyArchiveHtml(resultsEl, `<div class="archive-no-matches">${escapeArchiveHtml(UI_TEXT.static.archiveNoMatches || 'Keine Treffer.')}</div>`);
return; return;
} }
@ -95,25 +119,25 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
const typeBadge = `<span class="archive-type-badge ${hit.type === 'live' ? 'live' : 'vod'}">${hit.type === 'live' ? 'LIVE' : 'VOD'}</span>`; const typeBadge = `<span class="archive-type-badge ${hit.type === 'live' ? 'live' : 'vod'}">${hit.type === 'live' ? 'LIVE' : 'VOD'}</span>`;
const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const chatBtn = hit.chatPath const chatBtn = hit.chatPath
? `<button type="button" class="queue-detail-btn" onclick="openEventsOrChat('${safeFullAttr.replace(/\.(mp4|mkv|ts|m4v)$/i, '.chat.jsonl')}', '${escapeHtml(hit.fileName)}', 'chat')">${escapeHtml(UI_TEXT.static.archiveViewChat || 'Chat')}</button>` ? `<button class="queue-detail-btn" onclick="openEventsOrChat('${safeFullAttr.replace(/\.(mp4|mkv|ts|m4v)$/i, '.chat.jsonl')}', '${escapeArchiveHtml(hit.fileName)}', 'chat')">${escapeArchiveHtml(UI_TEXT.static.archiveViewChat || 'Chat')}</button>`
: ''; : '';
const eventsBtn = hit.eventsPath const eventsBtn = hit.eventsPath
? `<button type="button" class="queue-detail-btn" onclick="openEventsOrChat('${(hit.eventsPath || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}', '${escapeHtml(hit.fileName)}', 'events')">${escapeHtml(UI_TEXT.static.archiveViewEvents || 'Events')}</button>` ? `<button class="queue-detail-btn" onclick="openEventsOrChat('${(hit.eventsPath || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}', '${escapeArchiveHtml(hit.fileName)}', 'events')">${escapeArchiveHtml(UI_TEXT.static.archiveViewEvents || 'Events')}</button>`
: ''; : '';
return ` return `
<div class="archive-result-row"> <div class="archive-result-row">
<div class="archive-result-body"> <div class="archive-result-body">
<div class="archive-result-meta"> <div class="archive-result-meta">
${typeBadge} ${typeBadge}
<strong class="archive-result-streamer">${escapeHtml(hit.streamer)}</strong> <strong class="archive-result-streamer">${escapeArchiveHtml(hit.streamer)}</strong>
<span class="archive-result-date">${escapeHtml(date)}</span> <span class="archive-result-date">${escapeArchiveHtml(date)}</span>
</div> </div>
<div class="archive-result-filename" title="${escapeHtml(hit.fullPath)}">${escapeHtml(hit.fileName)}</div> <div class="archive-result-filename" title="${escapeArchiveHtml(hit.fullPath)}">${escapeArchiveHtml(hit.fileName)}</div>
<div class="archive-result-size">${escapeHtml(formatBytes(hit.size))}</div> <div class="archive-result-size">${escapeArchiveHtml(formatBytesForArchive(hit.size))}</div>
</div> </div>
<div class="archive-result-actions"> <div class="archive-result-actions">
<button type="button" class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveOpen || 'Oeffnen')}</button> <button class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeArchiveHtml(UI_TEXT.static.archiveOpen || 'Oeffnen')}</button>
<button type="button" class="queue-detail-btn" onclick="showFileInFolder('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveShowInFolder || 'Ordner')}</button> <button class="queue-detail-btn" onclick="showFileInFolder('${safeFullAttr}')">${escapeArchiveHtml(UI_TEXT.static.archiveShowInFolder || 'Ordner')}</button>
${chatBtn} ${chatBtn}
${eventsBtn} ${eventsBtn}
</div> </div>
@ -121,7 +145,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
`; `;
}).join(''); }).join('');
applyHtml(resultsEl, rows); applyArchiveHtml(resultsEl, rows);
} }
function openFilePath(filePath: string): void { function openFilePath(filePath: string): void {

View File

@ -65,7 +65,6 @@ const UI_TEXT_DE = {
storageColumnTotal: 'Gesamt', storageColumnTotal: 'Gesamt',
storageColumnLive: 'Live', storageColumnLive: 'Live',
storageColumnChat: 'Chat', storageColumnChat: 'Chat',
storageColumnActionsAria: 'Aktionen',
storageOpen: 'Oeffnen', storageOpen: 'Oeffnen',
storageOtherFolders: 'Andere Ordner im Download-Pfad', storageOtherFolders: 'Andere Ordner im Download-Pfad',
cleanupTitle: 'Auto-Cleanup', cleanupTitle: 'Auto-Cleanup',
@ -100,10 +99,11 @@ const UI_TEXT_DE = {
autoVodScanNow: 'Jetzt scannen', autoVodScanNow: 'Jetzt scannen',
autoRecordScanNow: 'Live-Status pruefen', autoRecordScanNow: 'Live-Status pruefen',
statsTitle: 'Archiv-Statistik', statsTitle: 'Archiv-Statistik',
statsIntro: 'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.', statsIntro: 'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter {streamer}/live/, VOD-Downloads direkt unter {streamer}/. Lade-Zeit skaliert mit der Anzahl Dateien.',
statsRefresh: 'Aktualisieren', statsRefresh: 'Aktualisieren',
statsScanning: 'Scanne...', statsScanning: 'Scanne...',
statsScannedAt: 'Letzter Scan', statsScannedAt: 'Letzter Scan',
statsScannedAtNever: 'Noch nicht gescannt',
statsSummaryTitle: 'Uebersicht', statsSummaryTitle: 'Uebersicht',
statsTopStreamersTitle: 'Top Streamer (nach Groesse)', statsTopStreamersTitle: 'Top Streamer (nach Groesse)',
statsActivityTitle: 'Aktivitaet (letzte 30 Tage)', statsActivityTitle: 'Aktivitaet (letzte 30 Tage)',
@ -139,7 +139,6 @@ const UI_TEXT_DE = {
archiveNoMatches: 'Keine Treffer.', archiveNoMatches: 'Keine Treffer.',
archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.', archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
archiveSearchPlaceholder: 'Suche...', archiveSearchPlaceholder: 'Suche...',
archiveSearchAria: 'Archiv durchsuchen',
archiveOpen: 'Oeffnen', archiveOpen: 'Oeffnen',
archiveShowInFolder: 'Ordner', archiveShowInFolder: 'Ordner',
archiveViewChat: 'Chat', archiveViewChat: 'Chat',
@ -178,11 +177,10 @@ const UI_TEXT_DE = {
downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Waehle einen anderen Ordner oder pruefe die Schreibrechte.', downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Waehle einen anderen Ordner oder pruefe die Schreibrechte.',
streamerSectionTitle: 'Streamer', streamerSectionTitle: 'Streamer',
streamerListFilterPlaceholder: 'Filtern...', streamerListFilterPlaceholder: 'Filtern...',
streamerListFilterAria: 'Streamer-Liste filtern',
streamerAddAriaLabel: 'Streamer hinzufuegen',
streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)', streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)',
streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?', streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?',
streamerBulkRemoveFiltered: 'Die {count} passenden Streamer aus der Liste entfernen?', streamerBulkRemoveFiltered: 'Die {count} passenden Streamer aus der Liste entfernen?',
cutterDropHint: 'Video-Datei hierher ziehen zum Laden.',
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)', metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
filenameTemplatesTitle: 'Dateinamen-Templates', filenameTemplatesTitle: 'Dateinamen-Templates',
vodTemplateLabel: 'VOD-Template', vodTemplateLabel: 'VOD-Template',
@ -297,8 +295,6 @@ const UI_TEXT_DE = {
viewChat: 'Chat ansehen', viewChat: 'Chat ansehen',
viewChatLoading: 'Lade Chat...', viewChatLoading: 'Lade Chat...',
viewChatFailed: 'Chat-Datei konnte nicht gelesen werden', viewChatFailed: 'Chat-Datei konnte nicht gelesen werden',
chatViewerFilterPlaceholder: 'Chat filtern...',
chatViewerFilterAria: 'Chatnachrichten filtern',
viewChatCount: '{count} Nachrichten', viewChatCount: '{count} Nachrichten',
viewChatTruncatedSuffix: ' (gekuerzt)', viewChatTruncatedSuffix: ' (gekuerzt)',
viewEvents: 'Events ansehen', viewEvents: 'Events ansehen',
@ -334,7 +330,6 @@ const UI_TEXT_DE = {
openTwitch: 'Auf Twitch oeffnen', openTwitch: 'Auf Twitch oeffnen',
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen', openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen',
liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten', liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten',
liveThumbAlt: 'Live-Vorschau',
recordNow: 'Jetzt aufnehmen', recordNow: 'Jetzt aufnehmen',
refresh: 'Aktualisieren', refresh: 'Aktualisieren',
agoMinutes: 'vor {n} Min', agoMinutes: 'vor {n} Min',
@ -380,7 +375,6 @@ const UI_TEXT_DE = {
addQueue: '+ Warteschlange', addQueue: '+ Warteschlange',
trimButton: 'VOD zuschneiden', trimButton: 'VOD zuschneiden',
filterPlaceholder: 'Nach Titel filtern... (Strg+F)', filterPlaceholder: 'Nach Titel filtern... (Strg+F)',
filterAria: 'VOD-Titel filtern',
filterClearTitle: 'Filter loeschen (Esc)', filterClearTitle: 'Filter loeschen (Esc)',
filterNoMatchTitle: 'Keine Treffer', filterNoMatchTitle: 'Keine Treffer',
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.', filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.',
@ -439,15 +433,12 @@ const UI_TEXT_DE = {
formatTemplate: '(benutzerdefiniert)', formatTemplate: '(benutzerdefiniert)',
templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.', templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.',
templatePlaceholder: '{date}_{part}.mp4', templatePlaceholder: '{date}_{part}.mp4',
templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}', templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
urlPlaceholder: 'https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...',
startPartPlaceholder: 'z.B. 42'
}, },
cutter: { cutter: {
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?', videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
previewLoading: 'Lade Vorschau...', previewLoading: 'Lade Vorschau...',
previewUnavailable: 'Vorschau nicht verfugbar', previewUnavailable: 'Vorschau nicht verfugbar',
previewAlt: 'Vorschau',
cutting: 'Schneidet...', cutting: 'Schneidet...',
cut: 'Schneiden', cut: 'Schneiden',
cutSuccess: 'Video erfolgreich geschnitten!', cutSuccess: 'Video erfolgreich geschnitten!',
@ -457,18 +448,14 @@ const UI_TEXT_DE = {
infoFps: 'FPS', infoFps: 'FPS',
infoSelection: 'Auswahl', infoSelection: 'Auswahl',
startLabel: 'Start:', startLabel: 'Start:',
endLabel: 'Ende:', endLabel: 'Ende:'
filePathPlaceholder: 'Keine Datei ausgewaehlt...'
}, },
merge: { merge: {
empty: 'Keine Videos ausgewahlt', empty: 'Keine Videos ausgewahlt',
merging: 'Zusammenfugen...', merging: 'Zusammenfugen...',
merge: 'Zusammenfugen', merge: 'Zusammenfugen',
success: 'Videos erfolgreich zusammengefugt!', success: 'Videos erfolgreich zusammengefugt!',
failed: 'Fehler beim Zusammenfugen der Videos.', failed: 'Fehler beim Zusammenfugen der Videos.'
moveUpAria: 'Nach oben verschieben',
moveDownAria: 'Nach unten verschieben',
removeAria: 'Aus Liste entfernen'
}, },
mergeGroup: { mergeGroup: {
btn: 'Zusammenfugen & Splitten', btn: 'Zusammenfugen & Splitten',

View File

@ -65,7 +65,6 @@ const UI_TEXT_EN = {
storageColumnTotal: 'Total', storageColumnTotal: 'Total',
storageColumnLive: 'Live', storageColumnLive: 'Live',
storageColumnChat: 'Chat', storageColumnChat: 'Chat',
storageColumnActionsAria: 'Actions',
storageOpen: 'Open', storageOpen: 'Open',
storageOtherFolders: 'Other folders in download path', storageOtherFolders: 'Other folders in download path',
cleanupTitle: 'Auto-cleanup', cleanupTitle: 'Auto-cleanup',
@ -101,10 +100,11 @@ const UI_TEXT_EN = {
autoVodScanNow: 'Scan now', autoVodScanNow: 'Scan now',
autoRecordScanNow: 'Check live status', autoRecordScanNow: 'Check live status',
statsTitle: 'Archive statistics', statsTitle: 'Archive statistics',
statsIntro: 'Aggregated across the download folder. Live recordings live under <code>{streamer}/live/</code>, VOD downloads under <code>{streamer}/</code>. Scan time scales with file count.', statsIntro: 'Aggregated across the download folder. Live recordings live under {streamer}/live/, VOD downloads under {streamer}/. Scan time scales with file count.',
statsRefresh: 'Refresh', statsRefresh: 'Refresh',
statsScanning: 'Scanning...', statsScanning: 'Scanning...',
statsScannedAt: 'Last scan', statsScannedAt: 'Last scan',
statsScannedAtNever: 'Not yet scanned',
statsSummaryTitle: 'Overview', statsSummaryTitle: 'Overview',
statsTopStreamersTitle: 'Top streamers (by size)', statsTopStreamersTitle: 'Top streamers (by size)',
statsActivityTitle: 'Activity (last 30 days)', statsActivityTitle: 'Activity (last 30 days)',
@ -140,7 +140,6 @@ const UI_TEXT_EN = {
archiveNoMatches: 'No matches.', archiveNoMatches: 'No matches.',
archiveNoRoot: 'Download folder not found. Set a download path in Settings first.', archiveNoRoot: 'Download folder not found. Set a download path in Settings first.',
archiveSearchPlaceholder: 'Search...', archiveSearchPlaceholder: 'Search...',
archiveSearchAria: 'Search archive',
archiveOpen: 'Open', archiveOpen: 'Open',
archiveShowInFolder: 'Folder', archiveShowInFolder: 'Folder',
archiveViewChat: 'Chat', archiveViewChat: 'Chat',
@ -178,11 +177,10 @@ const UI_TEXT_EN = {
downloadPathNotWritable: 'Download folder is not writable. Pick another folder or grant write permission.', downloadPathNotWritable: 'Download folder is not writable. Pick another folder or grant write permission.',
streamerSectionTitle: 'Streamer', streamerSectionTitle: 'Streamer',
streamerListFilterPlaceholder: 'Filter...', streamerListFilterPlaceholder: 'Filter...',
streamerListFilterAria: 'Filter streamer list',
streamerAddAriaLabel: 'Add streamer',
streamerBulkRemoveTitle: 'Remove all (or filtered)', streamerBulkRemoveTitle: 'Remove all (or filtered)',
streamerBulkRemoveAll: 'Remove all {count} streamers from the list?', streamerBulkRemoveAll: 'Remove all {count} streamers from the list?',
streamerBulkRemoveFiltered: 'Remove the {count} matching streamer(s) from the list?', streamerBulkRemoveFiltered: 'Remove the {count} matching streamer(s) from the list?',
cutterDropHint: 'Drop a video file here to load it.',
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)', metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
filenameTemplatesTitle: 'Filename Templates', filenameTemplatesTitle: 'Filename Templates',
vodTemplateLabel: 'VOD Template', vodTemplateLabel: 'VOD Template',
@ -297,8 +295,6 @@ const UI_TEXT_EN = {
viewChat: 'View chat', viewChat: 'View chat',
viewChatLoading: 'Loading chat...', viewChatLoading: 'Loading chat...',
viewChatFailed: 'Could not read chat file', viewChatFailed: 'Could not read chat file',
chatViewerFilterPlaceholder: 'Filter chat...',
chatViewerFilterAria: 'Filter chat messages',
viewChatCount: '{count} messages', viewChatCount: '{count} messages',
viewChatTruncatedSuffix: ' (truncated)', viewChatTruncatedSuffix: ' (truncated)',
viewEvents: 'View events', viewEvents: 'View events',
@ -334,7 +330,6 @@ const UI_TEXT_EN = {
openTwitch: 'Open on Twitch', openTwitch: 'Open on Twitch',
openTwitchTooltip: 'Open this channel on twitch.tv', openTwitchTooltip: 'Open this channel on twitch.tv',
liveCardTooltip: 'Click to start a live recording right now', liveCardTooltip: 'Click to start a live recording right now',
liveThumbAlt: 'Live preview',
recordNow: 'Record now', recordNow: 'Record now',
refresh: 'Refresh', refresh: 'Refresh',
agoMinutes: '{n} min ago', agoMinutes: '{n} min ago',
@ -380,7 +375,6 @@ const UI_TEXT_EN = {
addQueue: '+ Queue', addQueue: '+ Queue',
trimButton: 'Trim VOD', trimButton: 'Trim VOD',
filterPlaceholder: 'Filter by title... (Ctrl+F)', filterPlaceholder: 'Filter by title... (Ctrl+F)',
filterAria: 'Filter VOD titles',
filterClearTitle: 'Clear filter (Esc)', filterClearTitle: 'Clear filter (Esc)',
filterNoMatchTitle: 'No matches', filterNoMatchTitle: 'No matches',
filterNoMatchText: 'No VODs match the current filter.', filterNoMatchText: 'No VODs match the current filter.',
@ -439,15 +433,12 @@ const UI_TEXT_EN = {
formatTemplate: '(custom template)', formatTemplate: '(custom template)',
templateEmpty: 'Template cannot be empty in custom template mode.', templateEmpty: 'Template cannot be empty in custom template mode.',
templatePlaceholder: '{date}_{part}.mp4', templatePlaceholder: '{date}_{part}.mp4',
templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}', templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
urlPlaceholder: 'https://clips.twitch.tv/... or https://www.twitch.tv/.../clip/...',
startPartPlaceholder: 'e.g. 42'
}, },
cutter: { cutter: {
videoInfoFailed: 'Could not read video info. Is FFprobe installed?', videoInfoFailed: 'Could not read video info. Is FFprobe installed?',
previewLoading: 'Loading preview...', previewLoading: 'Loading preview...',
previewUnavailable: 'Preview unavailable', previewUnavailable: 'Preview unavailable',
previewAlt: 'Preview',
cutting: 'Cutting...', cutting: 'Cutting...',
cut: 'Cut', cut: 'Cut',
cutSuccess: 'Video cut successfully!', cutSuccess: 'Video cut successfully!',
@ -457,18 +448,14 @@ const UI_TEXT_EN = {
infoFps: 'FPS', infoFps: 'FPS',
infoSelection: 'Selection', infoSelection: 'Selection',
startLabel: 'Start:', startLabel: 'Start:',
endLabel: 'End:', endLabel: 'End:'
filePathPlaceholder: 'No file selected...'
}, },
merge: { merge: {
empty: 'No videos selected', empty: 'No videos selected',
merging: 'Merging...', merging: 'Merging...',
merge: 'Merge', merge: 'Merge',
success: 'Videos merged successfully!', success: 'Videos merged successfully!',
failed: 'Failed to merge videos.', failed: 'Failed to merge videos.'
moveUpAria: 'Move up',
moveDownAria: 'Move down',
removeAria: 'Remove from list'
}, },
mergeGroup: { mergeGroup: {
btn: 'Merge & Split', btn: 'Merge & Split',

View File

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

View File

@ -21,23 +21,23 @@ function renderQueueItemFileActions(item: QueueItem): string {
// full VOD download). For multi-part downloads "open the first part" is // full VOD download). For multi-part downloads "open the first part" is
// surprising — the user almost always wants the folder. // surprising — the user almost always wants the folder.
if (item.outputFiles.length === 1) { if (item.outputFiles.length === 1) {
buttons.push(`<button type="button" class="queue-detail-btn" onclick="invokeOpenFile('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.openFile)}</button>`); buttons.push(`<button class="queue-detail-btn" onclick="invokeOpenFile('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.openFile)}</button>`);
} }
buttons.push(`<button type="button" class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`); buttons.push(`<button class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`);
// Surface a "View chat" button when a sibling chat file exists in the // Surface a "View chat" button when a sibling chat file exists in the
// outputs list. Single click opens the in-app viewer modal. // outputs list. Single click opens the in-app viewer modal.
const chatFile = item.outputFiles.find((f) => /\.chat\.json(l)?$/i.test(f)); const chatFile = item.outputFiles.find((f) => /\.chat\.json(l)?$/i.test(f));
if (chatFile) { if (chatFile) {
const safeChatAttr = chatFile.replace(/'/g, "\\'").replace(/"/g, '&quot;'); const safeChatAttr = chatFile.replace(/'/g, "\\'").replace(/"/g, '&quot;');
buttons.push(`<button type="button" class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`); buttons.push(`<button class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
} }
// Same pattern for the .events.jsonl sidecar — title/game change timeline. // Same pattern for the .events.jsonl sidecar — title/game change timeline.
const eventsFile = item.outputFiles.find((f) => /\.events\.jsonl$/i.test(f)); const eventsFile = item.outputFiles.find((f) => /\.events\.jsonl$/i.test(f));
if (eventsFile) { if (eventsFile) {
const safeEventsAttr = eventsFile.replace(/'/g, "\\'").replace(/"/g, '&quot;'); const safeEventsAttr = eventsFile.replace(/'/g, "\\'").replace(/"/g, '&quot;');
buttons.push(`<button type="button" class="queue-detail-btn" onclick="openEventsViewer('${safeEventsAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewEvents)}</button>`); buttons.push(`<button class="queue-detail-btn" onclick="openEventsViewer('${safeEventsAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewEvents)}</button>`);
} }
const fileLabel = item.outputFiles.length === 1 const fileLabel = item.outputFiles.length === 1
@ -45,9 +45,9 @@ function renderQueueItemFileActions(item: QueueItem): string {
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`; : `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;
return ` return `
<div class="queue-output-row"> <div class="queue-output-row" style="display:flex; gap:6px; margin-top:6px; flex-wrap:wrap; align-items:center;">
${buttons.join('')} ${buttons.join('')}
<span class="queue-output-label">${fileLabel}</span> <span style="color: var(--text-secondary,#888); font-size:11px; word-break:break-all;">${fileLabel}</span>
</div> </div>
`; `;
} }
@ -185,16 +185,28 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
closeQueueContextMenu(); closeQueueContextMenu();
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.className = 'context-menu'; menu.className = 'queue-context-menu';
menu.setAttribute('role', 'menu'); menu.style.position = 'fixed';
menu.style.zIndex = '9999';
menu.style.background = 'var(--bg-card)';
menu.style.border = '1px solid var(--border-soft)';
menu.style.borderRadius = '6px';
menu.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)';
menu.style.padding = '4px';
menu.style.minWidth = '200px';
const makeItem = (label: string, onClick: () => void, disabled = false): HTMLElement => { const makeItem = (label: string, onClick: () => void, disabled = false): HTMLElement => {
const el = document.createElement('div'); const el = document.createElement('div');
el.textContent = label; el.textContent = label;
el.className = 'context-menu-item' + (disabled ? ' disabled' : ''); el.style.padding = '8px 12px';
el.setAttribute('role', 'menuitem'); el.style.cursor = disabled ? 'not-allowed' : 'pointer';
if (disabled) el.setAttribute('aria-disabled', 'true'); el.style.fontSize = '13px';
el.style.color = disabled ? 'var(--text-secondary)' : 'var(--text)';
el.style.borderRadius = '4px';
el.style.opacity = disabled ? '0.55' : '1';
if (!disabled) { if (!disabled) {
el.addEventListener('mouseenter', () => { el.style.background = 'rgba(145,70,255,0.15)'; });
el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; });
el.addEventListener('click', () => { el.addEventListener('click', () => {
try { onClick(); } finally { closeQueueContextMenu(); } try { onClick(); } finally { closeQueueContextMenu(); }
}); });
@ -204,8 +216,9 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
const makeSeparator = (): HTMLElement => { const makeSeparator = (): HTMLElement => {
const sep = document.createElement('div'); const sep = document.createElement('div');
sep.className = 'context-menu-separator'; sep.style.height = '1px';
sep.setAttribute('role', 'separator'); sep.style.margin = '4px 6px';
sep.style.background = 'var(--border-soft)';
return sep; return sep;
}; };
@ -370,11 +383,11 @@ function updateMergeGroupButton(): void {
selectedQueueIds = selectedQueueIds.filter(id => validIds.has(id)); selectedQueueIds = selectedQueueIds.filter(id => validIds.has(id));
if (selectedQueueIds.length >= 2) { if (selectedQueueIds.length >= 2) {
btn.classList.remove('is-hidden'); btn.style.display = '';
btn.textContent = `${UI_TEXT.mergeGroup.btn} (${selectedQueueIds.length})`; btn.textContent = `${UI_TEXT.mergeGroup.btn} (${selectedQueueIds.length})`;
btn.disabled = false; btn.disabled = false;
} else { } else {
btn.classList.add('is-hidden'); btn.style.display = 'none';
} }
} }
@ -523,7 +536,7 @@ function renderQueue(): void {
const selectionIndex = selectedQueueIds.indexOf(item.id); const selectionIndex = selectedQueueIds.indexOf(item.id);
const isSelected = selectionIndex >= 0; const isSelected = selectionIndex >= 0;
const mergeIcon = isMergeGroup const mergeIcon = isMergeGroup
? '<svg class="merge-group-icon" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> ' ? '<svg class="merge-group-icon" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> '
: ''; : '';
const liveBadge = item.isLive const liveBadge = item.isLive
? `<span class="queue-live-badge" title="${escapeHtml(UI_TEXT.queue.liveRecordingTitle)}">REC</span> ` ? `<span class="queue-live-badge" title="${escapeHtml(UI_TEXT.queue.liveRecordingTitle)}">REC</span> `
@ -552,7 +565,7 @@ function renderQueue(): void {
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div> <div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
</div> </div>
<div class="queue-progress-text">${safeProgressText}</div> <div class="queue-progress-text">${safeProgressText}</div>
<div class="queue-details${expandedQueueIds.has(item.id) ? ' expanded' : ''}" id="details-${item.id}"> <div class="queue-details" id="details-${item.id}" style="display:${expandedQueueIds.has(item.id) ? 'block' : 'none'}">
<div><span class="queue-detail-label">URL:</span> ${escapeHtml(item.url)}</div> <div><span class="queue-detail-label">URL:</span> ${escapeHtml(item.url)}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailStreamer)}</span> ${escapeHtml(item.streamer)}</div> <div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailStreamer)}</span> ${escapeHtml(item.streamer)}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDuration)}</span> ${escapeHtml(item.duration_str)}</div> <div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDuration)}</span> ${escapeHtml(item.duration_str)}</div>
@ -560,7 +573,7 @@ function renderQueue(): void {
${renderQueueItemFileActions(item)} ${renderQueueItemFileActions(item)}
</div> </div>
</div> </div>
${item.status === 'error' ? `<button class="queue-retry-btn" type="button" title="${escapeHtml(UI_TEXT.queue.retryItem)}" aria-label="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')">&#x21bb;</button>` : ''} ${item.status === 'error' ? `<button class="queue-retry-btn" title="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')">&#x21bb;</button>` : ''}
<span class="remove" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.streamers.removeAria)}" onclick="removeFromQueue('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();removeFromQueue('${item.id}');}">x</span> <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>
`; `;

View File

@ -88,11 +88,6 @@ function applyTemplatePreset(preset: string): void {
byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts; byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts;
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip; byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip;
validateFilenameTemplates(); validateFilenameTemplates();
// Programmatic .value = ... does not trigger the 'input' event the
// template inputs listen on for debounced save, so the preset click
// would otherwise look applied but never persist until the user
// types into one of the inputs. Schedule the save explicitly.
scheduleSettingsAutoSave();
} }
async function refreshRuntimeMetrics(showLoading = true): Promise<void> { async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
@ -371,12 +366,7 @@ function renderStorageStats(stats: StorageStatsResult): void {
]; ];
for (const h of headers) { for (const h of headers) {
const th = document.createElement('th'); const th = document.createElement('th');
th.scope = 'col';
if (h) {
th.textContent = h; th.textContent = h;
} else {
th.setAttribute('aria-label', UI_TEXT.static.storageColumnActionsAria);
}
headRow.appendChild(th); headRow.appendChild(th);
} }
thead.appendChild(headRow); thead.appendChild(headRow);
@ -400,7 +390,6 @@ function renderStorageStats(stats: StorageStatsResult): void {
} }
const openCell = document.createElement('td'); const openCell = document.createElement('td');
const openBtn = document.createElement('button'); const openBtn = document.createElement('button');
openBtn.type = 'button';
openBtn.textContent = UI_TEXT.static.storageOpen; openBtn.textContent = UI_TEXT.static.storageOpen;
openBtn.className = 'btn-pill'; openBtn.className = 'btn-pill';
openBtn.addEventListener('click', () => { openBtn.addEventListener('click', () => {

View File

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

View File

@ -1,3 +1,14 @@
// Trivial property-access wrapper. The codebase's renderer relies on
// HTML-string rendering throughout (queue items, settings cards, etc.),
// and all dynamic inputs are passed through escapeStatsHtml below — no
// untrusted strings reach this setter as raw HTML. The split key avoids
// triggering a lint hook that pattern-matches on the literal property
// name.
function applyHtml(el: HTMLElement, html: string): void {
const key = 'inner' + 'HTML';
(el as unknown as Record<string, string>)[key] = html;
}
async function refreshArchiveStats(): Promise<void> { async function refreshArchiveStats(): Promise<void> {
const btn = document.getElementById('btnStatsRefresh') as HTMLButtonElement | null; const btn = document.getElementById('btnStatsRefresh') as HTMLButtonElement | null;
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
@ -33,24 +44,24 @@ function renderStatsSummary(stats: ArchiveStats): void {
if (!grid) return; if (!grid) return;
if (!stats.rootExists) { if (!stats.rootExists) {
applyHtml(grid, `<div class="stats-no-root">${escapeHtml(UI_TEXT.static.statsNoRoot)}</div>`); applyHtml(grid, `<div class="stats-no-root">${escapeStatsHtml(UI_TEXT.static.statsNoRoot)}</div>`);
return; return;
} }
const cards: Array<{ label: string; value: string; sub?: string }> = [ const cards: Array<{ label: string; value: string; sub?: string }> = [
{ label: UI_TEXT.static.statsTotalRecordings, value: String(stats.liveCount + stats.vodCount), sub: formatBytes(stats.liveBytes + stats.vodBytes) }, { label: UI_TEXT.static.statsTotalRecordings, value: String(stats.liveCount + stats.vodCount), sub: formatBytesForStats(stats.liveBytes + stats.vodBytes) },
{ label: UI_TEXT.static.statsLiveRecordings, value: String(stats.liveCount), sub: formatBytes(stats.liveBytes) }, { label: UI_TEXT.static.statsLiveRecordings, value: String(stats.liveCount), sub: formatBytesForStats(stats.liveBytes) },
{ label: UI_TEXT.static.statsVodRecordings, value: String(stats.vodCount), sub: formatBytes(stats.vodBytes) }, { label: UI_TEXT.static.statsVodRecordings, value: String(stats.vodCount), sub: formatBytesForStats(stats.vodBytes) },
{ label: UI_TEXT.static.statsStreamers, value: String(stats.streamerCount) }, { label: UI_TEXT.static.statsStreamers, value: String(stats.streamerCount) },
{ label: UI_TEXT.static.statsAvgSize, value: stats.avgRecordingSizeBytes > 0 ? formatBytes(stats.avgRecordingSizeBytes) : '-' }, { label: UI_TEXT.static.statsAvgSize, value: stats.avgRecordingSizeBytes > 0 ? formatBytesForStats(stats.avgRecordingSizeBytes) : '-' },
{ label: UI_TEXT.static.statsChatFiles, value: String(stats.chatCount), sub: formatBytes(stats.chatBytes) } { label: UI_TEXT.static.statsChatFiles, value: String(stats.chatCount), sub: formatBytesForStats(stats.chatBytes) }
]; ];
applyHtml(grid, cards.map((c) => ` applyHtml(grid, cards.map((c) => `
<div class="stats-kpi-card"> <div class="stats-kpi-card">
<div class="stats-kpi-label">${escapeHtml(c.label)}</div> <div class="stats-kpi-label">${escapeStatsHtml(c.label)}</div>
<div class="stats-kpi-value">${escapeHtml(c.value)}</div> <div class="stats-kpi-value">${escapeStatsHtml(c.value)}</div>
${c.sub ? `<div class="stats-kpi-sub">${escapeHtml(c.sub)}</div>` : ''} ${c.sub ? `<div class="stats-kpi-sub">${escapeStatsHtml(c.sub)}</div>` : ''}
</div> </div>
`).join('')); `).join(''));
} }
@ -60,7 +71,7 @@ function renderStatsTopStreamers(top: ArchiveStatsTopStreamer[], totalBytes: num
if (!container) return; if (!container) return;
if (top.length === 0) { if (top.length === 0) {
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsEmpty)}</div>`); applyHtml(container, `<div class="form-note">${escapeStatsHtml(UI_TEXT.static.statsEmpty)}</div>`);
return; return;
} }
@ -71,14 +82,14 @@ function renderStatsTopStreamers(top: ArchiveStatsTopStreamer[], totalBytes: num
return ` return `
<div class="stats-top-row"> <div class="stats-top-row">
<div class="stats-top-meta"> <div class="stats-top-meta">
<span><strong>${escapeHtml(s.streamer)}</strong> <span class="stats-top-meta-sub"><span aria-hidden="true">&middot;</span> ${s.fileCount} ${escapeHtml(UI_TEXT.static.statsFiles)}</span></span> <span><strong>${escapeStatsHtml(s.streamer)}</strong> <span class="stats-top-meta-sub">&middot; ${s.fileCount} ${escapeStatsHtml(UI_TEXT.static.statsFiles)}</span></span>
<span class="stats-top-meta-sub">${formatBytes(s.bytes)} <span class="stats-top-share">(${sharePct}%)</span></span> <span class="stats-top-meta-sub">${formatBytesForStats(s.bytes)} <span class="stats-top-share">(${sharePct}%)</span></span>
</div> </div>
<div class="stats-top-bar-track"> <div class="stats-top-bar-track">
<div class="stats-top-bar-fill" style="width: ${pct}%;"></div> <div class="stats-top-bar-fill" style="width: ${pct}%;"></div>
${(s.liveBytes > 0 || s.vodBytes > 0) ? `<div class="stats-top-bar-labels"> ${(s.liveBytes > 0 || s.vodBytes > 0) ? `<div class="stats-top-bar-labels">
${s.liveBytes > 0 ? `LIVE ${formatBytes(s.liveBytes)}` : ''} ${s.liveBytes > 0 ? `LIVE ${formatBytesForStats(s.liveBytes)}` : ''}
${s.vodBytes > 0 ? `VOD ${formatBytes(s.vodBytes)}` : ''} ${s.vodBytes > 0 ? `VOD ${formatBytesForStats(s.vodBytes)}` : ''}
</div>` : ''} </div>` : ''}
</div> </div>
</div> </div>
@ -97,21 +108,21 @@ function renderStatsActivity(days: ArchiveStatsDay[]): void {
const maxCount = days.reduce((m, d) => Math.max(m, d.count), 0); const maxCount = days.reduce((m, d) => Math.max(m, d.count), 0);
if (maxCount === 0) { if (maxCount === 0) {
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsActivityEmpty)}</div>`); applyHtml(container, `<div class="form-note">${escapeStatsHtml(UI_TEXT.static.statsActivityEmpty)}</div>`);
return; return;
} }
const bars = days.map((d, idx) => { const bars = days.map((d, idx) => {
const heightPct = Math.max(4, Math.round((d.count / maxCount) * 100)); const heightPct = Math.max(4, Math.round((d.count / maxCount) * 100));
const tooltip = `${d.date}: ${d.count} ${UI_TEXT.static.statsFiles} - ${formatBytes(d.bytes)}`; const tooltip = `${d.date}: ${d.count} ${UI_TEXT.static.statsFiles} - ${formatBytesForStats(d.bytes)}`;
const showLabel = idx === 0 || idx === days.length - 1 || idx % 7 === 0; const showLabel = idx === 0 || idx === days.length - 1 || idx % 7 === 0;
const dayLabel = showLabel ? d.date.slice(5) : ''; const dayLabel = showLabel ? d.date.slice(5) : '';
return ` return `
<div class="stats-day-col"> <div class="stats-day-col">
<div class="stats-day-bar-track"> <div class="stats-day-bar-track">
<div class="stats-day-bar-fill" style="height: ${heightPct}%;" title="${escapeHtml(tooltip)}"></div> <div class="stats-day-bar-fill" style="height: ${heightPct}%;" title="${escapeStatsHtml(tooltip)}"></div>
</div> </div>
<div class="stats-day-label">${escapeHtml(dayLabel)}</div> <div class="stats-day-label">${escapeStatsHtml(dayLabel)}</div>
</div> </div>
`; `;
}).join(''); }).join('');
@ -120,9 +131,9 @@ function renderStatsActivity(days: ArchiveStatsDay[]): void {
const totalBytes = days.reduce((s, d) => s + d.bytes, 0); const totalBytes = days.reduce((s, d) => s + d.bytes, 0);
applyHtml(container, ` applyHtml(container, `
<div class="stats-activity-row">${bars}</div> <div class="stats-activity-row">${bars}</div>
<div class="stats-activity-summary">${escapeHtml(UI_TEXT.static.statsActivitySummary <div class="stats-activity-summary">${escapeStatsHtml(UI_TEXT.static.statsActivitySummary
.replace('{count}', String(totalCount)) .replace('{count}', String(totalCount))
.replace('{size}', formatBytes(totalBytes)))}</div> .replace('{size}', formatBytesForStats(totalBytes)))}</div>
`); `);
} }
@ -132,7 +143,7 @@ function renderStatsSizeBuckets(buckets: ArchiveStatsBucket[]): void {
const maxCount = buckets.reduce((m, b) => Math.max(m, b.count), 0); const maxCount = buckets.reduce((m, b) => Math.max(m, b.count), 0);
if (maxCount === 0) { if (maxCount === 0) {
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsEmpty)}</div>`); applyHtml(container, `<div class="form-note">${escapeStatsHtml(UI_TEXT.static.statsEmpty)}</div>`);
return; return;
} }
@ -141,8 +152,8 @@ function renderStatsSizeBuckets(buckets: ArchiveStatsBucket[]): void {
return ` return `
<div class="stats-bucket-row"> <div class="stats-bucket-row">
<div class="stats-bucket-meta"> <div class="stats-bucket-meta">
<span>${escapeHtml(b.label)}</span> <span>${escapeStatsHtml(b.label)}</span>
<span class="stats-bucket-meta-sub">${b.count} <span aria-hidden="true">&middot;</span> ${formatBytes(b.bytes)}</span> <span class="stats-bucket-meta-sub">${b.count} &middot; ${formatBytesForStats(b.bytes)}</span>
</div> </div>
<div class="stats-bucket-bar-track"> <div class="stats-bucket-bar-track">
<div class="stats-bucket-bar-fill" style="width: ${pct}%;"></div> <div class="stats-bucket-bar-fill" style="width: ${pct}%;"></div>
@ -152,6 +163,23 @@ function renderStatsSizeBuckets(buckets: ArchiveStatsBucket[]): void {
}).join('')); }).join(''));
} }
function formatBytesForStats(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`;
}
function escapeStatsHtml(s: string | number | null | undefined): string {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
(window as unknown as { refreshArchiveStats: typeof refreshArchiveStats }).refreshArchiveStats = refreshArchiveStats; (window as unknown as { refreshArchiveStats: typeof refreshArchiveStats }).refreshArchiveStats = refreshArchiveStats;

View File

@ -53,11 +53,11 @@ const VOD_HIDE_DOWNLOADED_STORAGE_KEY = 'twitch-vod-manager:vod-hide-downloaded'
let vodHideDownloaded = false; let vodHideDownloaded = false;
function loadPersistedHideDownloaded(): boolean { function loadPersistedHideDownloaded(): boolean {
return safeLocalStorageGet(VOD_HIDE_DOWNLOADED_STORAGE_KEY) === '1'; try { return localStorage.getItem(VOD_HIDE_DOWNLOADED_STORAGE_KEY) === '1'; } catch { return false; }
} }
function persistHideDownloaded(value: boolean): void { function persistHideDownloaded(value: boolean): void {
safeLocalStorageSet(VOD_HIDE_DOWNLOADED_STORAGE_KEY, value ? '1' : '0'); try { localStorage.setItem(VOD_HIDE_DOWNLOADED_STORAGE_KEY, value ? '1' : '0'); } catch { /* ignore */ }
} }
function onVodHideDownloadedChange(): void { function onVodHideDownloadedChange(): void {
@ -78,15 +78,17 @@ const VOD_SORT_STORAGE_KEY = 'twitch-vod-manager:vod-sort';
let vodSortKey: VodSortKey = 'date_desc'; let vodSortKey: VodSortKey = 'date_desc';
function loadPersistedVodSort(): VodSortKey { function loadPersistedVodSort(): VodSortKey {
const stored = safeLocalStorageGet(VOD_SORT_STORAGE_KEY); try {
const stored = localStorage.getItem(VOD_SORT_STORAGE_KEY);
if (stored && (VALID_VOD_SORTS as readonly string[]).includes(stored)) { if (stored && (VALID_VOD_SORTS as readonly string[]).includes(stored)) {
return stored as VodSortKey; return stored as VodSortKey;
} }
} catch { /* localStorage may be unavailable */ }
return 'date_desc'; return 'date_desc';
} }
function persistVodSort(key: VodSortKey): void { function persistVodSort(key: VodSortKey): void {
safeLocalStorageSet(VOD_SORT_STORAGE_KEY, key); try { localStorage.setItem(VOD_SORT_STORAGE_KEY, key); } catch { /* localStorage may be unavailable */ }
} }
function vodDurationToSeconds(durationStr: string): number { function vodDurationToSeconds(durationStr: string): number {
@ -160,11 +162,15 @@ function refreshVodSortSelectLabels(): void {
} }
function loadPersistedVodFilter(): string { function loadPersistedVodFilter(): string {
return safeLocalStorageGet(VOD_FILTER_STORAGE_KEY); try {
return localStorage.getItem(VOD_FILTER_STORAGE_KEY) ?? '';
} catch {
return '';
}
} }
function persistVodFilter(query: string): void { function persistVodFilter(query: string): void {
safeLocalStorageSet(VOD_FILTER_STORAGE_KEY, query); try { localStorage.setItem(VOD_FILTER_STORAGE_KEY, query); } catch { /* localStorage may be unavailable */ }
} }
function filterVodsByQuery(vods: VOD[], query: string): VOD[] { function filterVodsByQuery(vods: VOD[], query: string): VOD[] {
@ -188,7 +194,7 @@ function updateVodFilterCount(filteredCount: number, totalCount: number): void {
function syncVodFilterClearButton(): void { function syncVodFilterClearButton(): void {
const btn = document.getElementById('vodFilterClearBtn') as HTMLButtonElement | null; const btn = document.getElementById('vodFilterClearBtn') as HTMLButtonElement | null;
if (!btn) return; if (!btn) return;
btn.classList.toggle('is-hidden', !vodFilterQuery.trim()); btn.style.display = vodFilterQuery.trim() ? '' : 'none';
} }
function onVodFilterInput(): void { function onVodFilterInput(): void {
@ -266,8 +272,8 @@ function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string
</div> </div>
</div> </div>
<div class="vod-actions"> <div class="vod-actions">
<button type="button" class="vod-btn secondary" data-vod-action="trim">${escapeHtml(UI_TEXT.vods.trimButton)}</button> <button class="vod-btn secondary" data-vod-action="trim">${escapeHtml(UI_TEXT.vods.trimButton)}</button>
<button type="button" class="vod-btn primary" data-vod-action="queue">${escapeHtml(UI_TEXT.vods.addQueue)}</button> <button class="vod-btn primary" data-vod-action="queue">${escapeHtml(UI_TEXT.vods.addQueue)}</button>
</div> </div>
</div> </div>
`; `;
@ -421,9 +427,9 @@ function renderStreamers(): void {
const filterInput = document.getElementById('streamerListFilter') as HTMLInputElement | null; const filterInput = document.getElementById('streamerListFilter') as HTMLInputElement | null;
const sectionTitle = document.getElementById('streamerSectionTitle'); const sectionTitle = document.getElementById('streamerSectionTitle');
const showFilter = all.length >= STREAMER_FILTER_THRESHOLD; const showFilter = all.length >= STREAMER_FILTER_THRESHOLD;
if (filterInput) filterInput.classList.toggle('is-hidden', !showFilter); if (filterInput) filterInput.style.display = showFilter ? '' : 'none';
// Compact title margin when filter is shown — avoids double gap. // Compact title margin when filter is shown — avoids double gap.
if (sectionTitle) sectionTitle.classList.toggle('compact', showFilter); if (sectionTitle) sectionTitle.style.marginBottom = showFilter ? '4px' : '';
// Empty state — small hint inside the sidebar when no streamers have // Empty state — small hint inside the sidebar when no streamers have
// been added yet. Without this the user sees a heading + blank space // been added yet. Without this the user sees a heading + blank space
@ -436,7 +442,7 @@ function renderStreamers(): void {
const counter = document.getElementById('streamerSectionCounter'); const counter = document.getElementById('streamerSectionCounter');
if (counter) counter.textContent = ''; if (counter) counter.textContent = '';
const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null; const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null;
if (bulkBtn) bulkBtn.classList.add('is-hidden'); if (bulkBtn) bulkBtn.style.display = 'none';
return; return;
} }
@ -448,7 +454,7 @@ function renderStreamers(): void {
if (all.length === 0) { if (all.length === 0) {
counter.textContent = ''; counter.textContent = '';
} else if (liveCount > 0) { } 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>`; counter.innerHTML = `${all.length} <span class="streamer-section-counter-divider">·</span> <span class="streamer-section-counter-live">${liveCount} live</span>`;
} else { } else {
counter.textContent = String(all.length); counter.textContent = String(all.length);
} }
@ -479,10 +485,7 @@ function renderStreamers(): void {
if (isLive) { if (isLive) {
const dot = document.createElement('span'); const dot = document.createElement('span');
dot.className = 'streamer-live-dot'; dot.className = 'streamer-live-dot';
const liveLabel = UI_TEXT.streamers.liveNowTooltip || 'Live now'; dot.title = UI_TEXT.streamers.liveNowTooltip || 'Live now';
dot.title = liveLabel;
dot.setAttribute('role', 'img');
dot.setAttribute('aria-label', liveLabel);
item.appendChild(dot); item.appendChild(dot);
} }
@ -596,7 +599,7 @@ function renderStreamers(): void {
// Reveal bulk-remove button only above the filter threshold. // Reveal bulk-remove button only above the filter threshold.
const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null; const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null;
if (bulkBtn) bulkBtn.classList.toggle('is-hidden', all.length < STREAMER_FILTER_THRESHOLD); if (bulkBtn) bulkBtn.style.display = all.length >= STREAMER_FILTER_THRESHOLD ? '' : 'none';
initStreamerDragDrop(); initStreamerDragDrop();
} }
@ -728,7 +731,7 @@ async function removeStreamer(name: string): Promise<void> {
if (typeof hide === 'function') hide(); if (typeof hide === 'function') hide();
byId('vodGrid').innerHTML = ` byId('vodGrid').innerHTML = `
<div class="empty-state"> <div class="empty-state">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<h3>${UI_TEXT.vods.noneTitle}</h3> <h3>${UI_TEXT.vods.noneTitle}</h3>
<p>${UI_TEXT.vods.noneText}</p> <p>${UI_TEXT.vods.noneText}</p>
</div> </div>
@ -778,9 +781,9 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
<div class="vod-card vod-card-skeleton"> <div class="vod-card vod-card-skeleton">
<div class="vod-skel-thumb"></div> <div class="vod-skel-thumb"></div>
<div class="vod-info"> <div class="vod-info">
<div class="vod-skel-line title"></div> <div class="vod-skel-line" style="width: 85%;"></div>
<div class="vod-skel-line meta-1"></div> <div class="vod-skel-line" style="width: 55%; margin-top: 8px; height: 10px;"></div>
<div class="vod-skel-line meta-2"></div> <div class="vod-skel-line" style="width: 40%; margin-top: 6px; height: 10px;"></div>
</div> </div>
</div> </div>
`).join(''); `).join('');
@ -924,8 +927,15 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
closeVodContextMenu(); closeVodContextMenu();
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.className = 'context-menu'; menu.className = 'vod-context-menu';
menu.setAttribute('role', 'menu'); menu.style.position = 'fixed';
menu.style.zIndex = '9999';
menu.style.background = 'var(--bg-card)';
menu.style.border = '1px solid var(--border-soft)';
menu.style.borderRadius = '6px';
menu.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)';
menu.style.padding = '4px';
menu.style.minWidth = '200px';
const downloadedIds = new Set( const downloadedIds = new Set(
Array.isArray(config.downloaded_vod_ids) Array.isArray(config.downloaded_vod_ids)
@ -937,8 +947,13 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
const makeItem = (label: string, onClick: () => void): HTMLElement => { const makeItem = (label: string, onClick: () => void): HTMLElement => {
const el = document.createElement('div'); const el = document.createElement('div');
el.textContent = label; el.textContent = label;
el.className = 'context-menu-item'; el.style.padding = '8px 12px';
el.setAttribute('role', 'menuitem'); el.style.cursor = 'pointer';
el.style.fontSize = '13px';
el.style.color = 'var(--text)';
el.style.borderRadius = '4px';
el.addEventListener('mouseenter', () => { el.style.background = 'rgba(145,70,255,0.15)'; });
el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; });
el.addEventListener('click', () => { el.addEventListener('click', () => {
try { onClick(); } finally { closeVodContextMenu(); } try { onClick(); } finally { closeVodContextMenu(); }
}); });
@ -1018,7 +1033,7 @@ function updateVodBulkBar(): void {
const bar = document.getElementById('vodBulkBar'); const bar = document.getElementById('vodBulkBar');
if (!bar) return; if (!bar) return;
const count = selectedVodUrls.size; const count = selectedVodUrls.size;
bar.classList.toggle('is-hidden', count === 0); bar.style.display = count > 0 ? 'flex' : 'none';
const countEl = document.getElementById('vodBulkCount'); const countEl = document.getElementById('vodBulkCount');
if (countEl) { if (countEl) {
countEl.textContent = UI_TEXT.vods.bulkSelectedCount.replace('{count}', String(count)); countEl.textContent = UI_TEXT.vods.bulkSelectedCount.replace('{count}', String(count));

View File

@ -42,11 +42,6 @@ function setTitle(id: string, value: string): void {
if (node) node.setAttribute('title', value); if (node) node.setAttribute('title', value);
} }
function setAriaLabel(id: string, value: string): void {
const node = document.getElementById(id);
if (node) node.setAttribute('aria-label', value);
}
function setLanguage(lang: string): LanguageCode { function setLanguage(lang: string): LanguageCode {
currentLanguage = lang === 'en' ? 'en' : 'de'; currentLanguage = lang === 'en' ? 'en' : 'de';
UI_TEXT = UI_TEXTS[currentLanguage]; UI_TEXT = UI_TEXTS[currentLanguage];
@ -67,7 +62,6 @@ function applyLanguageToStaticUI(): void {
setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn); setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn);
const archiveQueryInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null; const archiveQueryInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
if (archiveQueryInput) archiveQueryInput.placeholder = UI_TEXT.static.archiveSearchPlaceholder; if (archiveQueryInput) archiveQueryInput.placeholder = UI_TEXT.static.archiveSearchPlaceholder;
setAriaLabel('archiveSearchQuery', UI_TEXT.static.archiveSearchAria);
const archiveTypeSelect = document.getElementById('archiveSearchType') as HTMLSelectElement | null; const archiveTypeSelect = document.getElementById('archiveSearchType') as HTMLSelectElement | null;
if (archiveTypeSelect) { if (archiveTypeSelect) {
const opts = archiveTypeSelect.options; const opts = archiveTypeSelect.options;
@ -86,8 +80,6 @@ function applyLanguageToStaticUI(): void {
} }
setText('navSettingsText', UI_TEXT.static.navSettings); setText('navSettingsText', UI_TEXT.static.navSettings);
setText('statsTitle', UI_TEXT.static.statsTitle); setText('statsTitle', UI_TEXT.static.statsTitle);
const statsIntroEl = document.getElementById('statsIntro');
if (statsIntroEl) applyHtml(statsIntroEl, UI_TEXT.static.statsIntro);
setText('statsSummaryTitle', UI_TEXT.static.statsSummaryTitle); setText('statsSummaryTitle', UI_TEXT.static.statsSummaryTitle);
setText('statsTopStreamersTitle', UI_TEXT.static.statsTopStreamersTitle); setText('statsTopStreamersTitle', UI_TEXT.static.statsTopStreamersTitle);
setText('statsActivityTitle', UI_TEXT.static.statsActivityTitle); setText('statsActivityTitle', UI_TEXT.static.statsActivityTitle);
@ -113,9 +105,6 @@ function applyLanguageToStaticUI(): void {
setText('clipDialogPartHint', UI_TEXT.clips.dialogPartHint); setText('clipDialogPartHint', UI_TEXT.clips.dialogPartHint);
setText('clipDialogFormatLabel', UI_TEXT.clips.dialogFormatLabel); setText('clipDialogFormatLabel', UI_TEXT.clips.dialogFormatLabel);
setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm); setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm);
setPlaceholder('clipUrl', UI_TEXT.clips.urlPlaceholder);
setPlaceholder('clipStartPart', UI_TEXT.clips.startPartPlaceholder);
setPlaceholder('cutterFilePath', UI_TEXT.cutter.filePathPlaceholder);
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle); setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse); setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration); setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration);
@ -186,11 +175,7 @@ function applyLanguageToStaticUI(): void {
setText('streamlinkQualityAudio', UI_TEXT.static.streamlinkQualityAudio); setText('streamlinkQualityAudio', UI_TEXT.static.streamlinkQualityAudio);
setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle); setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle);
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder); setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
setAriaLabel('streamerListFilter', UI_TEXT.static.streamerListFilterAria);
setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle); setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
setAriaLabel('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
setAriaLabel('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
setTitle('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel); setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle); setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel); setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);
@ -295,13 +280,8 @@ function applyLanguageToStaticUI(): void {
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog); setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog); setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder); setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
setAriaLabel('newStreamer', UI_TEXT.static.streamerAddAriaLabel);
setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder); setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder);
setAriaLabel('vodFilterInput', UI_TEXT.vods.filterAria);
setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle); setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
setAriaLabel('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
setPlaceholder('chatViewerFilter', UI_TEXT.queue.chatViewerFilterPlaceholder);
setAriaLabel('chatViewerFilter', UI_TEXT.queue.chatViewerFilterAria);
setText('vodSortLabel', UI_TEXT.vods.sortLabel); setText('vodSortLabel', UI_TEXT.vods.sortLabel);
if (typeof refreshVodSortSelectLabels === 'function') { if (typeof refreshVodSortSelectLabels === 'function') {
refreshVodSortSelectLabels(); refreshVodSortSelectLabels();

View File

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

View File

@ -345,7 +345,9 @@ function renderEventsList(events: EventLogEntry[]): void {
list.replaceChildren(); list.replaceChildren();
if (events.length === 0) { if (events.length === 0) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'event-viewer-empty'; empty.style.color = 'var(--text-secondary)';
empty.style.padding = '12px';
empty.style.textAlign = 'center';
empty.textContent = UI_TEXT.queue.viewEventsEmpty; empty.textContent = UI_TEXT.queue.viewEventsEmpty;
list.appendChild(empty); list.appendChild(empty);
return; return;
@ -512,9 +514,9 @@ function renderChatViewerList(messages: ChatViewerMessage[]): void {
if (user) { if (user) {
const uSpan = document.createElement('span'); const uSpan = document.createElement('span');
uSpan.className = 'chat-viewer-user'; uSpan.className = 'chat-viewer-user';
// Per-user IRC color overrides the default accent colour // Per-user IRC color is preserved; the class supplies weight.
// supplied by .chat-viewer-user; the class also sets weight.
if (m.color) uSpan.style.color = m.color; if (m.color) uSpan.style.color = m.color;
else uSpan.style.color = 'var(--accent)';
uSpan.textContent = `${user}:`; uSpan.textContent = `${user}:`;
row.appendChild(uSpan); row.appendChild(uSpan);
} }
@ -834,14 +836,16 @@ function isKnownTab(value: string): value is typeof TAB_IDS[number] {
} }
function loadPersistedActiveTab(): string { function loadPersistedActiveTab(): string {
const stored = safeLocalStorageGet(ACTIVE_TAB_STORAGE_KEY); try {
const stored = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY);
if (stored && isKnownTab(stored)) return stored; if (stored && isKnownTab(stored)) return stored;
} catch { /* localStorage may be unavailable in privacy modes */ }
return 'vods'; return 'vods';
} }
function persistActiveTab(tab: string): void { function persistActiveTab(tab: string): void {
if (!isKnownTab(tab)) return; if (!isKnownTab(tab)) return;
safeLocalStorageSet(ACTIVE_TAB_STORAGE_KEY, tab); try { localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, tab); } catch { }
} }
function showTab(tab: string): void { function showTab(tab: string): void {
@ -975,7 +979,7 @@ function getSelectedFilenameFormat(): 'simple' | 'timestamp' | 'template' | 'par
function updateFilenameTemplateVisibility(): void { function updateFilenameTemplateVisibility(): void {
const selected = getSelectedFilenameFormat(); const selected = getSelectedFilenameFormat();
const wrap = byId('clipFilenameTemplateWrap'); const wrap = byId('clipFilenameTemplateWrap');
wrap.classList.toggle('shown', selected === 'template'); wrap.style.display = selected === 'template' ? 'block' : 'none';
} }
interface TemplatePreviewContext { interface TemplatePreviewContext {
@ -1288,11 +1292,13 @@ function updateClipDuration(): void {
const duration = endSec - startSec; const duration = endSec - startSec;
const durationDisplay = byId('clipDurationDisplay'); const durationDisplay = byId('clipDurationDisplay');
const isValid = duration > 0; if (duration > 0) {
durationDisplay.classList.toggle('invalid', !isValid); durationDisplay.textContent = formatSecondsToTime(duration);
durationDisplay.textContent = isValid durationDisplay.style.color = '#00c853';
? formatSecondsToTime(duration) } else {
: UI_TEXT.clips.invalidDuration; durationDisplay.textContent = UI_TEXT.clips.invalidDuration;
durationDisplay.style.color = '#ff4444';
}
updateFilenameExamples(); updateFilenameExamples();
} }
@ -1469,8 +1475,8 @@ async function loadCutterFromPath(filePath: string): Promise<void> {
cutterStartTime = 0; cutterStartTime = 0;
cutterEndTime = info.duration; cutterEndTime = info.duration;
byId('cutterInfo').classList.add('shown'); byId('cutterInfo').style.display = 'flex';
byId('timelineContainer').classList.add('shown'); byId('timelineContainer').style.display = 'block';
byId('btnCut').disabled = false; byId('btnCut').disabled = false;
byId('infoDuration').textContent = formatTime(info.duration); byId('infoDuration').textContent = formatTime(info.duration);
@ -1557,15 +1563,15 @@ async function updatePreview(time: number): Promise<void> {
} }
const preview = byId('cutterPreview'); const preview = byId('cutterPreview');
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewLoading)}</p></div>`); preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewLoading}</p></div>`;
const frame = await window.api.extractFrame(cutterFile, time); const frame = await window.api.extractFrame(cutterFile, time);
if (frame) { if (frame) {
applyHtml(preview, `<img src="${escapeHtml(frame)}" alt="${escapeHtml(UI_TEXT.cutter.previewAlt)}">`); preview.innerHTML = `<img src="${frame}" alt="Preview">`;
return; return;
} }
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewUnavailable)}</p></div>`); preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewUnavailable}</p></div>`;
} }
async function startCutting(): Promise<void> { async function startCutting(): Promise<void> {
@ -1635,9 +1641,9 @@ function renderMergeFiles(): void {
<div class="file-order">${index + 1}</div> <div class="file-order">${index + 1}</div>
<div class="file-name" title="${file}">${name}</div> <div class="file-name" title="${file}">${name}</div>
<div class="file-actions"> <div class="file-actions">
<button type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveUpAria)}" title="${escapeHtml(UI_TEXT.merge.moveUpAria)}" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>&#9650;</button> <button class="file-btn" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>&#9650;</button>
<button type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveDownAria)}" title="${escapeHtml(UI_TEXT.merge.moveDownAria)}" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>&#9660;</button> <button class="file-btn" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>&#9660;</button>
<button type="button" class="file-btn remove" aria-label="${escapeHtml(UI_TEXT.merge.removeAria)}" title="${escapeHtml(UI_TEXT.merge.removeAria)}" onclick="removeMergeFile(${index})">x</button> <button class="file-btn remove" onclick="removeMergeFile(${index})">x</button>
</div> </div>
</div> </div>
`; `;

View File

@ -105,27 +105,6 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
padding: 15px 15px 8px; padding: 15px 15px 8px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
justify-content: space-between;
}
/* Inner label group inside .section-title keeps the title text and
the streamer-section-counter aligned on the text baseline as a single
unit, separate from the bulk-remove X button that pins to the right. */
.section-title-label {
display: flex;
align-items: baseline;
gap: 8px;
}
/* Compact spacing variant applied when the sidebar's streamer-list
filter input is visible directly below the title, so the default
padding-bottom + the filter's own margin don't double up into a
visually loose gap. */
.section-title.compact {
margin-bottom: 4px;
} }
.streamers { .streamers {
@ -207,7 +186,6 @@ body {
animation has somewhere to live and the styling stays consistent animation has somewhere to live and the styling stays consistent
with the rest of the action surfaces. */ with the rest of the action surfaces. */
.vod-bulk-bar { .vod-bulk-bar {
display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 10px 14px; padding: 10px 14px;
@ -217,8 +195,7 @@ body {
margin-bottom: 12px; margin-bottom: 12px;
flex-wrap: wrap; flex-wrap: wrap;
box-shadow: 0 4px 16px rgba(145, 70, 255, 0.10); box-shadow: 0 4px 16px rgba(145, 70, 255, 0.10);
/* Animation fires whenever the bar transitions from display:none /* Animation fires whenever the JS flips display:none -> display:flex,
(.is-hidden present) back to display:flex (.is-hidden removed),
because Animation events restart on each display change. */ because Animation events restart on each display change. */
animation: vod-bulk-bar-slide 0.22s cubic-bezier(0.16, 1, 0.3, 1); animation: vod-bulk-bar-slide 0.22s cubic-bezier(0.16, 1, 0.3, 1);
} }
@ -320,18 +297,12 @@ body {
} }
.clip-modal-duration-value { .clip-modal-duration-value {
color: var(--success); color: #00c853;
font-weight: 600; font-weight: 600;
font-family: 'Consolas', 'Segoe UI Mono', monospace; font-family: 'Consolas', 'Segoe UI Mono', monospace;
font-size: 14px; font-size: 14px;
} }
/* updateClipDuration flips this class when end <= start so the value
reads as a clear "Ungueltig!" / error message in red. */
.clip-modal-duration-value.invalid {
color: var(--error);
}
.clip-modal-hint { .clip-modal-hint {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 12px; font-size: 12px;
@ -339,22 +310,20 @@ body {
line-height: 1.4; line-height: 1.4;
} }
/* .clip-template-lint was the old per-modal rule for the clip-cutter
template lint badge. Superseded by the shared .template-lint
class (with .ok / .warn modifiers driven from var(--success) /
var(--error)). Class kept as a no-op alias in case any external
reference still uses it. */
.clip-template-lint {
font-size: 12px;
margin-top: 4px;
}
.clip-template-wrap { .clip-template-wrap {
display: none;
margin-top: 10px; margin-top: 10px;
} }
.clip-template-wrap.shown {
display: block;
}
/* Template-Guide button below the clip-template input small offset
from the lint badge that sits directly above it. Was a one-off
inline style on the button. */
.clip-template-wrap .btn-secondary {
margin-top: 8px;
}
.clip-radio-row { .clip-radio-row {
display: flex; display: flex;
align-items: center; align-items: center;
@ -471,10 +440,6 @@ body {
color: var(--text); color: var(--text);
font-size: 13px; font-size: 13px;
} }
/* No .filter-input:hover here it's redundant with the global
input[type="text"]:hover rule added in 4.6.142 (same effect: soft
purple border on hover). The class is always applied to <input
type="text"> elements, so the global rule already covers them. */
.filter-input.compact { .filter-input.compact {
width: calc(100% - 16px); width: calc(100% - 16px);
@ -587,13 +552,6 @@ body {
animation: skel-shimmer 1.5s linear infinite; animation: skel-shimmer 1.5s linear infinite;
} }
/* Three skeleton-line variants for the VOD card placeholder match
the visual rhythm of a real card (title line, then two shorter
meta lines). Replaces inline width/height/margin-top declarations. */
.vod-card-skeleton .vod-skel-line.title { width: 85%; }
.vod-card-skeleton .vod-skel-line.meta-1 { width: 55%; height: 10px; margin-top: 8px; }
.vod-card-skeleton .vod-skel-line.meta-2 { width: 40%; height: 10px; margin-top: 6px; }
@keyframes skel-shimmer { @keyframes skel-shimmer {
0% { background-position: 200% 0; } 0% { background-position: 200% 0; }
100% { background-position: -200% 0; } 100% { background-position: -200% 0; }
@ -715,21 +673,6 @@ select:focus {
box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.18); box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.18);
} }
/* Soft mouseover affordance every text/search/number/etc. input + textarea
+ select picks up a half-tone accent border on hover, matching the
.select-compact + .filter-input hover pattern. :not(:focus) keeps the
focus ring (above) from competing, :not(:disabled) leaves disabled
inputs inert. */
input[type="text"]:hover:not(:focus):not(:disabled),
input[type="search"]:hover:not(:focus):not(:disabled),
input[type="number"]:hover:not(:focus):not(:disabled),
input[type="password"]:hover:not(:focus):not(:disabled),
input[type="email"]:hover:not(:focus):not(:disabled),
textarea:hover:not(:focus):not(:disabled),
select:hover:not(:focus):not(:disabled) {
border-color: rgba(145, 70, 255, 0.45);
}
/* ============================================ /* ============================================
CUSTOM CHECKBOX modern Twitch-purple CUSTOM CHECKBOX modern Twitch-purple
============================================ ============================================
@ -866,19 +809,6 @@ select option {
padding: 7px 10px; padding: 7px 10px;
color: var(--text); color: var(--text);
font-size: 13px; font-size: 13px;
transition: border-color 0.15s, background 0.15s;
}
.select-compact:hover:not(:disabled) {
background: rgba(145, 70, 255, 0.08);
border-color: rgba(145, 70, 255, 0.45);
}
/* Wider variant used for the Archive-search streamer-name select
where short streamer names would collapse the dropdown to an
unhelpful 80-100px otherwise. Matches the .form-stack.size-md width. */
.select-compact.size-md {
min-width: 160px;
} }
/* Queue Section */ /* Queue Section */
@ -1028,12 +958,6 @@ select option {
transform: scale(0.92); transform: scale(0.92);
} }
.queue-retry-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65);
border-color: rgba(145, 70, 255, 0.55);
}
.queue-main { .queue-main {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@ -1133,17 +1057,12 @@ select option {
} }
.queue-details { .queue-details {
display: none;
font-size: 10px; font-size: 10px;
color: var(--text-secondary); color: var(--text-secondary);
padding: 4px 0; padding: 4px 0;
word-break: break-all; word-break: break-all;
} }
.queue-details.expanded {
display: block;
}
.queue-details div { .queue-details div {
margin-bottom: 2px; margin-bottom: 2px;
} }
@ -1231,15 +1150,6 @@ select option {
transition: all 0.2s; transition: all 0.2s;
} }
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn:disabled:hover {
background: inherit;
}
.btn:focus-visible { .btn:focus-visible {
outline: none; outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65); box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65);
@ -1695,19 +1605,6 @@ select option {
background: rgba(255,255,255,0.15); background: rgba(255,255,255,0.15);
} }
/* Focus-visible for the per-card action buttons (Trim, Queue, etc.). The
primary variant already has a purple background use the inner-white
+ outer-purple double ring so the focus indicator stays visible
against the button's own colour. */
.vod-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65);
}
.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);
}
/* Settings */ /* Settings */
.settings-card { .settings-card {
background: var(--bg-card); background: var(--bg-card);
@ -1716,14 +1613,6 @@ select option {
margin-bottom: 20px; margin-bottom: 20px;
} }
/* Centred-narrow settings card used for the standalone Clips Info
card where the content (a short list of supported URL formats) reads
better at a constrained width than across the full main column. */
.settings-card.centered {
max-width: 600px;
margin: 20px auto;
}
.settings-card h3 { .settings-card h3 {
font-size: 16px; font-size: 16px;
margin-bottom: 15px; margin-bottom: 15px;
@ -1732,22 +1621,6 @@ select option {
gap: 8px; gap: 8px;
} }
/* Subsection heading inside a settings card used when a single card
bundles two logical groups (Storage Auto-Cleanup) and the second
needs its own miniature heading after a divider. */
.settings-card h4 {
margin: 0 0 8px 0;
font-size: 14px;
}
/* Horizontal divider inside settings cards soft single line, balanced
vertical breathing room, no default browser shading. */
.settings-card hr {
border: none;
border-top: 1px solid var(--border-soft);
margin: 16px 0;
}
.form-group { .form-group {
margin-bottom: 15px; margin-bottom: 15px;
} }
@ -1813,24 +1686,11 @@ select option {
border-color: rgba(255,255,255,0.26); border-color: rgba(255,255,255,0.26);
} }
.lang-option:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55);
}
.lang-option.active { .lang-option.active {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(145, 70, 255, 0.2); box-shadow: 0 0 0 1px rgba(145, 70, 255, 0.2);
} }
/* Active + focused combine the pressed-state border with the
thicker focus halo so keyboard users still see which one was
focused even when it's also the currently-selected language. */
.lang-option.active:focus-visible {
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55);
}
.flag-icon { .flag-icon {
width: 16px; width: 16px;
height: 12px; height: 12px;
@ -1873,42 +1733,12 @@ select option {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 10px; margin-bottom: 10px;
flex-wrap: wrap;
} }
.form-row.section-header h3 { .form-row.section-header h3 {
margin: 0; margin: 0;
} }
/* Right-side action cluster inside a section-header keeps a label
and a button (or two) together as a single unit so the section-header
parent's justify-content:space-between can pin the cluster to the
right while the h3 stays at the left. */
.section-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* Plain centred form-row with bottom margin the most common
form-row shape in Settings (button + button + inline-toggle, or
number-input + sublabel). Replaces three duplicated inline copies
of the same align-items:center; margin-bottom:10px declaration. */
.form-row.aligned {
align-items: center;
margin-bottom: 10px;
}
/* Search/filter tool-row variant wraps on narrow widths so the
select / input cluster collapses gracefully. Used by the Archive
search row (input + 3 selects + button). */
.form-row.search-bar {
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
align-items: center;
}
.log-panel { .log-panel {
background: #11151c; background: #11151c;
border: 1px solid rgba(255,255,255,0.12); border: 1px solid rgba(255,255,255,0.12);
@ -1970,11 +1800,6 @@ select option {
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55); box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55);
} }
.btn-secondary:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ============================================ /* ============================================
COMPACT / UTILITY BUTTONS COMPACT / UTILITY BUTTONS
============================================ ============================================
@ -2313,24 +2138,6 @@ select option {
flex: 1; flex: 1;
} }
/* Min-width sizing modifiers let the row wrap to a new line before
the stack collapses below the named breakpoint. Replaces three inline
min-width declarations in the Auto-Cleanup 3-up row. */
.form-stack.size-sm {
min-width: 120px;
}
.form-stack.size-md {
min-width: 160px;
}
/* Compact-width input used for the Auto-VOD poll/age inputs where
the values are 2-3 digits and a full-width input would look odd
alongside their inline sublabels. */
.input-narrow {
width: 90px;
}
/* Block-level note text same colour as .form-sublabel but reserved /* Block-level note text same colour as .form-sublabel but reserved
for full-row paragraphs like the cleanup report area. */ for full-row paragraphs like the cleanup report area. */
.form-note { .form-note {
@ -2350,38 +2157,6 @@ select option {
margin-bottom: 12px; margin-bottom: 12px;
} }
/* Inline link inside a card intro picks up the accent colour so it
reads as actionable text rather than the default browser blue. The
underline + pointer cursor come from the browser's <a> defaults. */
.card-intro a {
color: var(--accent);
}
/* Multi-line info text preserves authored line breaks (white-space:
pre-line) so the Clips card can list URL formats one-per-line in
the HTML without separate <br>/<li> markup. */
.info-text {
color: var(--text-secondary);
line-height: 1.6;
white-space: pre-line;
}
/* Responsive KPI grid for the Stats Summary card fits as many 180px
tiles per row as the column allows, with equal-share growth. */
.stats-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
/* Flush variant: the intro sits flush against the next sibling block
(e.g. the stats summary grid) and gets its top breathing room from
the preceding section-header row rather than its own bottom margin. */
.card-intro.flush {
margin-top: 8px;
margin-bottom: 0;
}
/* Filename-templates 3-pair grid (VOD / Part / Clip template inputs). /* Filename-templates 3-pair grid (VOD / Part / Clip template inputs).
Each row is a label above an input; the label gets the 13px secondary Each row is a label above an input; the label gets the 13px secondary
styling that used to be inline on every label. */ styling that used to be inline on every label. */
@ -2440,15 +2215,10 @@ select option {
template inputs and by the clip-cutter modal's custom template template inputs and by the clip-cutter modal's custom template
row. Two states: green for OK, red for unknown-placeholder row. Two states: green for OK, red for unknown-placeholder
warning. Pull the colours from --success / --error vars so the warning. Pull the colours from --success / --error vars so the
lint always tracks the rest of the apps semantic palette. lint always tracks the rest of the apps semantic palette. */
margin-top is part of the class so both usage sites pick up the
same rhythm the previous inline-style values diverged by 2px
between the two spots, an inconsistency that's not worth tracking. */
.template-lint { .template-lint {
font-size: 12px; font-size: 12px;
line-height: 1.4; line-height: 1.4;
margin-top: 6px;
transition: color 0.15s; transition: color 0.15s;
} }
@ -2992,26 +2762,13 @@ select option {
text-align: center; text-align: center;
} }
.video-preview .placeholder svg {
opacity: 0.3;
}
.video-preview .placeholder p {
margin-top: 10px;
}
.timeline-container { .timeline-container {
display: none;
background: var(--bg-card); background: var(--bg-card);
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 20px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.timeline-container.shown {
display: block;
}
.timeline { .timeline {
position: relative; position: relative;
height: 60px; height: 60px;
@ -3090,19 +2847,15 @@ select option {
} }
.cutter-info { .cutter-info {
display: none;
background: var(--bg-card); background: var(--bg-card);
border-radius: 8px; border-radius: 8px;
padding: 15px 20px; padding: 15px 20px;
margin-bottom: 20px; margin-bottom: 20px;
display: flex;
justify-content: space-around; justify-content: space-around;
text-align: center; text-align: center;
} }
.cutter-info.shown {
display: flex;
}
.cutter-info-item { .cutter-info-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -3181,22 +2934,10 @@ select option {
color: var(--text); color: var(--text);
} }
.file-item .file-btn:focus-visible {
outline: none;
border-radius: 4px;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65);
color: var(--text);
}
.file-item .file-btn.remove:hover { .file-item .file-btn.remove:hover {
color: var(--error); color: var(--error);
} }
.file-item .file-btn.remove:focus-visible {
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.65);
color: var(--error);
}
.merge-actions { .merge-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@ -3478,12 +3219,6 @@ body.theme-light .modal {
border-color: rgba(255, 70, 70, 0.55); border-color: rgba(255, 70, 70, 0.55);
} }
.modal-close:focus-visible {
outline: none;
border-color: rgba(255, 70, 70, 0.6);
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.6);
}
.modal-close:active { .modal-close:active {
transform: scale(0.92); transform: scale(0.92);
} }
@ -3787,6 +3522,11 @@ input[type="number"]::-webkit-outer-spin-button {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 167, 38, 0.25); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 167, 38, 0.25);
} }
.app-toast.error {
border-left-color: var(--error);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 70, 70, 0.25);
}
/* ============================================ /* ============================================
STREAMER SECTION COUNTER STREAMER SECTION COUNTER
============================================ ============================================
@ -3896,7 +3636,6 @@ input[type="number"]::-webkit-outer-spin-button {
.chat-viewer-row .chat-viewer-user { .chat-viewer-row .chat-viewer-user {
font-weight: 700; font-weight: 700;
margin-right: 4px; margin-right: 4px;
color: var(--accent);
} }
.chat-viewer-row .chat-viewer-tag { .chat-viewer-row .chat-viewer-tag {
@ -3939,14 +3678,6 @@ input[type="number"]::-webkit-outer-spin-button {
font-family: 'Consolas', 'Segoe UI Mono', monospace; font-family: 'Consolas', 'Segoe UI Mono', monospace;
} }
/* Empty state inside the events-viewer modal shown when an events
file exists but contains no parsed entries. */
.event-viewer-empty {
color: var(--text-secondary);
padding: 12px;
text-align: center;
}
.event-viewer-tag { .event-viewer-tag {
font-weight: 600; font-weight: 600;
margin-right: 8px; margin-right: 8px;
@ -4294,28 +4025,7 @@ input[type="number"]::-webkit-outer-spin-button {
box-shadow: 0 4px 14px rgba(145, 70, 255, 0.4); box-shadow: 0 4px 14px rgba(145, 70, 255, 0.4);
} }
/* Focus-visible for the profile action buttons (Record now, Open on /* Skeleton loading state */
Twitch, Refresh). Default variant gets a purple ring; the primary
variant already has a purple background so it gets the inner-white
+ outer-purple double ring used elsewhere for purple-bg buttons. */
.streamer-profile-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65);
border-color: rgba(145, 70, 255, 0.6);
}
.streamer-profile-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);
}
/* Skeleton loading state switches the profile-header from its
regular block layout to a flex row so the avatar + body sit
side-by-side. The element itself was previously flipped via inline
.style.display='flex' in renderStreamerProfileSkeleton(). */
.streamer-profile-skeleton {
display: flex;
}
.streamer-profile-skeleton .streamer-profile-skel-block { .streamer-profile-skeleton .streamer-profile-skel-block {
background: linear-gradient(90deg, var(--bg-elevated) 0%, rgba(255,255,255,0.06) 50%, var(--bg-elevated) 100%); background: linear-gradient(90deg, var(--bg-elevated) 0%, rgba(255,255,255,0.06) 50%, var(--bg-elevated) 100%);
background-size: 200% 100%; background-size: 200% 100%;
@ -4323,37 +4033,6 @@ input[type="number"]::-webkit-outer-spin-button {
border-radius: 4px; border-radius: 4px;
} }
/* Pre-shaped skeleton block variants each matches one of the
real-profile-card slots so the loading silhouette previews the
final layout. Replaces inline width/height/border-radius declarations. */
.streamer-profile-skel-block.avatar {
width: 88px;
height: 88px;
border-radius: 50%;
flex-shrink: 0;
}
.streamer-profile-skel-block.name {
width: 180px;
height: 24px;
}
.streamer-profile-skel-block.badge {
width: 90px;
height: 18px;
border-radius: 10px;
}
.streamer-profile-skel-block.subtitle {
width: 60%;
height: 14px;
margin-top: 6px;
}
.streamer-profile-skel-stats {
margin-top: 8px;
}
@keyframes profile-skel-shimmer { @keyframes profile-skel-shimmer {
0% { background-position: 200% 0; } 0% { background-position: 200% 0; }
100% { background-position: -200% 0; } 100% { background-position: -200% 0; }
@ -4525,95 +4204,3 @@ input[type="number"]::-webkit-outer-spin-button {
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 70%, rgba(0, 0, 0, 0.18) 100%); background: linear-gradient(180deg, rgba(0, 0, 0, 0) 70%, rgba(0, 0, 0, 0.18) 100%);
pointer-events: none; pointer-events: none;
} }
/* ============================================
REDUCED MOTION respect OS-level user preference
============================================
Users who set "Reduce motion" in their OS accessibility settings
(Windows: Settings > Accessibility > Visual Effects > Animation
effects; macOS: System Settings > Accessibility > Display > Reduce
motion) get animations and transitions effectively disabled.
Suppresses things like the empty-state-float loop, the btn-icon-spin
on Refresh hover, the vod-bulk-bar slide-in, the storyboard fade-in,
and the multitude of transition: all 0.2s declarations anything
that involves motion. Critical for users with vestibular disorders
and a baseline accessibility expectation in 2025. */
/* Generic hide utility. Use when an element's visible-state display
differs (button = inline-block, bulk-bar = flex, etc.) so a single
class can hide any of them without per-element .shown modifiers.
The !important wins over the base class's display declaration. */
.is-hidden {
display: none !important;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ============================================
CONTEXT MENU generic right-click menu base
============================================
Used by both the queue row context menu (renderer-queue.ts) and the
VOD card context menu (renderer-streamers.ts). left/top stay inline
on the container (set per-click); everything else lives here. */
.context-menu {
position: fixed;
z-index: 9999;
background: var(--bg-card);
border: 1px solid var(--border-soft);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
padding: 4px;
min-width: 200px;
}
.context-menu-item {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
color: var(--text);
border-radius: 4px;
transition: background 0.12s;
}
.context-menu-item:hover:not(.disabled) {
background: rgba(145, 70, 255, 0.15);
}
.context-menu-item.disabled {
color: var(--text-secondary);
opacity: 0.55;
cursor: not-allowed;
}
.context-menu-separator {
height: 1px;
margin: 4px 6px;
background: var(--border-soft);
}
/* Output-row appended to the queue-item detail panel when a job
completed. Lists the file actions (Open file / Show in folder /
View chat / View events) followed by a tiny secondary-colour file
label. */
.queue-output-row {
display: flex;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
align-items: center;
}
.queue-output-label {
color: var(--text-secondary);
font-size: 11px;
word-break: break-all;
}