From 2b935f4a3ae07ef4e41911ef81dc5c470e2d2ee5 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Wed, 4 Feb 2026 14:04:20 +0100 Subject: [PATCH] Add Clip erstellen feature for time-range VOD downloads (v3.6.1) New feature: - Clip erstellen button on VOD cards - Modal dialog with start/end time sliders - Time input fields for precise selection - Custom clip support with part numbering - Downloads only selected time range using streamlink --hls-start-offset/duration Technical changes: - Added CustomClip interface to QueueItem - Updated downloadVOD to handle custom clips - New clip dialog UI with sliders and inputs - Queue shows clip items with * prefix Co-Authored-By: Claude Opus 4.5 --- typescript-version/package.json | 2 +- typescript-version/src/index.html | 316 +++++++++++++++++++++++++++++- typescript-version/src/main.ts | 63 +++++- typescript-version/src/preload.ts | 7 + 4 files changed, 375 insertions(+), 13 deletions(-) diff --git a/typescript-version/package.json b/typescript-version/package.json index 30fe7ae..dd85a9b 100644 --- a/typescript-version/package.json +++ b/typescript-version/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "3.6.0", + "version": "3.6.1", "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 2fbbdc9..5dc5a77 100644 --- a/typescript-version/src/index.html +++ b/typescript-version/src/index.html @@ -899,6 +899,146 @@ --accent: #0A84FF; --accent-hover: #0071e3; } + + /* Modal Styles */ + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: none; + justify-content: center; + align-items: center; + z-index: 1000; + } + + .modal-overlay.show { + display: flex; + } + + .modal { + background: var(--bg-card); + border-radius: 12px; + padding: 25px; + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + } + + .modal h2 { + margin-bottom: 20px; + font-size: 18px; + } + + .modal-close { + float: right; + background: none; + border: none; + color: var(--text-secondary); + font-size: 24px; + cursor: pointer; + padding: 0; + line-height: 1; + } + + .modal-close:hover { + color: var(--text); + } + + .slider-group { + margin-bottom: 20px; + } + + .slider-group label { + display: block; + margin-bottom: 8px; + color: var(--text-secondary); + font-size: 13px; + } + + .slider-group input[type="range"] { + width: 100%; + height: 6px; + -webkit-appearance: none; + background: var(--bg-main); + border-radius: 3px; + outline: none; + } + + .slider-group input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; + } + + .clip-time-display { + display: flex; + justify-content: space-between; + margin-top: 8px; + font-family: monospace; + font-size: 14px; + } + + .clip-info-row { + background: var(--bg-main); + padding: 12px 15px; + border-radius: 6px; + margin-bottom: 15px; + text-align: center; + } + + .clip-info-row .label { + color: var(--text-secondary); + font-size: 12px; + margin-bottom: 4px; + } + + .clip-info-row .value { + font-size: 18px; + font-weight: 600; + color: var(--success); + } + + .clip-info-row .value.error { + color: var(--error); + } + + .part-number-group { + margin-bottom: 20px; + } + + .part-number-group input { + width: 100px; + background: var(--bg-main); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 8px 12px; + color: var(--text); + font-size: 14px; + } + + .part-number-group small { + display: block; + margin-top: 5px; + color: var(--text-secondary); + font-size: 11px; + } + + .modal-actions { + display: flex; + gap: 10px; + margin-top: 20px; + } + + .modal-actions button { + flex: 1; + } @@ -907,6 +1047,47 @@ + + +
- v3.6.0 + v3.6.1 @@ -1360,6 +1541,7 @@ grid.innerHTML = vods.map(vod => { const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180'); const date = new Date(vod.created_at).toLocaleDateString('de-DE'); + const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/"/g, """); return `
@@ -1372,7 +1554,8 @@
- + +
`; @@ -1410,13 +1593,16 @@ return; } - list.innerHTML = queue.map(item => ` -
-
-
${item.title}
- x -
- `).join(''); + list.innerHTML = queue.map(item => { + const isClip = item.customClip ? '* ' : ''; + return ` +
+
+
${isClip}${item.title}
+ x +
+ `; + }).join(''); } async function toggleDownload() { @@ -1427,6 +1613,116 @@ } } + // Clip Dialog + let clipDialogData = null; + let clipTotalSeconds = 0; + + function parseDurationToSeconds(durStr) { + let seconds = 0; + const hours = durStr.match(/(\d+)h/); + const minutes = durStr.match(/(\d+)m/); + const secs = durStr.match(/(\d+)s/); + if (hours) seconds += parseInt(hours[1]) * 3600; + if (minutes) seconds += parseInt(minutes[1]) * 60; + if (secs) seconds += parseInt(secs[1]); + return seconds; + } + + function formatSecondsToTime(seconds) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } + + function parseTimeToSeconds(timeStr) { + const parts = timeStr.split(':').map(p => parseInt(p) || 0); + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + return 0; + } + + function openClipDialog(url, title, date, streamer, duration) { + clipDialogData = { url, title, date, streamer, duration }; + clipTotalSeconds = parseDurationToSeconds(duration); + + document.getElementById('clipDialogTitle').textContent = title.substring(0, 50) + (title.length > 50 ? '...' : '') + ' (' + duration + ')'; + document.getElementById('clipStartSlider').max = clipTotalSeconds; + document.getElementById('clipEndSlider').max = clipTotalSeconds; + document.getElementById('clipStartSlider').value = 0; + document.getElementById('clipEndSlider').value = Math.min(3600, clipTotalSeconds); // Default 1 hour or max + document.getElementById('clipStartPart').value = 1; + + updateClipSliders(); + document.getElementById('clipModal').classList.add('show'); + } + + function closeClipDialog() { + document.getElementById('clipModal').classList.remove('show'); + clipDialogData = null; + } + + function updateClipSliders() { + const startSec = parseInt(document.getElementById('clipStartSlider').value); + const endSec = parseInt(document.getElementById('clipEndSlider').value); + + document.getElementById('clipStartTime').value = formatSecondsToTime(startSec); + document.getElementById('clipEndTime').value = formatSecondsToTime(endSec); + + const duration = endSec - startSec; + const durationDisplay = document.getElementById('clipDurationDisplay'); + + if (duration > 0) { + durationDisplay.textContent = formatSecondsToTime(duration); + durationDisplay.classList.remove('error'); + } else { + durationDisplay.textContent = 'Ungultig'; + durationDisplay.classList.add('error'); + } + } + + function updateClipFromInput() { + const startSec = parseTimeToSeconds(document.getElementById('clipStartTime').value); + const endSec = parseTimeToSeconds(document.getElementById('clipEndTime').value); + + document.getElementById('clipStartSlider').value = Math.max(0, Math.min(startSec, clipTotalSeconds)); + document.getElementById('clipEndSlider').value = Math.max(0, Math.min(endSec, clipTotalSeconds)); + + updateClipSliders(); + } + + async function confirmClipDialog() { + if (!clipDialogData) return; + + const startSec = parseInt(document.getElementById('clipStartSlider').value); + const endSec = parseInt(document.getElementById('clipEndSlider').value); + const startPart = parseInt(document.getElementById('clipStartPart').value) || 1; + + if (endSec <= startSec) { + alert('Endzeit muss grosser als Startzeit sein!'); + return; + } + + const durationSec = endSec - startSec; + + queue = await window.api.addToQueue({ + url: clipDialogData.url, + title: clipDialogData.title, + date: clipDialogData.date, + streamer: clipDialogData.streamer, + duration_str: clipDialogData.duration, + customClip: { + startSec: startSec, + durationSec: durationSec, + startPart: startPart + } + }); + + renderQueue(); + closeClipDialog(); + } + // Settings async function saveSettings() { const clientId = document.getElementById('clientId').value; diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts index 8c1625c..dff0d48 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 = '3.6.0'; +const APP_VERSION = '3.6.1'; const UPDATE_CHECK_URL = 'http://24-music.de/version.json'; // Paths @@ -51,6 +51,12 @@ interface VOD { stream_id: string; } +interface CustomClip { + startSec: number; + durationSec: number; + startPart: number; +} + interface QueueItem { id: string; title: string; @@ -66,6 +72,7 @@ interface QueueItem { eta?: string; downloadedBytes?: number; totalBytes?: number; + customClip?: CustomClip; } interface DownloadProgress { @@ -669,6 +676,58 @@ async function downloadVOD( const safeTitle = item.title.replace(/[^a-zA-Z0-9_\- ]/g, '').substring(0, 50); const totalDuration = parseDuration(item.duration_str); + // Custom Clip - download specific time range + if (item.customClip) { + const clip = item.customClip; + const partDuration = config.part_minutes * 60; + + // If clip is longer than part duration, split into parts + if (clip.durationSec > partDuration) { + const numParts = Math.ceil(clip.durationSec / partDuration); + const downloadedFiles: string[] = []; + + for (let i = 0; i < numParts; i++) { + if (currentDownloadCancelled) break; + + const partNum = clip.startPart + i; + const startOffset = clip.startSec + (i * partDuration); + const remainingDuration = clip.durationSec - (i * partDuration); + const thisDuration = Math.min(partDuration, remainingDuration); + + const partFilename = path.join(folder, `${dateStr}_Part${partNum.toString().padStart(2, '0')}.mp4`); + + const success = await downloadVODPart( + item.url, + partFilename, + formatDuration(startOffset), + formatDuration(thisDuration), + onProgress, + item.id, + i + 1, + numParts + ); + + if (!success) return false; + downloadedFiles.push(partFilename); + } + + return downloadedFiles.length === numParts; + } else { + // Single clip file + const filename = path.join(folder, `${dateStr}_Part${clip.startPart.toString().padStart(2, '0')}.mp4`); + return await downloadVODPart( + item.url, + filename, + formatDuration(clip.startSec), + formatDuration(clip.durationSec), + onProgress, + item.id, + 1, + 1 + ); + } + } + // Check download mode if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) { // Full download @@ -687,7 +746,7 @@ async function downloadVOD( const endSec = Math.min((i + 1) * partDuration, totalDuration); const duration = endSec - startSec; - const partFilename = path.join(folder, `${safeTitle}_Part${(i + 1).toString().padStart(2, '0')}.mp4`); + const partFilename = path.join(folder, `${dateStr}_Part${(i + 1).toString().padStart(2, '0')}.mp4`); const success = await downloadVODPart( item.url, diff --git a/typescript-version/src/preload.ts b/typescript-version/src/preload.ts index 5420e66..8f717eb 100644 --- a/typescript-version/src/preload.ts +++ b/typescript-version/src/preload.ts @@ -1,6 +1,12 @@ import { contextBridge, ipcRenderer } from 'electron'; // Types +interface CustomClip { + startSec: number; + durationSec: number; + startPart: number; +} + interface QueueItem { id: string; title: string; @@ -14,6 +20,7 @@ interface QueueItem { totalParts?: number; speed?: string; eta?: string; + customClip?: CustomClip; } interface DownloadProgress {