Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e81a47e9e | ||
|
|
70643b4c08 | ||
|
|
86d68466f9 | ||
|
|
ae156ff395 | ||
|
|
2d109077a0 | ||
|
|
25be77b4ab | ||
|
|
29315091c6 | ||
|
|
84f576d131 | ||
|
|
fce353d529 | ||
|
|
7b0e511479 | ||
|
|
6c56c4e908 | ||
|
|
4472e3bf50 | ||
|
|
ce3b876006 | ||
|
|
801e02601f | ||
|
|
65c9d06dfa | ||
|
|
e8404b8802 | ||
|
|
3d40160b5c | ||
|
|
85d2bf5316 | ||
|
|
8f0f7d5d84 | ||
|
|
564d123431 | ||
|
|
6d28aa1972 | ||
|
|
0419317122 | ||
|
|
b73593fc9a | ||
|
|
0aea6af88c |
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.6.143",
|
||||
"version": "4.6.155",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.6.143",
|
||||
"version": "4.6.155",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.6.143",
|
||||
"version": "4.6.155",
|
||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||
"main": "dist/main.js",
|
||||
"author": "xRangerDE",
|
||||
|
||||
@ -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"></div>
|
||||
<div id="eventsViewerStatus" class="viewer-modal-status" role="status" aria-live="polite"></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"></span>
|
||||
<span id="chatViewerStatus" class="viewer-modal-status viewer-modal-status-inline" role="status" aria-live="polite"></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"></div>
|
||||
<div class="clip-status" id="clipStatus" role="status" aria-live="polite"></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"></span>
|
||||
<span id="statsLastScannedLabel" class="form-sublabel" role="status" aria-live="polite"></span>
|
||||
<button type="button" class="btn-secondary" id="btnStatsRefresh" onclick="refreshArchiveStats()">Aktualisieren</button>
|
||||
</div>
|
||||
</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"></div>
|
||||
<div id="archiveSearchSummary" class="form-sublabel" role="status" aria-live="polite"></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;"></div>
|
||||
<div id="storageSummary" class="form-sublabel" style="margin-bottom:8px;" role="status" aria-live="polite"></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"></div>
|
||||
<div id="cleanupReport" class="form-note" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
|
||||
@ -100,11 +100,10 @@ const UI_TEXT_DE = {
|
||||
autoVodScanNow: 'Jetzt scannen',
|
||||
autoRecordScanNow: 'Live-Status pruefen',
|
||||
statsTitle: 'Archiv-Statistik',
|
||||
statsIntro: 'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter {streamer}/live/, VOD-Downloads direkt unter {streamer}/. Lade-Zeit skaliert mit der Anzahl Dateien.',
|
||||
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.',
|
||||
statsRefresh: 'Aktualisieren',
|
||||
statsScanning: 'Scanne...',
|
||||
statsScannedAt: 'Letzter Scan',
|
||||
statsScannedAtNever: 'Noch nicht gescannt',
|
||||
statsSummaryTitle: 'Uebersicht',
|
||||
statsTopStreamersTitle: 'Top Streamer (nach Groesse)',
|
||||
statsActivityTitle: 'Aktivitaet (letzte 30 Tage)',
|
||||
@ -140,6 +139,7 @@ const UI_TEXT_DE = {
|
||||
archiveNoMatches: 'Keine Treffer.',
|
||||
archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
|
||||
archiveSearchPlaceholder: 'Suche...',
|
||||
archiveSearchAria: 'Archiv durchsuchen',
|
||||
archiveOpen: 'Oeffnen',
|
||||
archiveShowInFolder: 'Ordner',
|
||||
archiveViewChat: 'Chat',
|
||||
@ -178,11 +178,11 @@ const UI_TEXT_DE = {
|
||||
downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Waehle einen anderen Ordner oder pruefe die Schreibrechte.',
|
||||
streamerSectionTitle: 'Streamer',
|
||||
streamerListFilterPlaceholder: 'Filtern...',
|
||||
streamerListFilterAria: 'Streamer-Liste filtern',
|
||||
streamerAddAriaLabel: 'Streamer hinzufuegen',
|
||||
streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)',
|
||||
streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?',
|
||||
streamerBulkRemoveFiltered: 'Die {count} passenden Streamer aus der Liste entfernen?',
|
||||
cutterDropHint: 'Video-Datei hierher ziehen zum Laden.',
|
||||
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
|
||||
filenameTemplatesTitle: 'Dateinamen-Templates',
|
||||
vodTemplateLabel: 'VOD-Template',
|
||||
@ -334,6 +334,7 @@ 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',
|
||||
@ -379,6 +380,7 @@ 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.',
|
||||
@ -445,6 +447,7 @@ 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!',
|
||||
|
||||
@ -101,11 +101,10 @@ const UI_TEXT_EN = {
|
||||
autoVodScanNow: 'Scan now',
|
||||
autoRecordScanNow: 'Check live status',
|
||||
statsTitle: 'Archive statistics',
|
||||
statsIntro: 'Aggregated across the download folder. Live recordings live under {streamer}/live/, VOD downloads under {streamer}/. Scan time scales with file count.',
|
||||
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.',
|
||||
statsRefresh: 'Refresh',
|
||||
statsScanning: 'Scanning...',
|
||||
statsScannedAt: 'Last scan',
|
||||
statsScannedAtNever: 'Not yet scanned',
|
||||
statsSummaryTitle: 'Overview',
|
||||
statsTopStreamersTitle: 'Top streamers (by size)',
|
||||
statsActivityTitle: 'Activity (last 30 days)',
|
||||
@ -141,6 +140,7 @@ const UI_TEXT_EN = {
|
||||
archiveNoMatches: 'No matches.',
|
||||
archiveNoRoot: 'Download folder not found. Set a download path in Settings first.',
|
||||
archiveSearchPlaceholder: 'Search...',
|
||||
archiveSearchAria: 'Search archive',
|
||||
archiveOpen: 'Open',
|
||||
archiveShowInFolder: 'Folder',
|
||||
archiveViewChat: 'Chat',
|
||||
@ -178,11 +178,11 @@ const UI_TEXT_EN = {
|
||||
downloadPathNotWritable: 'Download folder is not writable. Pick another folder or grant write permission.',
|
||||
streamerSectionTitle: 'Streamer',
|
||||
streamerListFilterPlaceholder: 'Filter...',
|
||||
streamerListFilterAria: 'Filter streamer list',
|
||||
streamerAddAriaLabel: 'Add streamer',
|
||||
streamerBulkRemoveTitle: 'Remove all (or filtered)',
|
||||
streamerBulkRemoveAll: 'Remove all {count} streamers from the list?',
|
||||
streamerBulkRemoveFiltered: 'Remove the {count} matching streamer(s) from the list?',
|
||||
cutterDropHint: 'Drop a video file here to load it.',
|
||||
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
|
||||
filenameTemplatesTitle: 'Filename Templates',
|
||||
vodTemplateLabel: 'VOD Template',
|
||||
@ -334,6 +334,7 @@ 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',
|
||||
@ -379,6 +380,7 @@ 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.',
|
||||
@ -445,6 +447,7 @@ 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!',
|
||||
|
||||
@ -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="Live preview" onerror="onProfileLivePreviewError(this)">`
|
||||
? `<img class="streamer-profile-live-thumb" src="${escapeHtml(p.currentStreamPreviewUrl)}" alt="${escapeHtml(UI_TEXT.profile.liveThumbAlt)}" onerror="onProfileLivePreviewError(this)">`
|
||||
: `<div class="streamer-profile-live-thumb-fallback"></div>`}
|
||||
<div class="streamer-profile-live-body">
|
||||
<div class="streamer-profile-live-badge-row">
|
||||
|
||||
@ -88,6 +88,11 @@ 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> {
|
||||
|
||||
@ -67,6 +67,7 @@ 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;
|
||||
@ -85,6 +86,8 @@ 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);
|
||||
@ -183,6 +186,7 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('streamlinkQualityAudio', UI_TEXT.static.streamlinkQualityAudio);
|
||||
setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle);
|
||||
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
|
||||
setAriaLabel('streamerListFilter', UI_TEXT.static.streamerListFilterAria);
|
||||
setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
|
||||
setAriaLabel('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
|
||||
setAriaLabel('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
|
||||
@ -291,7 +295,9 @@ 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);
|
||||
|
||||
@ -1557,15 +1557,15 @@ async function updatePreview(time: number): Promise<void> {
|
||||
}
|
||||
|
||||
const preview = byId('cutterPreview');
|
||||
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewLoading}</p></div>`;
|
||||
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewLoading)}</p></div>`);
|
||||
|
||||
const frame = await window.api.extractFrame(cutterFile, time);
|
||||
if (frame) {
|
||||
preview.innerHTML = `<img src="${frame}" alt="Preview">`;
|
||||
applyHtml(preview, `<img src="${escapeHtml(frame)}" alt="${escapeHtml(UI_TEXT.cutter.previewAlt)}">`);
|
||||
return;
|
||||
}
|
||||
|
||||
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewUnavailable}</p></div>`;
|
||||
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewUnavailable)}</p></div>`);
|
||||
}
|
||||
|
||||
async function startCutting(): Promise<void> {
|
||||
|
||||
@ -471,10 +471,10 @@ body {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-input:hover:not(:focus):not(:disabled) {
|
||||
border-color: rgba(145, 70, 255, 0.45);
|
||||
}
|
||||
/* 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 {
|
||||
width: calc(100% - 16px);
|
||||
@ -1970,6 +1970,11 @@ select option {
|
||||
box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
COMPACT / UTILITY BUTTONS
|
||||
============================================
|
||||
@ -3782,11 +3787,6 @@ 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
|
||||
============================================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user