Add in-app template guide with live examples (v4.0.6)

This commit is contained in:
xRangerDE 2026-02-16 13:07:49 +01:00
parent 1306309e6e
commit 1e5e9137ff
9 changed files with 436 additions and 9 deletions

View File

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

View File

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

View File

@ -92,6 +92,7 @@
style="width: 100%; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white; font-family: monospace;"
oninput="updateFilenameExamples()">
<div id="clipTemplateHelp" style="color: #888; font-size: 12px; margin-top: 6px;">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<button class="btn-secondary" id="clipTemplateGuideBtn" style="margin-top: 8px;" onclick="openTemplateGuide('clip')">Template Guide</button>
</div>
</div>
@ -102,6 +103,48 @@
</div>
</div>
<!-- Template Guide Modal -->
<div class="modal-overlay" id="templateGuideModal">
<div class="modal template-guide-modal">
<button class="modal-close" onclick="closeTemplateGuide()">x</button>
<h2 id="templateGuideTitle">Template Guide</h2>
<p id="templateGuideIntro" class="template-guide-intro">Nutze Variablen fur Dateinamen und prufe das Ergebnis als Live-Vorschau.</p>
<div class="template-guide-actions">
<button class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button>
<button class="btn-secondary" id="templateGuideUseParts" onclick="setTemplateGuidePreset('parts')">VOD Part Template</button>
<button class="btn-secondary" id="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button>
</div>
<label id="templateGuideTemplateLabel" class="template-guide-label">Template</label>
<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-label" id="templateGuideOutputLabel">Live Vorschau</div>
<div id="templateGuideOutput" class="template-guide-output">-</div>
<div id="templateGuideContext" class="template-guide-context"></div>
</div>
<h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfugbare Variablen</h3>
<div class="template-guide-table-wrap">
<table class="template-guide-table">
<thead>
<tr>
<th id="templateGuideVarCol">Variable</th>
<th id="templateGuideDescCol">Beschreibung</th>
<th id="templateGuideExampleCol">Beispiel</th>
</tr>
</thead>
<tbody id="templateGuideBody"></tbody>
</table>
</div>
<div class="template-guide-footer">
<button class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button>
</div>
</div>
</div>
<div class="app">
<aside class="sidebar">
<div class="logo">
@ -365,7 +408,10 @@
<input type="number" id="partMinutes" value="120" min="10" max="480">
</div>
<div class="form-group">
<label id="filenameTemplatesTitle">Dateinamen-Templates</label>
<div class="form-row" style="align-items:center; margin-bottom: 4px;">
<label id="filenameTemplatesTitle" style="margin: 0;">Dateinamen-Templates</label>
<button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
</div>
<div style="display: grid; gap: 8px; margin-top: 8px;">
<label id="vodTemplateLabel" style="font-size: 13px; color: var(--text-secondary);">VOD Template</label>
<input type="text" id="vodFilenameTemplate" placeholder="{title}.mp4" style="font-family: monospace;">
@ -382,7 +428,7 @@
<div class="settings-card">
<h3 id="updateTitle">Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.0.5</p>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.0.6</p>
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
@ -414,7 +460,7 @@
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span>
</div>
<span id="versionText">v4.0.5</span>
<span id="versionText">v4.0.6</span>
</div>
</main>
</div>

View File

@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
// ==========================================
// CONFIG & CONSTANTS
// ==========================================
const APP_VERSION = '4.0.5';
const APP_VERSION = '4.0.6';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths

View File

