Compare commits

..

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

10 changed files with 27 additions and 41 deletions

4
package-lock.json generated
View File

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

View File

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

View File

@ -118,7 +118,7 @@
<div class="modal viewer-modal viewer-modal-events">
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeEventsViewer()">x</button>
<h2 id="eventsViewerTitle" class="viewer-modal-title"></h2>
<div id="eventsViewerStatus" class="viewer-modal-status" role="status" aria-live="polite"></div>
<div id="eventsViewerStatus" class="viewer-modal-status"></div>
<div id="eventsViewerList" class="viewer-modal-list"></div>
</div>
</div>
@ -130,7 +130,7 @@
<h2 id="chatViewerTitle" class="viewer-modal-title"></h2>
<div class="viewer-modal-filter-row">
<input type="text" id="chatViewerFilter" class="viewer-modal-filter-input" placeholder="Filter..." oninput="onChatViewerFilterChange()">
<span id="chatViewerStatus" class="viewer-modal-status viewer-modal-status-inline" role="status" aria-live="polite"></span>
<span id="chatViewerStatus" class="viewer-modal-status viewer-modal-status-inline"></span>
</div>
<div id="chatViewerList" class="viewer-modal-list viewer-modal-list-chat"></div>
</div>
@ -301,7 +301,7 @@
<h2 id="clipsHeading">Twitch Clip-Download</h2>
<input type="text" id="clipUrl" placeholder="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...">
<button type="button" class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
<div class="clip-status" id="clipStatus" role="status" aria-live="polite"></div>
<div class="clip-status" id="clipStatus"></div>
</div>
<div class="settings-card centered">
@ -422,7 +422,7 @@
<div class="form-row section-header">
<h3 id="statsTitle">Archiv-Statistik</h3>
<div class="section-header-actions">
<span id="statsLastScannedLabel" class="form-sublabel" role="status" aria-live="polite"></span>
<span id="statsLastScannedLabel" class="form-sublabel"></span>
<button type="button" class="btn-secondary" id="btnStatsRefresh" onclick="refreshArchiveStats()">Aktualisieren</button>
</div>
</div>
@ -474,7 +474,7 @@
</select>
<button type="button" class="btn-secondary" id="btnArchiveSearch" onclick="performArchiveSearch()">Suchen</button>
</div>
<div id="archiveSearchSummary" class="form-sublabel" role="status" aria-live="polite"></div>
<div id="archiveSearchSummary" class="form-sublabel"></div>
</div>
<div class="settings-card">
<div id="archiveSearchResults"></div>
@ -695,7 +695,7 @@
<button type="button" class="btn-secondary" id="btnRefreshStorage" onclick="refreshStorageStats()">Aktualisieren</button>
</div>
<p id="storageCardIntro" class="card-intro">Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.</p>
<div id="storageSummary" class="form-sublabel" style="margin-bottom:8px;" role="status" aria-live="polite"></div>
<div id="storageSummary" class="form-sublabel" style="margin-bottom:8px;"></div>
<div id="storageList"></div>
<hr>
@ -729,7 +729,7 @@
<button type="button" class="btn-secondary" id="btnCleanupDryRun" onclick="runCleanupDryRun()">Vorschau</button>
<button type="button" class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausfuehren</button>
</div>
<div id="cleanupReport" class="form-note" role="status" aria-live="polite"></div>
<div id="cleanupReport" class="form-note"></div>
</div>
<div class="settings-card">

View File

