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 <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-02-04 14:04:20 +01:00
parent c9c28380c6
commit 2b935f4a3a
4 changed files with 375 additions and 13 deletions

View File

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

View File

@ -899,6 +899,146 @@
--accent: #0A84FF; --accent: #0A84FF;
--accent-hover: #0071e3; --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;
}
</style> </style>
</head> </head>
<body class="theme-twitch"> <body class="theme-twitch">
@ -907,6 +1047,47 @@
<button onclick="downloadUpdate()">Jetzt aktualisieren</button> <button onclick="downloadUpdate()">Jetzt aktualisieren</button>
</div> </div>
<!-- Clip Dialog Modal -->
<div class="modal-overlay" id="clipModal">
<div class="modal">
<button class="modal-close" onclick="closeClipDialog()">x</button>
<h2>Clip erstellen</h2>
<p style="color: var(--text-secondary); margin-bottom: 15px;" id="clipDialogTitle"></p>
<div class="slider-group">
<label>Startzeit</label>
<input type="range" id="clipStartSlider" min="0" max="100" value="0" oninput="updateClipSliders()">
<div class="clip-time-display">
<input type="text" id="clipStartTime" value="00:00:00" style="width: 80px; background: var(--bg-main); border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 4px 8px; color: var(--text); text-align: center; font-family: monospace;" onchange="updateClipFromInput()">
</div>
</div>
<div class="slider-group">
<label>Endzeit</label>
<input type="range" id="clipEndSlider" min="0" max="100" value="100" oninput="updateClipSliders()">
<div class="clip-time-display">
<input type="text" id="clipEndTime" value="00:00:00" style="width: 80px; background: var(--bg-main); border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 4px 8px; color: var(--text); text-align: center; font-family: monospace;" onchange="updateClipFromInput()">
</div>
</div>
<div class="clip-info-row">
<div class="label">Clip Dauer</div>
<div class="value" id="clipDurationDisplay">00:00:00</div>
</div>
<div class="part-number-group">
<label style="display: block; margin-bottom: 6px; color: var(--text-secondary); font-size: 13px;">Start Part-Nummer (optional)</label>
<input type="number" id="clipStartPart" value="1" min="1">
<small>Fur Fortsetzung eines Downloads</small>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick="closeClipDialog()">Abbrechen</button>
<button class="btn-primary" id="clipConfirmBtn" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
</div>
</div>
</div>
<div class="app"> <div class="app">
<aside class="sidebar"> <aside class="sidebar">
<div class="logo"> <div class="logo">
@ -1153,7 +1334,7 @@
<div class="settings-card"> <div class="settings-card">
<h3>Updates</h3> <h3>Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v3.6.0</p> <p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v3.6.1</p>
<button class="btn-secondary" onclick="checkUpdate()">Nach Updates suchen</button> <button class="btn-secondary" onclick="checkUpdate()">Nach Updates suchen</button>
</div> </div>
</div> </div>
@ -1164,7 +1345,7 @@
<div class="status-dot" id="statusDot"></div> <div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span> <span id="statusText">Nicht verbunden</span>
</div> </div>
<span id="versionText">v3.6.0</span> <span id="versionText">v3.6.1</span>
</div> </div>
</main> </main>
</div> </div>
@ -1360,6 +1541,7 @@
grid.innerHTML = vods.map(vod => { grid.innerHTML = vods.map(vod => {
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180'); const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
const date = new Date(vod.created_at).toLocaleDateString('de-DE'); const date = new Date(vod.created_at).toLocaleDateString('de-DE');
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/"/g, "&quot;");
return ` return `
<div class="vod-card"> <div class="vod-card">
<img class="vod-thumbnail" src="${thumb}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'"> <img class="vod-thumbnail" src="${thumb}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'">
@ -1372,7 +1554,8 @@
</div> </div>
</div> </div>
<div class="vod-actions"> <div class="vod-actions">
<button class="vod-btn primary" onclick="addToQueue('${vod.url}', '${vod.title.replace(/'/g, "\\'")}', '${vod.created_at}', '${streamer}', '${vod.duration}')">+ Warteschlange</button> <button class="vod-btn secondary" onclick="openClipDialog('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">Clip</button>
<button class="vod-btn primary" onclick="addToQueue('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">+ Queue</button>
</div> </div>
</div> </div>
`; `;
@ -1410,13 +1593,16 @@
return; return;
} }
list.innerHTML = queue.map(item => ` list.innerHTML = queue.map(item => {
<div class="queue-item"> const isClip = item.customClip ? '* ' : '';
<div class="status ${item.status}"></div> return `
<div class="title" title="${item.title}">${item.title}</div> <div class="queue-item">
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span> <div class="status ${item.status}"></div>
</div> <div class="title" title="${item.title}">${isClip}${item.title}</div>
`).join(''); <span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
</div>
`;
}).join('');
} }
async function toggleDownload() { 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 // Settings
async function saveSettings() { async function saveSettings() {
const clientId = document.getElementById('clientId').value; const clientId = document.getElementById('clientId').value;

View File

@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
// ========================================== // ==========================================
// CONFIG & CONSTANTS // CONFIG & CONSTANTS
// ========================================== // ==========================================
const APP_VERSION = '3.6.0'; const APP_VERSION = '3.6.1';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json'; const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths // Paths
@ -51,6 +51,12 @@ interface VOD {
stream_id: string; stream_id: string;
} }
interface CustomClip {
startSec: number;
durationSec: number;
startPart: number;
}
interface QueueItem { interface QueueItem {
id: string; id: string;
title: string; title: string;
@ -66,6 +72,7 @@ interface QueueItem {
eta?: string; eta?: string;
downloadedBytes?: number; downloadedBytes?: number;
totalBytes?: number; totalBytes?: number;
customClip?: CustomClip;
} }
interface DownloadProgress { interface DownloadProgress {
@ -669,6 +676,58 @@ async function downloadVOD(
const safeTitle = item.title.replace(/[^a-zA-Z0-9_\- ]/g, '').substring(0, 50); const safeTitle = item.title.replace(/[^a-zA-Z0-9_\- ]/g, '').substring(0, 50);
const totalDuration = parseDuration(item.duration_str); 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 // Check download mode
if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) { if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) {
// Full download // Full download
@ -687,7 +746,7 @@ async function downloadVOD(
const endSec = Math.min((i + 1) * partDuration, totalDuration); const endSec = Math.min((i + 1) * partDuration, totalDuration);
const duration = endSec - startSec; 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( const success = await downloadVODPart(
item.url, item.url,

View File

@ -1,6 +1,12 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
// Types // Types
interface CustomClip {
startSec: number;
durationSec: number;
startPart: number;
}
interface QueueItem { interface QueueItem {
id: string; id: string;
title: string; title: string;
@ -14,6 +20,7 @@ interface QueueItem {
totalParts?: number; totalParts?: number;
speed?: string; speed?: string;
eta?: string; eta?: string;
customClip?: CustomClip;
} }
interface DownloadProgress { interface DownloadProgress {