@ -48,6 +48,23 @@ const UI_TEXT_DE = {
vodTemplatePlaceholder: '{title}.mp4',
partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4',
defaultClipTemplatePlaceholder: '{date}_{part}.mp4',
templateGuideButton: 'Template Guide',
templateGuideTitle: 'Dateinamen-Template Guide',
templateGuideIntro: 'Nutze Platzhalter fur Dateinamen und teste dein Muster mit einer Live-Vorschau.',
templateGuideTemplateLabel: 'Template',
templateGuideOutputLabel: 'Live-Vorschau',
templateGuideVarsTitle: 'Verfugbare Platzhalter',
templateGuideVarCol: 'Platzhalter',
templateGuideDescCol: 'Beschreibung',
templateGuideExampleCol: 'Beispiel',
templateGuideUseVod: 'VOD-Template nutzen',
templateGuideUseParts: 'Teile-Template nutzen',
templateGuideUseClip: 'Clip-Template nutzen',
templateGuideClose: 'Schliessen',
templateGuideContextVod: 'Kontext: Beispiel fur kompletten VOD-Download',
templateGuideContextParts: 'Kontext: Beispiel fur VOD-Teil',
templateGuideContextClip: 'Kontext: Beispiel fur Clip-Zuschnitt',
templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog',
updateTitle: 'Updates',
checkUpdates: 'Nach Updates suchen',
preflightTitle: 'System-Check',

View File

@ -48,6 +48,23 @@ const UI_TEXT_EN = {
vodTemplatePlaceholder: '{title}.mp4',
partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4',
defaultClipTemplatePlaceholder: '{date}_{part}.mp4',
templateGuideButton: 'Template Guide',
templateGuideTitle: 'Filename Template Guide',
templateGuideIntro: 'Use placeholders for filenames and test your pattern with a live preview.',
templateGuideTemplateLabel: 'Template',
templateGuideOutputLabel: 'Live preview',
templateGuideVarsTitle: 'Available placeholders',
templateGuideVarCol: 'Placeholder',
templateGuideDescCol: 'Description',
templateGuideExampleCol: 'Example',
templateGuideUseVod: 'Use VOD template',
templateGuideUseParts: 'Use part template',
templateGuideUseClip: 'Use clip template',
templateGuideClose: 'Close',
templateGuideContextVod: 'Context: Sample full VOD download',
templateGuideContextParts: 'Context: Sample split VOD part',
templateGuideContextClip: 'Context: Sample clip trim',
templateGuideContextClipLive: 'Context: Current clip dialog selection',
updateTitle: 'Updates',
checkUpdates: 'Check for updates',
preflightTitle: 'System Check',

View File

@ -87,6 +87,21 @@ function applyLanguageToStaticUI(): void {
setText('partsTemplateLabel', UI_TEXT.static.partsTemplateLabel);
setText('defaultClipTemplateLabel', UI_TEXT.static.defaultClipTemplateLabel);
setText('filenameTemplateHint', UI_TEXT.static.filenameTemplateHint);
setText('settingsTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
setText('clipTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
setText('templateGuideTitle', UI_TEXT.static.templateGuideTitle);
setText('templateGuideIntro', UI_TEXT.static.templateGuideIntro);
setText('templateGuideTemplateLabel', UI_TEXT.static.templateGuideTemplateLabel);
setText('templateGuideOutputLabel', UI_TEXT.static.templateGuideOutputLabel);
setText('templateGuideVarsTitle', UI_TEXT.static.templateGuideVarsTitle);
setText('templateGuideVarCol', UI_TEXT.static.templateGuideVarCol);
setText('templateGuideDescCol', UI_TEXT.static.templateGuideDescCol);
setText('templateGuideExampleCol', UI_TEXT.static.templateGuideExampleCol);
setText('templateGuideUseVod', UI_TEXT.static.templateGuideUseVod);
setText('templateGuideUseParts', UI_TEXT.static.templateGuideUseParts);
setText('templateGuideUseClip', UI_TEXT.static.templateGuideUseClip);
setText('templateGuideCloseBtn', UI_TEXT.static.templateGuideClose);
setPlaceholder('templateGuideInput', UI_TEXT.static.vodTemplatePlaceholder);
setPlaceholder('vodFilenameTemplate', UI_TEXT.static.vodTemplatePlaceholder);
setPlaceholder('partsFilenameTemplate', UI_TEXT.static.partsTemplatePlaceholder);
setPlaceholder('defaultClipFilenameTemplate', UI_TEXT.static.defaultClipTemplatePlaceholder);
@ -107,6 +122,11 @@ function applyLanguageToStaticUI(): void {
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {
setText('statusText', UI_TEXT.static.notConnected);
}
const guideRefresh = (window as unknown as { refreshTemplateGuideTexts?: () => void }).refreshTemplateGuideTexts;
if (typeof guideRefresh === 'function') {
guideRefresh();
}
}
function localizeCurrentStatusText(current: string): string {

View File

@ -181,6 +181,10 @@ const DEFAULT_VOD_TEMPLATE = '{title}.mp4';
const DEFAULT_PARTS_TEMPLATE = '{date}_Part{part_padded}.mp4';
const DEFAULT_CLIP_TEMPLATE = '{date}_{part}.mp4';
type TemplateGuideSource = 'vod' | 'parts' | 'clip';
let templateGuideSource: TemplateGuideSource = 'vod';
function formatDateWithPattern(date: Date, pattern: string): string {
const tokenMap: Record<string, string> = {
yyyy: date.getFullYear().toString(),
@ -237,7 +241,7 @@ function updateFilenameTemplateVisibility(): void {
wrap.style.display = selected === 'template' ? 'block' : 'none';
}
function buildTemplatePreview(template: string, context: {
interface TemplatePreviewContext {
title: string;
date: Date;
streamer: string;
@ -245,7 +249,9 @@ function buildTemplatePreview(template: string, context: {
startSec: number;
durationSec: number;
totalSec: number;
}): string {
}
function buildTemplatePreview(template: string, context: TemplatePreviewContext): string {
const dateStr = `${context.date.getDate().toString().padStart(2, '0')}.${(context.date.getMonth() + 1).toString().padStart(2, '0')}.${context.date.getFullYear()}`;
const normalizedPart = context.partNum || '1';
let output = template
@ -272,6 +278,211 @@ function buildTemplatePreview(template: string, context: {
return output;
}
function getTemplateForSource(source: TemplateGuideSource): string {
if (source === 'vod') {
return ((config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE).trim() || DEFAULT_VOD_TEMPLATE;
}
if (source === 'parts') {
return ((config.filename_template_parts as string) || DEFAULT_PARTS_TEMPLATE).trim() || DEFAULT_PARTS_TEMPLATE;
}
const clipField = document.getElementById('clipFilenameTemplate') as HTMLInputElement | null;
const clipFromDialog = clipField?.value.trim() || '';
if (clipFromDialog) {
return clipFromDialog;
}
return ((config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE).trim() || DEFAULT_CLIP_TEMPLATE;
}
function getTemplateGuidePreviewContext(source: TemplateGuideSource): { context: TemplatePreviewContext; contextText: string } {
const now = new Date();
const sampleDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 15, 8);
const sampleStreamer = currentStreamer || 'sample_streamer';
if (source === 'clip' && clipDialogData) {
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
const clipDuration = Math.max(1, endSec - startSec);
const totalSec = Math.max(1, clipTotalSeconds || parseDurationToSeconds(clipDialogData.duration));
return {
context: {
title: clipDialogData.title || 'Clip Title',
date: new Date(clipDialogData.date),
streamer: clipDialogData.streamer || sampleStreamer,
partNum: byId<HTMLInputElement>('clipStartPart').value.trim() || '1',
startSec,
durationSec: clipDuration,
totalSec
},
contextText: UI_TEXT.static.templateGuideContextClipLive
};
}
if (source === 'parts') {
const partLen = Math.max(60, Number(config.part_minutes ?? 120) * 60);
return {
context: {
title: 'Epic Ranked Session',
date: sampleDate,
streamer: sampleStreamer,
partNum: '3',
startSec: partLen * 2,
durationSec: partLen,
totalSec: partLen * 5
},
contextText: UI_TEXT.static.templateGuideContextParts
};
}
if (source === 'clip') {
return {
context: {
title: 'Funny Clip Moment',
date: sampleDate,
streamer: sampleStreamer,
partNum: '1',
startSec: 95,
durationSec: 45,
totalSec: 5400
},
contextText: UI_TEXT.static.templateGuideContextClip
};
}
return {
context: {
title: 'Epic Ranked Session',
date: sampleDate,
streamer: sampleStreamer,
partNum: '1',
startSec: 0,
durationSec: 3 * 3600 + 12 * 60 + 5,
totalSec: 3 * 3600 + 12 * 60 + 5
},
contextText: UI_TEXT.static.templateGuideContextVod
};
}
interface TemplateVariableDoc {
placeholder: string;
description: string;
exampleTemplate: string;
}
function getTemplateVariableDocs(): TemplateVariableDoc[] {
const de = currentLanguage !== 'en';
const text = (deText: string, enText: string) => de ? deText : enText;
return [
{ placeholder: '{title}', description: text('Titel des VODs/Clips', 'Title of the VOD/clip'), exampleTemplate: '{title}' },
{ placeholder: '{id}', description: text('VOD-ID', 'VOD id'), exampleTemplate: '{id}' },
{ placeholder: '{channel}', description: text('Kanalname', 'Channel name'), exampleTemplate: '{channel}' },
{ placeholder: '{date}', description: text('Datum (DD.MM.YYYY)', 'Date (DD.MM.YYYY)'), exampleTemplate: '{date}' },
{ placeholder: '{part}', description: text('Teilnummer', 'Part number'), exampleTemplate: '{part}' },
{ placeholder: '{part_padded}', description: text('Teilnummer mit 2 Stellen', 'Part number padded to 2 digits'), exampleTemplate: '{part_padded}' },
{ placeholder: '{trim_start}', description: text('Startzeit des Ausschnitts', 'Trim start time'), exampleTemplate: '{trim_start}' },
{ placeholder: '{trim_end}', description: text('Endzeit des Ausschnitts', 'Trim end time'), exampleTemplate: '{trim_end}' },
{ placeholder: '{trim_length}', description: text('Lange des Ausschnitts', 'Trimmed duration'), exampleTemplate: '{trim_length}' },
{ placeholder: '{length}', description: text('Gesamtdauer', 'Total duration'), exampleTemplate: '{length}' },
{ placeholder: '{ext}', description: text('Dateiendung', 'File extension'), exampleTemplate: '{ext}' },
{ placeholder: '{random_string}', description: text('Zufallsstring (8 Zeichen)', 'Random string (8 chars)'), exampleTemplate: '{random_string}' },
{ placeholder: '{date_custom="yyyy-MM-dd"}', description: text('Datum mit eigenem Format', 'Custom-formatted date'), exampleTemplate: '{date_custom="yyyy-MM-dd"}' },
{ placeholder: '{trim_start_custom="HH-mm-ss"}', description: text('Startzeit mit eigenem Format', 'Custom-formatted trim start'), exampleTemplate: '{trim_start_custom="HH-mm-ss"}' },
{ placeholder: '{trim_end_custom="HH-mm-ss"}', description: text('Endzeit mit eigenem Format', 'Custom-formatted trim end'), exampleTemplate: '{trim_end_custom="HH-mm-ss"}' },
{ placeholder: '{trim_length_custom="HH-mm-ss"}', description: text('Trim-Dauer mit eigenem Format', 'Custom-formatted trim length'), exampleTemplate: '{trim_length_custom="HH-mm-ss"}' },
{ placeholder: '{length_custom="HH-mm-ss"}', description: text('Gesamtdauer mit eigenem Format', 'Custom-formatted total duration'), exampleTemplate: '{length_custom="HH-mm-ss"}' }
];
}
function renderTemplateGuideTable(context: TemplatePreviewContext): void {
const body = byId('templateGuideBody');
body.innerHTML = '';
for (const item of getTemplateVariableDocs()) {
const row = document.createElement('tr');
const varCell = document.createElement('td');
const descCell = document.createElement('td');
const exampleCell = document.createElement('td');
varCell.textContent = item.placeholder;
descCell.textContent = item.description;
exampleCell.textContent = buildTemplatePreview(item.exampleTemplate, context);
row.append(varCell, descCell, exampleCell);
body.appendChild(row);
}
}
function updateTemplateGuidePresetButtons(): void {
const activeId: Record<TemplateGuideSource, string> = {
vod: 'templateGuideUseVod',
parts: 'templateGuideUseParts',
clip: 'templateGuideUseClip'
};
(Object.keys(activeId) as TemplateGuideSource[]).forEach((key) => {
const btn = byId<HTMLButtonElement>(activeId[key]);
btn.classList.toggle('active', key === templateGuideSource);
});
}
function refreshTemplateGuideTexts(): void {
setText('settingsTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
setText('clipTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
setText('templateGuideTitle', UI_TEXT.static.templateGuideTitle);
setText('templateGuideIntro', UI_TEXT.static.templateGuideIntro);
setText('templateGuideTemplateLabel', UI_TEXT.static.templateGuideTemplateLabel);
setText('templateGuideOutputLabel', UI_TEXT.static.templateGuideOutputLabel);
setText('templateGuideVarsTitle', UI_TEXT.static.templateGuideVarsTitle);
setText('templateGuideVarCol', UI_TEXT.static.templateGuideVarCol);
setText('templateGuideDescCol', UI_TEXT.static.templateGuideDescCol);
setText('templateGuideExampleCol', UI_TEXT.static.templateGuideExampleCol);
setText('templateGuideUseVod', UI_TEXT.static.templateGuideUseVod);
setText('templateGuideUseParts', UI_TEXT.static.templateGuideUseParts);
setText('templateGuideUseClip', UI_TEXT.static.templateGuideUseClip);
setText('templateGuideCloseBtn', UI_TEXT.static.templateGuideClose);
setPlaceholder('templateGuideInput', getTemplateForSource(templateGuideSource));
updateTemplateGuidePresetButtons();
const modal = document.getElementById('templateGuideModal');
if (modal?.classList.contains('show')) {
updateTemplateGuidePreview();
}
}
function openTemplateGuide(source: TemplateGuideSource = 'vod'): void {
templateGuideSource = source;
byId('templateGuideModal').classList.add('show');
refreshTemplateGuideTexts();
setTemplateGuidePreset(source);
}
function closeTemplateGuide(): void {
byId('templateGuideModal').classList.remove('show');
}
function setTemplateGuidePreset(source: TemplateGuideSource): void {
templateGuideSource = source;
const template = getTemplateForSource(source);
byId<HTMLInputElement>('templateGuideInput').value = template;
setPlaceholder('templateGuideInput', template);
updateTemplateGuidePresetButtons();
updateTemplateGuidePreview();
}
function updateTemplateGuidePreview(): void {
const input = byId<HTMLInputElement>('templateGuideInput');
const template = input.value.trim() || getTemplateForSource(templateGuideSource);
const { context, contextText } = getTemplateGuidePreviewContext(templateGuideSource);
byId('templateGuideOutput').textContent = buildTemplatePreview(template, context);
byId('templateGuideContext').textContent = contextText;
renderTemplateGuideTable(context);
}
function parseTimeToSeconds(timeStr: string): number {
const parts = timeStr.split(':').map((p: string) => parseInt(p, 10) || 0);
if (parts.length === 3) {
@ -378,6 +589,11 @@ function updateFilenameExamples(): void {
durationSec,
totalSec: clipTotalSeconds
})} ${UI_TEXT.clips.formatTemplate}`;
const guideModal = document.getElementById('templateGuideModal');
if (guideModal?.classList.contains('show') && templateGuideSource === 'clip') {
updateTemplateGuidePreview();
}
}
async function confirmClipDialog(): Promise<void> {

View File

@ -1268,3 +1268,114 @@ body.theme-apple {
.modal-actions button {
flex: 1;
}
.template-guide-modal {
max-width: 860px;
}
.template-guide-intro {
color: var(--text-secondary);
margin-bottom: 14px;
line-height: 1.5;
}
.template-guide-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.template-guide-actions .btn-secondary {
padding: 8px 12px;
min-width: 140px;
}
.template-guide-actions .btn-secondary.active {
background: var(--accent);
color: #fff;
border-color: transparent;
}
.template-guide-label {
display: block;
margin-bottom: 6px;
font-size: 13px;
color: var(--text-secondary);
}
.template-guide-input {
width: 100%;
font-family: Consolas, "Courier New", monospace;
margin-bottom: 10px;
}
.template-guide-preview-box {
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 10px;
margin-bottom: 14px;
}
.template-guide-preview-label {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.template-guide-output {
font-family: Consolas, "Courier New", monospace;
color: var(--text);
word-break: break-word;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
padding: 8px;
}
.template-guide-context {
margin-top: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.template-guide-vars-title {
margin: 0 0 8px;
font-size: 14px;
}
.template-guide-table-wrap {
max-height: 280px;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
margin-bottom: 12px;
}
.template-guide-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.template-guide-table th,
.template-guide-table td {
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding: 8px;
vertical-align: top;
}
.template-guide-table tbody tr:last-child td {
border-bottom: 0;
}
.template-guide-table td:first-child,
.template-guide-table td:last-child {
font-family: Consolas, "Courier New", monospace;
}
.template-guide-footer {
display: flex;
justify-content: flex-end;
}