@ -100,10 +100,11 @@ const UI_TEXT_DE = {
autoVodScanNow: 'Jetzt scannen',
autoRecordScanNow: 'Live-Status pruefen',
statsTitle: 'Archiv-Statistik',
statsIntro: 'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.',
statsIntro: 'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter {streamer}/live/, VOD-Downloads direkt unter {streamer}/. Lade-Zeit skaliert mit der Anzahl Dateien.',
statsRefresh: 'Aktualisieren',
statsScanning: 'Scanne...',
statsScannedAt: 'Letzter Scan',
statsScannedAtNever: 'Noch nicht gescannt',
statsSummaryTitle: 'Uebersicht',
statsTopStreamersTitle: 'Top Streamer (nach Groesse)',
statsActivityTitle: 'Aktivitaet (letzte 30 Tage)',
@ -139,7 +140,6 @@ const UI_TEXT_DE = {
archiveNoMatches: 'Keine Treffer.',
archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
archiveSearchPlaceholder: 'Suche...',
archiveSearchAria: 'Archiv durchsuchen',
archiveOpen: 'Oeffnen',
archiveShowInFolder: 'Ordner',
archiveViewChat: 'Chat',
@ -178,7 +178,6 @@ const UI_TEXT_DE = {
downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Waehle einen anderen Ordner oder pruefe die Schreibrechte.',
streamerSectionTitle: 'Streamer',
streamerListFilterPlaceholder: 'Filtern...',
streamerListFilterAria: 'Streamer-Liste filtern',
streamerAddAriaLabel: 'Streamer hinzufuegen',
streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)',
streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?',
@ -334,7 +333,6 @@ const UI_TEXT_DE = {
openTwitch: 'Auf Twitch oeffnen',
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen',
liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten',
liveThumbAlt: 'Live-Vorschau',
recordNow: 'Jetzt aufnehmen',
refresh: 'Aktualisieren',
agoMinutes: 'vor {n} Min',
@ -380,7 +378,6 @@ const UI_TEXT_DE = {
addQueue: '+ Warteschlange',
trimButton: 'VOD zuschneiden',
filterPlaceholder: 'Nach Titel filtern... (Strg+F)',
filterAria: 'VOD-Titel filtern',
filterClearTitle: 'Filter loeschen (Esc)',
filterNoMatchTitle: 'Keine Treffer',
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.',
@ -447,7 +444,6 @@ const UI_TEXT_DE = {
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
previewLoading: 'Lade Vorschau...',
previewUnavailable: 'Vorschau nicht verfugbar',
previewAlt: 'Vorschau',
cutting: 'Schneidet...',
cut: 'Schneiden',
cutSuccess: 'Video erfolgreich geschnitten!',

View File

@ -101,10 +101,11 @@ const UI_TEXT_EN = {
autoVodScanNow: 'Scan now',
autoRecordScanNow: 'Check live status',
statsTitle: 'Archive statistics',
statsIntro: 'Aggregated across the download folder. Live recordings live under <code>{streamer}/live/</code>, VOD downloads under <code>{streamer}/</code>. Scan time scales with file count.',
statsIntro: 'Aggregated across the download folder. Live recordings live under {streamer}/live/, VOD downloads under {streamer}/. Scan time scales with file count.',
statsRefresh: 'Refresh',
statsScanning: 'Scanning...',
statsScannedAt: 'Last scan',
statsScannedAtNever: 'Not yet scanned',
statsSummaryTitle: 'Overview',
statsTopStreamersTitle: 'Top streamers (by size)',
statsActivityTitle: 'Activity (last 30 days)',
@ -140,7 +141,6 @@ const UI_TEXT_EN = {
archiveNoMatches: 'No matches.',
archiveNoRoot: 'Download folder not found. Set a download path in Settings first.',
archiveSearchPlaceholder: 'Search...',
archiveSearchAria: 'Search archive',
archiveOpen: 'Open',
archiveShowInFolder: 'Folder',
archiveViewChat: 'Chat',
@ -178,7 +178,6 @@ const UI_TEXT_EN = {
downloadPathNotWritable: 'Download folder is not writable. Pick another folder or grant write permission.',
streamerSectionTitle: 'Streamer',
streamerListFilterPlaceholder: 'Filter...',
streamerListFilterAria: 'Filter streamer list',
streamerAddAriaLabel: 'Add streamer',
streamerBulkRemoveTitle: 'Remove all (or filtered)',
streamerBulkRemoveAll: 'Remove all {count} streamers from the list?',
@ -334,7 +333,6 @@ const UI_TEXT_EN = {
openTwitch: 'Open on Twitch',
openTwitchTooltip: 'Open this channel on twitch.tv',
liveCardTooltip: 'Click to start a live recording right now',
liveThumbAlt: 'Live preview',
recordNow: 'Record now',
refresh: 'Refresh',
agoMinutes: '{n} min ago',
@ -380,7 +378,6 @@ const UI_TEXT_EN = {
addQueue: '+ Queue',
trimButton: 'Trim VOD',
filterPlaceholder: 'Filter by title... (Ctrl+F)',
filterAria: 'Filter VOD titles',
filterClearTitle: 'Clear filter (Esc)',
filterNoMatchTitle: 'No matches',
filterNoMatchText: 'No VODs match the current filter.',
@ -447,7 +444,6 @@ const UI_TEXT_EN = {
videoInfoFailed: 'Could not read video info. Is FFprobe installed?',
previewLoading: 'Loading preview...',
previewUnavailable: 'Preview unavailable',
previewAlt: 'Preview',
cutting: 'Cutting...',
cut: 'Cut',
cutSuccess: 'Video cut successfully!',

View File

@ -106,7 +106,7 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
? `
<div class="streamer-profile-live-card" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.profile.liveCardTooltip)}" onclick="triggerLiveRecordingFromProfile('${safeLogin}')" onkeydown="if((event.key==='Enter'||event.key===' ')&&event.target===event.currentTarget){event.preventDefault();triggerLiveRecordingFromProfile('${safeLogin}');}" title="${escapeHtml(UI_TEXT.profile.liveCardTooltip)}">
${p.currentStreamPreviewUrl
? `<img class="streamer-profile-live-thumb" src="${escapeHtml(p.currentStreamPreviewUrl)}" alt="${escapeHtml(UI_TEXT.profile.liveThumbAlt)}" onerror="onProfileLivePreviewError(this)">`
? `<img class="streamer-profile-live-thumb" src="${escapeHtml(p.currentStreamPreviewUrl)}" alt="Live preview" onerror="onProfileLivePreviewError(this)">`
: `<div class="streamer-profile-live-thumb-fallback"></div>`}
<div class="streamer-profile-live-body">
<div class="streamer-profile-live-badge-row">

View File

@ -88,11 +88,6 @@ function applyTemplatePreset(preset: string): void {
byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts;
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip;
validateFilenameTemplates();
// Programmatic .value = ... does not trigger the 'input' event the
// template inputs listen on for debounced save, so the preset click
// would otherwise look applied but never persist until the user
// types into one of the inputs. Schedule the save explicitly.
scheduleSettingsAutoSave();
}
async function refreshRuntimeMetrics(showLoading = true): Promise<void> {

View File

@ -67,7 +67,6 @@ function applyLanguageToStaticUI(): void {
setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn);
const archiveQueryInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
if (archiveQueryInput) archiveQueryInput.placeholder = UI_TEXT.static.archiveSearchPlaceholder;
setAriaLabel('archiveSearchQuery', UI_TEXT.static.archiveSearchAria);
const archiveTypeSelect = document.getElementById('archiveSearchType') as HTMLSelectElement | null;
if (archiveTypeSelect) {
const opts = archiveTypeSelect.options;
@ -86,8 +85,6 @@ function applyLanguageToStaticUI(): void {
}
setText('navSettingsText', UI_TEXT.static.navSettings);
setText('statsTitle', UI_TEXT.static.statsTitle);
const statsIntroEl = document.getElementById('statsIntro');
if (statsIntroEl) applyHtml(statsIntroEl, UI_TEXT.static.statsIntro);
setText('statsSummaryTitle', UI_TEXT.static.statsSummaryTitle);
setText('statsTopStreamersTitle', UI_TEXT.static.statsTopStreamersTitle);
setText('statsActivityTitle', UI_TEXT.static.statsActivityTitle);
@ -186,7 +183,6 @@ function applyLanguageToStaticUI(): void {
setText('streamlinkQualityAudio', UI_TEXT.static.streamlinkQualityAudio);
setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle);
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
setAriaLabel('streamerListFilter', UI_TEXT.static.streamerListFilterAria);
setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
setAriaLabel('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
setAriaLabel('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
@ -295,9 +291,7 @@ function applyLanguageToStaticUI(): void {
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
setAriaLabel('newStreamer', UI_TEXT.static.streamerAddAriaLabel);
setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder);
setAriaLabel('vodFilterInput', UI_TEXT.vods.filterAria);
setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
setAriaLabel('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
setPlaceholder('chatViewerFilter', UI_TEXT.queue.chatViewerFilterPlaceholder);

View File

@ -1557,15 +1557,15 @@ async function updatePreview(time: number): Promise<void> {
}
const preview = byId('cutterPreview');
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewLoading)}</p></div>`);
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewLoading}</p></div>`;
const frame = await window.api.extractFrame(cutterFile, time);
if (frame) {
applyHtml(preview, `<img src="${escapeHtml(frame)}" alt="${escapeHtml(UI_TEXT.cutter.previewAlt)}">`);
preview.innerHTML = `<img src="${frame}" alt="Preview">`;
return;
}
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewUnavailable)}</p></div>`);
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewUnavailable}</p></div>`;
}
async function startCutting(): Promise<void> {

View File

@ -471,10 +471,10 @@ body {
color: var(--text);
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:hover:not(:focus):not(:disabled) {
border-color: rgba(145, 70, 255, 0.45);
}
.filter-input.compact {
width: calc(100% - 16px);
@ -3787,6 +3787,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);
}
.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
============================================