diff --git a/typescript-version/package-lock.json b/typescript-version/package-lock.json index 2856bb4..cec5f88 100644 --- a/typescript-version/package-lock.json +++ b/typescript-version/package-lock.json @@ -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", diff --git a/typescript-version/package.json b/typescript-version/package.json index 04cb9b8..be59de4 100644 --- a/typescript-version/package.json +++ b/typescript-version/package.json @@ -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", diff --git a/typescript-version/src/index.html b/typescript-version/src/index.html index 2682026..e4b4e61 100644 --- a/typescript-version/src/index.html +++ b/typescript-version/src/index.html @@ -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()">
Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}
+ @@ -102,6 +103,48 @@ + + +
diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts index 6a9b02c..e94af7b 100644 --- a/typescript-version/src/main.ts +++ b/typescript-version/src/main.ts @@ -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 diff --git a/typescript-version/src/renderer-locale-de.ts b/typescript-version/src/renderer-locale-de.ts index 69e3739..377f85c 100644 --- a/typescript-version/src/renderer-locale-de.ts +++ b/typescript-version/src/renderer-locale-de.ts @@ -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', diff --git a/typescript-version/src/renderer-locale-en.ts b/typescript-version/src/renderer-locale-en.ts index 1b9e2be..535d6e7 100644 --- a/typescript-version/src/renderer-locale-en.ts +++ b/typescript-version/src/renderer-locale-en.ts @@ -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', diff --git a/typescript-version/src/renderer-texts.ts b/typescript-version/src/renderer-texts.ts index 69c3562..2e31770 100644 --- a/typescript-version/src/renderer-texts.ts +++ b/typescript-version/src/renderer-texts.ts @@ -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 { diff --git a/typescript-version/src/renderer.ts b/typescript-version/src/renderer.ts index 54631d8..8d3f689 100644 --- a/typescript-version/src/renderer.ts +++ b/typescript-version/src/renderer.ts @@ -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 = { 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('clipStartTime').value); + const endSec = parseTimeToSeconds(byId('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('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 = { + vod: 'templateGuideUseVod', + parts: 'templateGuideUseParts', + clip: 'templateGuideUseClip' + }; + + (Object.keys(activeId) as TemplateGuideSource[]).forEach((key) => { + const btn = byId(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('templateGuideInput').value = template; + setPlaceholder('templateGuideInput', template); + updateTemplateGuidePresetButtons(); + updateTemplateGuidePreview(); +} + +function updateTemplateGuidePreview(): void { + const input = byId('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 { diff --git a/typescript-version/src/styles.css b/typescript-version/src/styles.css index 373eeba..d759082 100644 --- a/typescript-version/src/styles.css +++ b/typescript-version/src/styles.css @@ -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; +}