Add Video Cutter, Merge, and Part-based Download features (v3.6.0)
New features: - Video Cutter with timeline UI, preview, and frame extraction - Video Merge functionality to combine multiple videos - Part-based VOD downloads (split long VODs into segments) - Download progress with speed and ETA display - New navigation tabs for Cutter and Merge Technical changes: - Added FFmpeg/FFprobe integration for video processing - New IPC handlers for video operations - Extended preload API with cutter/merge methods - Progress event listeners for cut/merge operations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
31482d8a38
commit
c9c28380c6
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "3.5.3",
|
"version": "3.6.0",
|
||||||
"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",
|
||||||
|
|||||||
@ -496,6 +496,11 @@
|
|||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@ -626,6 +631,250 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Video Cutter Styles */
|
||||||
|
.cutter-container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview {
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview .placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
height: 60px;
|
||||||
|
background: var(--bg-main);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 15px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-selection {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(145, 71, 255, 0.3);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-right: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
width: 12px;
|
||||||
|
height: 70px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-handle.start { left: 0; transform: translateX(-50%); }
|
||||||
|
.timeline-handle.end { right: 0; transform: translateX(50%); }
|
||||||
|
|
||||||
|
.timeline-current {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--success);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-inputs {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-input-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-input-group label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-input-group input {
|
||||||
|
width: 100px;
|
||||||
|
background: var(--bg-main);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: center;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cutter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cutter-info {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cutter-info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cutter-info-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cutter-info-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Merge Styles */
|
||||||
|
.merge-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
padding: 12px 15px;
|
||||||
|
background: var(--bg-main);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .file-order {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .file-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .file-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .file-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .file-btn.remove:hover {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.progress-container {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-main);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Theme variations */
|
/* Theme variations */
|
||||||
body.theme-discord {
|
body.theme-discord {
|
||||||
--bg-main: #36393f;
|
--bg-main: #36393f;
|
||||||
@ -654,7 +903,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="theme-twitch">
|
<body class="theme-twitch">
|
||||||
<div class="update-banner" id="updateBanner">
|
<div class="update-banner" id="updateBanner">
|
||||||
<span id="updateText">Neue Version verfügbar!</span>
|
<span id="updateText">Neue Version verfugbar!</span>
|
||||||
<button onclick="downloadUpdate()">Jetzt aktualisieren</button>
|
<button onclick="downloadUpdate()">Jetzt aktualisieren</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -674,6 +923,14 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||||
Twitch Clips
|
Twitch Clips
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item" data-tab="cutter" onclick="showTab('cutter')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg>
|
||||||
|
Video Cutter
|
||||||
|
</div>
|
||||||
|
<div class="nav-item" data-tab="merge" onclick="showTab('merge')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg>
|
||||||
|
Videos Zusammenfugen
|
||||||
|
</div>
|
||||||
<div class="nav-item" data-tab="settings" onclick="showTab('settings')">
|
<div class="nav-item" data-tab="settings" onclick="showTab('settings')">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||||
Einstellungen
|
Einstellungen
|
||||||
@ -683,7 +940,7 @@
|
|||||||
<div class="section-title">Streamer</div>
|
<div class="section-title">Streamer</div>
|
||||||
<div class="streamers" id="streamerList"></div>
|
<div class="streamers" id="streamerList"></div>
|
||||||
<div class="add-streamer">
|
<div class="add-streamer">
|
||||||
<input type="text" id="newStreamer" placeholder="Streamer hinzufügen..." onkeypress="if(event.key==='Enter')addStreamer()">
|
<input type="text" id="newStreamer" placeholder="Streamer hinzufugen..." onkeypress="if(event.key==='Enter')addStreamer()">
|
||||||
<button onclick="addStreamer()">+</button>
|
<button onclick="addStreamer()">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -694,8 +951,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="queue-list" id="queueList"></div>
|
<div class="queue-list" id="queueList"></div>
|
||||||
<div class="queue-actions">
|
<div class="queue-actions">
|
||||||
<button class="btn btn-start" id="btnStart" onclick="toggleDownload()">▶ Start</button>
|
<button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
|
||||||
<button class="btn btn-clear" onclick="clearCompleted()">🗑 Leeren</button>
|
<button class="btn btn-clear" onclick="clearCompleted()">Leeren</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -718,7 +975,7 @@
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
|
||||||
<h3>Keine VODs</h3>
|
<h3>Keine VODs</h3>
|
||||||
<p>Wähle einen Streamer aus der Liste oder füge einen neuen hinzu.</p>
|
<p>Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -728,25 +985,125 @@
|
|||||||
<div class="clip-input">
|
<div class="clip-input">
|
||||||
<h2>Twitch Clip Downloader</h2>
|
<h2>Twitch Clip Downloader</h2>
|
||||||
<input type="text" id="clipUrl" placeholder="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...">
|
<input type="text" id="clipUrl" placeholder="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...">
|
||||||
<button class="btn-primary" onclick="downloadClip()" id="btnClip">⬇ Clip herunterladen</button>
|
<button class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
|
||||||
<div class="clip-status" id="clipStatus"></div>
|
<div class="clip-status" id="clipStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card" style="max-width: 600px; margin: 20px auto;">
|
<div class="settings-card" style="max-width: 600px; margin: 20px auto;">
|
||||||
<h3>ℹ️ Info</h3>
|
<h3>Info</h3>
|
||||||
<p style="color: var(--text-secondary); line-height: 1.6;">
|
<p style="color: var(--text-secondary); line-height: 1.6;">
|
||||||
Unterstützte Formate:<br>
|
Unterstutzte Formate:<br>
|
||||||
• https://clips.twitch.tv/ClipName<br>
|
- https://clips.twitch.tv/ClipName<br>
|
||||||
• https://www.twitch.tv/streamer/clip/ClipName<br><br>
|
- https://www.twitch.tv/streamer/clip/ClipName<br><br>
|
||||||
Clips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.
|
Clips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Video Cutter Tab -->
|
||||||
|
<div class="tab-content" id="cutterTab">
|
||||||
|
<div class="cutter-container">
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3>Video auswahlen</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt...">
|
||||||
|
<button class="btn-secondary" onclick="selectCutterVideo()">Durchsuchen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="video-preview" id="cutterPreview">
|
||||||
|
<div class="placeholder">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||||
|
<p style="margin-top:10px">Video auswahlen um Vorschau zu sehen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cutter-info" id="cutterInfo" style="display:none">
|
||||||
|
<div class="cutter-info-item">
|
||||||
|
<span class="cutter-info-label">Dauer</span>
|
||||||
|
<span class="cutter-info-value" id="infoDuration">--:--:--</span>
|
||||||
|
</div>
|
||||||
|
<div class="cutter-info-item">
|
||||||
|
<span class="cutter-info-label">Auflosung</span>
|
||||||
|
<span class="cutter-info-value" id="infoResolution">----x----</span>
|
||||||
|
</div>
|
||||||
|
<div class="cutter-info-item">
|
||||||
|
<span class="cutter-info-label">FPS</span>
|
||||||
|
<span class="cutter-info-value" id="infoFps">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="cutter-info-item">
|
||||||
|
<span class="cutter-info-label">Auswahl</span>
|
||||||
|
<span class="cutter-info-value" id="infoSelection">--:--:--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline-container" id="timelineContainer" style="display:none">
|
||||||
|
<div class="timeline" id="timeline" onclick="seekTimeline(event)">
|
||||||
|
<div class="timeline-selection" id="timelineSelection"></div>
|
||||||
|
<div class="timeline-current" id="timelineCurrent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="time-inputs">
|
||||||
|
<div class="time-input-group">
|
||||||
|
<label>Start:</label>
|
||||||
|
<input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()">
|
||||||
|
</div>
|
||||||
|
<div class="time-input-group">
|
||||||
|
<label>Ende:</label>
|
||||||
|
<input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-container" id="cutProgress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-bar-fill" id="cutProgressBar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text" id="cutProgressText">0%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cutter-actions">
|
||||||
|
<button class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Merge Tab -->
|
||||||
|
<div class="tab-content" id="mergeTab">
|
||||||
|
<div class="merge-container">
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3>Videos zusammenfugen</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 15px;">
|
||||||
|
Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen.
|
||||||
|
Die Reihenfolge kann per Drag & Drop geandert werden.
|
||||||
|
</p>
|
||||||
|
<button class="btn-secondary" onclick="addMergeFiles()">+ Videos hinzufugen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-list" id="mergeFileList">
|
||||||
|
<div class="empty-state" style="padding: 40px 20px;">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||||
|
<p style="margin-top:10px">Keine Videos ausgewahlt</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-container" id="mergeProgress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-bar-fill" id="mergeProgressBar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text" id="mergeProgressText">0%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="merge-actions">
|
||||||
|
<button class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Settings Tab -->
|
||||||
<div class="tab-content" id="settingsTab">
|
<div class="tab-content" id="settingsTab">
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h3>🎨 Design</h3>
|
<h3>Design</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Theme</label>
|
<label>Theme</label>
|
||||||
<select id="themeSelect" onchange="changeTheme(this.value)">
|
<select id="themeSelect" onchange="changeTheme(this.value)">
|
||||||
@ -759,7 +1116,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h3>🔑 Twitch API</h3>
|
<h3>Twitch API</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Client ID</label>
|
<label>Client ID</label>
|
||||||
<input type="text" id="clientId" placeholder="Twitch Client ID">
|
<input type="text" id="clientId" placeholder="Twitch Client ID">
|
||||||
@ -772,13 +1129,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h3>📁 Download-Einstellungen</h3>
|
<h3>Download-Einstellungen</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Speicherort</label>
|
<label>Speicherort</label>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input type="text" id="downloadPath" readonly>
|
<input type="text" id="downloadPath" readonly>
|
||||||
<button class="btn-secondary" onclick="selectFolder()">📂</button>
|
<button class="btn-secondary" onclick="selectFolder()">Ordner</button>
|
||||||
<button class="btn-secondary" onclick="openFolder()">↗</button>
|
<button class="btn-secondary" onclick="openFolder()">Offnen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -789,14 +1146,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Teil-Länge (Minuten)</label>
|
<label>Teil-Lange (Minuten)</label>
|
||||||
<input type="number" id="partMinutes" value="120" min="10" max="480">
|
<input type="number" id="partMinutes" value="120" min="10" max="480">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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.5.3</p>
|
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v3.6.0</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>
|
||||||
@ -807,7 +1164,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.5.3</span>
|
<span id="versionText">v3.6.0</span>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@ -820,6 +1177,17 @@
|
|||||||
let downloading = false;
|
let downloading = false;
|
||||||
let queue = [];
|
let queue = [];
|
||||||
|
|
||||||
|
// Cutter State
|
||||||
|
let cutterFile = null;
|
||||||
|
let cutterVideoInfo = null;
|
||||||
|
let cutterStartTime = 0;
|
||||||
|
let cutterEndTime = 0;
|
||||||
|
let isCutting = false;
|
||||||
|
|
||||||
|
// Merge State
|
||||||
|
let mergeFiles = [];
|
||||||
|
let isMerging = false;
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
async function init() {
|
async function init() {
|
||||||
config = await window.api.getConfig();
|
config = await window.api.getConfig();
|
||||||
@ -850,7 +1218,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.api.onDownloadProgress((progress) => {
|
window.api.onDownloadProgress((progress) => {
|
||||||
// Update progress in queue
|
|
||||||
const item = queue.find(i => i.id === progress.id);
|
const item = queue.find(i => i.id === progress.id);
|
||||||
if (item) {
|
if (item) {
|
||||||
item.progress = progress.progress;
|
item.progress = progress.progress;
|
||||||
@ -860,17 +1227,26 @@
|
|||||||
|
|
||||||
window.api.onDownloadStarted(() => {
|
window.api.onDownloadStarted(() => {
|
||||||
downloading = true;
|
downloading = true;
|
||||||
document.getElementById('btnStart').textContent = '⏹ Stoppen';
|
document.getElementById('btnStart').textContent = 'Stoppen';
|
||||||
document.getElementById('btnStart').classList.add('downloading');
|
document.getElementById('btnStart').classList.add('downloading');
|
||||||
});
|
});
|
||||||
|
|
||||||
window.api.onDownloadFinished(() => {
|
window.api.onDownloadFinished(() => {
|
||||||
downloading = false;
|
downloading = false;
|
||||||
document.getElementById('btnStart').textContent = '▶ Start';
|
document.getElementById('btnStart').textContent = 'Start';
|
||||||
document.getElementById('btnStart').classList.remove('downloading');
|
document.getElementById('btnStart').classList.remove('downloading');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for updates
|
window.api.onCutProgress((percent) => {
|
||||||
|
document.getElementById('cutProgressBar').style.width = percent + '%';
|
||||||
|
document.getElementById('cutProgressText').textContent = Math.round(percent) + '%';
|
||||||
|
});
|
||||||
|
|
||||||
|
window.api.onMergeProgress((percent) => {
|
||||||
|
document.getElementById('mergeProgressBar').style.width = percent + '%';
|
||||||
|
document.getElementById('mergeProgressText').textContent = Math.round(percent) + '%';
|
||||||
|
});
|
||||||
|
|
||||||
setTimeout(checkUpdateSilent, 3000);
|
setTimeout(checkUpdateSilent, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -896,7 +1272,13 @@
|
|||||||
document.querySelector(`.nav-item[data-tab="${tab}"]`).classList.add('active');
|
document.querySelector(`.nav-item[data-tab="${tab}"]`).classList.add('active');
|
||||||
document.getElementById(tab + 'Tab').classList.add('active');
|
document.getElementById(tab + 'Tab').classList.add('active');
|
||||||
|
|
||||||
const titles = { vods: 'VODs', clips: 'Clips', settings: 'Einstellungen' };
|
const titles = {
|
||||||
|
vods: 'VODs',
|
||||||
|
clips: 'Clips',
|
||||||
|
cutter: 'Video Cutter',
|
||||||
|
merge: 'Videos Zusammenfugen',
|
||||||
|
settings: 'Einstellungen'
|
||||||
|
};
|
||||||
document.getElementById('pageTitle').textContent = currentStreamer || titles[tab];
|
document.getElementById('pageTitle').textContent = currentStreamer || titles[tab];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -910,7 +1292,7 @@
|
|||||||
item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : '');
|
item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : '');
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<span>${streamer}</span>
|
<span>${streamer}</span>
|
||||||
<span class="remove" onclick="event.stopPropagation(); removeStreamer('${streamer}')">✕</span>
|
<span class="remove" onclick="event.stopPropagation(); removeStreamer('${streamer}')">x</span>
|
||||||
`;
|
`;
|
||||||
item.onclick = () => selectStreamer(streamer);
|
item.onclick = () => selectStreamer(streamer);
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
@ -939,7 +1321,7 @@
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
|
||||||
<h3>Keine VODs</h3>
|
<h3>Keine VODs</h3>
|
||||||
<p>Wähle einen Streamer aus der Liste.</p>
|
<p>Wahle einen Streamer aus der Liste.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -1032,7 +1414,7 @@
|
|||||||
<div class="queue-item">
|
<div class="queue-item">
|
||||||
<div class="status ${item.status}"></div>
|
<div class="status ${item.status}"></div>
|
||||||
<div class="title" title="${item.title}">${item.title}</div>
|
<div class="title" title="${item.title}">${item.title}</div>
|
||||||
<span class="remove" onclick="removeFromQueue('${item.id}')">✕</span>
|
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
@ -1095,13 +1477,13 @@
|
|||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Lade...';
|
btn.textContent = 'Lade...';
|
||||||
status.textContent = 'Download läuft...';
|
status.textContent = 'Download lauft...';
|
||||||
status.className = 'clip-status loading';
|
status.className = 'clip-status loading';
|
||||||
|
|
||||||
const result = await window.api.downloadClip(url);
|
const result = await window.api.downloadClip(url);
|
||||||
|
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = '⬇ Clip herunterladen';
|
btn.textContent = 'Clip herunterladen';
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
status.textContent = 'Download erfolgreich!';
|
status.textContent = 'Download erfolgreich!';
|
||||||
@ -1112,19 +1494,225 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Video Cutter
|
||||||
|
async function selectCutterVideo() {
|
||||||
|
const filePath = await window.api.selectVideoFile();
|
||||||
|
if (!filePath) return;
|
||||||
|
|
||||||
|
cutterFile = filePath;
|
||||||
|
document.getElementById('cutterFilePath').value = filePath;
|
||||||
|
|
||||||
|
const info = await window.api.getVideoInfo(filePath);
|
||||||
|
if (!info) {
|
||||||
|
alert('Konnte Video-Informationen nicht lesen. FFprobe installiert?');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cutterVideoInfo = info;
|
||||||
|
cutterStartTime = 0;
|
||||||
|
cutterEndTime = info.duration;
|
||||||
|
|
||||||
|
document.getElementById('cutterInfo').style.display = 'flex';
|
||||||
|
document.getElementById('timelineContainer').style.display = 'block';
|
||||||
|
document.getElementById('btnCut').disabled = false;
|
||||||
|
|
||||||
|
document.getElementById('infoDuration').textContent = formatTime(info.duration);
|
||||||
|
document.getElementById('infoResolution').textContent = `${info.width}x${info.height}`;
|
||||||
|
document.getElementById('infoFps').textContent = Math.round(info.fps);
|
||||||
|
document.getElementById('infoSelection').textContent = formatTime(info.duration);
|
||||||
|
|
||||||
|
document.getElementById('startTime').value = '00:00:00';
|
||||||
|
document.getElementById('endTime').value = formatTime(info.duration);
|
||||||
|
|
||||||
|
updateTimeline();
|
||||||
|
await updatePreview(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(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 parseTime(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 updateTimeline() {
|
||||||
|
if (!cutterVideoInfo) return;
|
||||||
|
|
||||||
|
const selection = document.getElementById('timelineSelection');
|
||||||
|
const startPercent = (cutterStartTime / cutterVideoInfo.duration) * 100;
|
||||||
|
const endPercent = (cutterEndTime / cutterVideoInfo.duration) * 100;
|
||||||
|
|
||||||
|
selection.style.left = startPercent + '%';
|
||||||
|
selection.style.width = (endPercent - startPercent) + '%';
|
||||||
|
|
||||||
|
const duration = cutterEndTime - cutterStartTime;
|
||||||
|
document.getElementById('infoSelection').textContent = formatTime(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimeFromInput() {
|
||||||
|
const startStr = document.getElementById('startTime').value;
|
||||||
|
const endStr = document.getElementById('endTime').value;
|
||||||
|
|
||||||
|
cutterStartTime = Math.max(0, parseTime(startStr));
|
||||||
|
cutterEndTime = Math.min(cutterVideoInfo?.duration || 0, parseTime(endStr));
|
||||||
|
|
||||||
|
if (cutterEndTime <= cutterStartTime) {
|
||||||
|
cutterEndTime = cutterStartTime + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seekTimeline(event) {
|
||||||
|
if (!cutterVideoInfo) return;
|
||||||
|
|
||||||
|
const timeline = document.getElementById('timeline');
|
||||||
|
const rect = timeline.getBoundingClientRect();
|
||||||
|
const percent = (event.clientX - rect.left) / rect.width;
|
||||||
|
const time = percent * cutterVideoInfo.duration;
|
||||||
|
|
||||||
|
document.getElementById('timelineCurrent').style.left = (percent * 100) + '%';
|
||||||
|
await updatePreview(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePreview(time) {
|
||||||
|
if (!cutterFile) return;
|
||||||
|
|
||||||
|
const preview = document.getElementById('cutterPreview');
|
||||||
|
preview.innerHTML = '<div class="placeholder"><p>Lade Vorschau...</p></div>';
|
||||||
|
|
||||||
|
const frame = await window.api.extractFrame(cutterFile, time);
|
||||||
|
if (frame) {
|
||||||
|
preview.innerHTML = `<img src="${frame}" alt="Preview">`;
|
||||||
|
} else {
|
||||||
|
preview.innerHTML = '<div class="placeholder"><p>Vorschau nicht verfugbar</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCutting() {
|
||||||
|
if (!cutterFile || isCutting) return;
|
||||||
|
|
||||||
|
isCutting = true;
|
||||||
|
document.getElementById('btnCut').disabled = true;
|
||||||
|
document.getElementById('btnCut').textContent = 'Schneidet...';
|
||||||
|
document.getElementById('cutProgress').classList.add('show');
|
||||||
|
|
||||||
|
const result = await window.api.cutVideo(cutterFile, cutterStartTime, cutterEndTime);
|
||||||
|
|
||||||
|
isCutting = false;
|
||||||
|
document.getElementById('btnCut').disabled = false;
|
||||||
|
document.getElementById('btnCut').textContent = 'Schneiden';
|
||||||
|
document.getElementById('cutProgress').classList.remove('show');
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert('Video erfolgreich geschnitten!\n\n' + result.outputFile);
|
||||||
|
} else {
|
||||||
|
alert('Fehler beim Schneiden des Videos.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge Videos
|
||||||
|
async function addMergeFiles() {
|
||||||
|
const files = await window.api.selectMultipleVideos();
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
mergeFiles = [...mergeFiles, ...files];
|
||||||
|
renderMergeFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMergeFiles() {
|
||||||
|
const list = document.getElementById('mergeFileList');
|
||||||
|
document.getElementById('btnMerge').disabled = mergeFiles.length < 2;
|
||||||
|
|
||||||
|
if (mergeFiles.length === 0) {
|
||||||
|
list.innerHTML = `
|
||||||
|
<div class="empty-state" style="padding: 40px 20px;">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||||
|
<p style="margin-top:10px">Keine Videos ausgewahlt</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = mergeFiles.map((file, index) => {
|
||||||
|
const name = file.split(/[/\\]/).pop();
|
||||||
|
return `
|
||||||
|
<div class="file-item" draggable="true" data-index="${index}">
|
||||||
|
<div class="file-order">${index + 1}</div>
|
||||||
|
<div class="file-name" title="${file}">${name}</div>
|
||||||
|
<div class="file-actions">
|
||||||
|
<button class="file-btn" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>▲</button>
|
||||||
|
<button class="file-btn" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>▼</button>
|
||||||
|
<button class="file-btn remove" onclick="removeMergeFile(${index})">x</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveMergeFile(index, direction) {
|
||||||
|
const newIndex = index + direction;
|
||||||
|
if (newIndex < 0 || newIndex >= mergeFiles.length) return;
|
||||||
|
|
||||||
|
const temp = mergeFiles[index];
|
||||||
|
mergeFiles[index] = mergeFiles[newIndex];
|
||||||
|
mergeFiles[newIndex] = temp;
|
||||||
|
renderMergeFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMergeFile(index) {
|
||||||
|
mergeFiles.splice(index, 1);
|
||||||
|
renderMergeFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMerging() {
|
||||||
|
if (mergeFiles.length < 2 || isMerging) return;
|
||||||
|
|
||||||
|
const outputFile = await window.api.saveVideoDialog('merged_video.mp4');
|
||||||
|
if (!outputFile) return;
|
||||||
|
|
||||||
|
isMerging = true;
|
||||||
|
document.getElementById('btnMerge').disabled = true;
|
||||||
|
document.getElementById('btnMerge').textContent = 'Zusammenfugen...';
|
||||||
|
document.getElementById('mergeProgress').classList.add('show');
|
||||||
|
|
||||||
|
const result = await window.api.mergeVideos(mergeFiles, outputFile);
|
||||||
|
|
||||||
|
isMerging = false;
|
||||||
|
document.getElementById('btnMerge').disabled = false;
|
||||||
|
document.getElementById('btnMerge').textContent = 'Zusammenfugen';
|
||||||
|
document.getElementById('mergeProgress').classList.remove('show');
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert('Videos erfolgreich zusammengefugt!\n\n' + result.outputFile);
|
||||||
|
mergeFiles = [];
|
||||||
|
renderMergeFiles();
|
||||||
|
} else {
|
||||||
|
alert('Fehler beim Zusammenfugen der Videos.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Updates
|
// Updates
|
||||||
async function checkUpdateSilent() {
|
async function checkUpdateSilent() {
|
||||||
const result = await window.api.checkUpdate();
|
const result = await window.api.checkUpdate();
|
||||||
if (result.hasUpdate) {
|
if (result.hasUpdate) {
|
||||||
document.getElementById('updateBanner').classList.add('show');
|
document.getElementById('updateBanner').classList.add('show');
|
||||||
document.getElementById('updateText').textContent = `Version ${result.version} verfügbar: ${result.changelog}`;
|
document.getElementById('updateText').textContent = `Version ${result.version} verfugbar: ${result.changelog}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkUpdate() {
|
async function checkUpdate() {
|
||||||
const result = await window.api.checkUpdate();
|
const result = await window.api.checkUpdate();
|
||||||
if (result.hasUpdate) {
|
if (result.hasUpdate) {
|
||||||
alert(`Neue Version ${result.version} verfügbar!\n\n${result.changelog}`);
|
alert(`Neue Version ${result.version} verfugbar!\n\n${result.changelog}`);
|
||||||
} else {
|
} else {
|
||||||
alert('Du hast die neueste Version!');
|
alert('Du hast die neueste Version!');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron';
|
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { spawn, ChildProcess, execSync } from 'child_process';
|
import { spawn, ChildProcess, execSync, exec } from 'child_process';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { autoUpdater } from 'electron-updater';
|
import { autoUpdater } from 'electron-updater';
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// CONFIG & CONSTANTS
|
// CONFIG & CONSTANTS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
const APP_VERSION = '3.5.3';
|
const APP_VERSION = '3.6.0';
|
||||||
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
|
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
|
||||||
|
|
||||||
// Paths
|
// Paths
|
||||||
@ -60,6 +60,12 @@ interface QueueItem {
|
|||||||
duration_str: string;
|
duration_str: string;
|
||||||
status: 'pending' | 'downloading' | 'completed' | 'error';
|
status: 'pending' | 'downloading' | 'completed' | 'error';
|
||||||
progress: number;
|
progress: number;
|
||||||
|
currentPart?: number;
|
||||||
|
totalParts?: number;
|
||||||
|
speed?: string;
|
||||||
|
eta?: string;
|
||||||
|
downloadedBytes?: number;
|
||||||
|
totalBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadProgress {
|
interface DownloadProgress {
|
||||||
@ -68,6 +74,17 @@ interface DownloadProgress {
|
|||||||
speed: string;
|
speed: string;
|
||||||
eta: string;
|
eta: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
currentPart?: number;
|
||||||
|
totalParts?: number;
|
||||||
|
downloadedBytes?: number;
|
||||||
|
totalBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoInfo {
|
||||||
|
duration: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fps: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -78,8 +95,8 @@ const defaultConfig: Config = {
|
|||||||
client_secret: '',
|
client_secret: '',
|
||||||
download_path: DEFAULT_DOWNLOAD_PATH,
|
download_path: DEFAULT_DOWNLOAD_PATH,
|
||||||
streamers: [],
|
streamers: [],
|
||||||
theme: 'Twitch',
|
theme: 'twitch',
|
||||||
download_mode: 'parts',
|
download_mode: 'full',
|
||||||
part_minutes: 120
|
part_minutes: 120
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -136,12 +153,13 @@ let downloadQueue: QueueItem[] = loadQueue();
|
|||||||
let isDownloading = false;
|
let isDownloading = false;
|
||||||
let currentProcess: ChildProcess | null = null;
|
let currentProcess: ChildProcess | null = null;
|
||||||
let currentDownloadCancelled = false;
|
let currentDownloadCancelled = false;
|
||||||
|
let downloadStartTime = 0;
|
||||||
|
let downloadedBytes = 0;
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// STREAMLINK HELPER
|
// TOOL PATHS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
function getStreamlinkPath(): string {
|
function getStreamlinkPath(): string {
|
||||||
// Try to find streamlink in PATH
|
|
||||||
try {
|
try {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
const result = execSync('where streamlink', { encoding: 'utf-8' });
|
const result = execSync('where streamlink', { encoding: 'utf-8' });
|
||||||
@ -151,11 +169,8 @@ function getStreamlinkPath(): string {
|
|||||||
const result = execSync('which streamlink', { encoding: 'utf-8' });
|
const result = execSync('which streamlink', { encoding: 'utf-8' });
|
||||||
return result.trim();
|
return result.trim();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch { }
|
||||||
// Streamlink not in PATH
|
|
||||||
}
|
|
||||||
|
|
||||||
// Common installation paths
|
|
||||||
const commonPaths = [
|
const commonPaths = [
|
||||||
'C:\\Program Files\\Streamlink\\bin\\streamlink.exe',
|
'C:\\Program Files\\Streamlink\\bin\\streamlink.exe',
|
||||||
'C:\\Program Files (x86)\\Streamlink\\bin\\streamlink.exe',
|
'C:\\Program Files (x86)\\Streamlink\\bin\\streamlink.exe',
|
||||||
@ -166,14 +181,43 @@ function getStreamlinkPath(): string {
|
|||||||
if (fs.existsSync(p)) return p;
|
if (fs.existsSync(p)) return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'streamlink'; // Fallback
|
return 'streamlink';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFFmpegPath(): string {
|
||||||
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const result = execSync('where ffmpeg', { encoding: 'utf-8' });
|
||||||
|
const paths = result.trim().split('\n');
|
||||||
|
if (paths.length > 0) return paths[0].trim();
|
||||||
|
} else {
|
||||||
|
const result = execSync('which ffmpeg', { encoding: 'utf-8' });
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
const commonPaths = [
|
||||||
|
'C:\\ffmpeg\\bin\\ffmpeg.exe',
|
||||||
|
'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',
|
||||||
|
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ffmpeg', 'bin', 'ffmpeg.exe')
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of commonPaths) {
|
||||||
|
if (fs.existsSync(p)) return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ffmpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFFprobePath(): string {
|
||||||
|
const ffmpegPath = getFFmpegPath();
|
||||||
|
return ffmpegPath.replace('ffmpeg.exe', 'ffprobe.exe').replace('ffmpeg', 'ffprobe');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// DURATION HELPERS
|
// DURATION HELPERS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
function parseDuration(duration: string): number {
|
function parseDuration(duration: string): number {
|
||||||
// Parse Twitch duration format like "3h45m20s"
|
|
||||||
let seconds = 0;
|
let seconds = 0;
|
||||||
const hours = duration.match(/(\d+)h/);
|
const hours = duration.match(/(\d+)h/);
|
||||||
const minutes = duration.match(/(\d+)m/);
|
const minutes = duration.match(/(\d+)m/);
|
||||||
@ -189,10 +233,31 @@ function parseDuration(duration: string): number {
|
|||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
const s = seconds % 60;
|
const s = Math.floor(seconds % 60);
|
||||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(bytesPerSec: number): string {
|
||||||
|
if (bytesPerSec < 1024) return bytesPerSec.toFixed(0) + ' B/s';
|
||||||
|
if (bytesPerSec < 1024 * 1024) return (bytesPerSec / 1024).toFixed(1) + ' KB/s';
|
||||||
|
return (bytesPerSec / (1024 * 1024)).toFixed(1) + ' MB/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatETA(seconds: number): string {
|
||||||
|
if (seconds < 60) return `${Math.floor(seconds)}s`;
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${h}h ${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// TWITCH API
|
// TWITCH API
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -279,53 +344,280 @@ async function getClipInfo(clipId: string): Promise<any | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// VIDEO INFO (for cutter)
|
||||||
|
// ==========================================
|
||||||
|
async function getVideoInfo(filePath: string): Promise<VideoInfo | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const ffprobe = getFFprobePath();
|
||||||
|
const args = [
|
||||||
|
'-v', 'quiet',
|
||||||
|
'-print_format', 'json',
|
||||||
|
'-show_format',
|
||||||
|
'-show_streams',
|
||||||
|
filePath
|
||||||
|
];
|
||||||
|
|
||||||
|
const proc = spawn(ffprobe, args, { windowsHide: true });
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
proc.stdout?.on('data', (data) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = JSON.parse(output);
|
||||||
|
const videoStream = info.streams?.find((s: any) => s.codec_type === 'video');
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
duration: parseFloat(info.format?.duration || '0'),
|
||||||
|
width: videoStream?.width || 0,
|
||||||
|
height: videoStream?.height || 0,
|
||||||
|
fps: eval(videoStream?.r_frame_rate || '30') || 30
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', () => resolve(null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// VIDEO CUTTER
|
||||||
|
// ==========================================
|
||||||
|
async function extractFrame(filePath: string, timeSeconds: number): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const ffmpeg = getFFmpegPath();
|
||||||
|
const tempFile = path.join(app.getPath('temp'), `frame_${Date.now()}.jpg`);
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-ss', timeSeconds.toString(),
|
||||||
|
'-i', filePath,
|
||||||
|
'-vframes', '1',
|
||||||
|
'-q:v', '2',
|
||||||
|
'-y',
|
||||||
|
tempFile
|
||||||
|
];
|
||||||
|
|
||||||
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (code === 0 && fs.existsSync(tempFile)) {
|
||||||
|
const imageData = fs.readFileSync(tempFile);
|
||||||
|
const base64 = `data:image/jpeg;base64,${imageData.toString('base64')}`;
|
||||||
|
fs.unlinkSync(tempFile);
|
||||||
|
resolve(base64);
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', () => resolve(null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cutVideo(
|
||||||
|
inputFile: string,
|
||||||
|
outputFile: string,
|
||||||
|
startTime: number,
|
||||||
|
endTime: number,
|
||||||
|
onProgress: (percent: number) => void
|
||||||
|
): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const ffmpeg = getFFmpegPath();
|
||||||
|
const duration = endTime - startTime;
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-ss', formatDuration(startTime),
|
||||||
|
'-i', inputFile,
|
||||||
|
'-t', formatDuration(duration),
|
||||||
|
'-c', 'copy',
|
||||||
|
'-progress', 'pipe:1',
|
||||||
|
'-y',
|
||||||
|
outputFile
|
||||||
|
];
|
||||||
|
|
||||||
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
||||||
|
currentProcess = proc;
|
||||||
|
|
||||||
|
proc.stdout?.on('data', (data) => {
|
||||||
|
const line = data.toString();
|
||||||
|
const match = line.match(/out_time_us=(\d+)/);
|
||||||
|
if (match) {
|
||||||
|
const currentUs = parseInt(match[1]);
|
||||||
|
const percent = Math.min(100, (currentUs / 1000000) / duration * 100);
|
||||||
|
onProgress(percent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
currentProcess = null;
|
||||||
|
resolve(code === 0 && fs.existsSync(outputFile));
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', () => {
|
||||||
|
currentProcess = null;
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MERGE VIDEOS
|
||||||
|
// ==========================================
|
||||||
|
async function mergeVideos(
|
||||||
|
inputFiles: string[],
|
||||||
|
outputFile: string,
|
||||||
|
onProgress: (percent: number) => void
|
||||||
|
): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const ffmpeg = getFFmpegPath();
|
||||||
|
|
||||||
|
// Create concat file
|
||||||
|
const concatFile = path.join(app.getPath('temp'), `concat_${Date.now()}.txt`);
|
||||||
|
const concatContent = inputFiles.map(f => `file '${f.replace(/'/g, "'\\''")}'`).join('\n');
|
||||||
|
fs.writeFileSync(concatFile, concatContent);
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-f', 'concat',
|
||||||
|
'-safe', '0',
|
||||||
|
'-i', concatFile,
|
||||||
|
'-c', 'copy',
|
||||||
|
'-progress', 'pipe:1',
|
||||||
|
'-y',
|
||||||
|
outputFile
|
||||||
|
];
|
||||||
|
|
||||||
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
||||||
|
currentProcess = proc;
|
||||||
|
|
||||||
|
// Get total duration for progress
|
||||||
|
let totalDuration = 0;
|
||||||
|
for (const file of inputFiles) {
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(file);
|
||||||
|
totalDuration += stats.size; // Approximate by file size
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.stdout?.on('data', (data) => {
|
||||||
|
const line = data.toString();
|
||||||
|
const match = line.match(/out_time_us=(\d+)/);
|
||||||
|
if (match) {
|
||||||
|
const currentUs = parseInt(match[1]);
|
||||||
|
// Approximate progress
|
||||||
|
onProgress(Math.min(99, currentUs / 10000000));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
currentProcess = null;
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(concatFile);
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
if (code === 0 && fs.existsSync(outputFile)) {
|
||||||
|
onProgress(100);
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', () => {
|
||||||
|
currentProcess = null;
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// DOWNLOAD FUNCTIONS
|
// DOWNLOAD FUNCTIONS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
function downloadVOD(item: QueueItem, onProgress: (progress: DownloadProgress) => void): Promise<boolean> {
|
function downloadVODPart(
|
||||||
|
url: string,
|
||||||
|
filename: string,
|
||||||
|
startTime: string | null,
|
||||||
|
endTime: string | null,
|
||||||
|
onProgress: (progress: DownloadProgress) => void,
|
||||||
|
itemId: string,
|
||||||
|
partNum: number,
|
||||||
|
totalParts: number
|
||||||
|
): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const streamer = item.streamer.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
||||||
const date = new Date(item.date);
|
|
||||||
const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`;
|
|
||||||
|
|
||||||
const folder = path.join(config.download_path, streamer, dateStr);
|
|
||||||
fs.mkdirSync(folder, { recursive: true });
|
|
||||||
|
|
||||||
const safeTitle = item.title.replace(/[^a-zA-Z0-9_\- ]/g, '').substring(0, 50);
|
|
||||||
const filename = path.join(folder, `${safeTitle}.mp4`);
|
|
||||||
|
|
||||||
const streamlinkPath = getStreamlinkPath();
|
const streamlinkPath = getStreamlinkPath();
|
||||||
const args = [
|
const args = [url, 'best', '-o', filename, '--force'];
|
||||||
item.url,
|
|
||||||
'best',
|
if (startTime) {
|
||||||
'-o', filename,
|
args.push('--hls-start-offset', startTime);
|
||||||
'--force',
|
}
|
||||||
'--progress', 'force'
|
if (endTime) {
|
||||||
];
|
args.push('--hls-duration', endTime);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Starting download:', streamlinkPath, args);
|
console.log('Starting download:', streamlinkPath, args);
|
||||||
|
|
||||||
const proc = spawn(streamlinkPath, args, {
|
const proc = spawn(streamlinkPath, args, { windowsHide: true });
|
||||||
windowsHide: true
|
|
||||||
});
|
|
||||||
|
|
||||||
currentProcess = proc;
|
currentProcess = proc;
|
||||||
let lastProgress = 0;
|
|
||||||
|
downloadStartTime = Date.now();
|
||||||
|
downloadedBytes = 0;
|
||||||
|
let lastBytes = 0;
|
||||||
|
let lastTime = Date.now();
|
||||||
|
|
||||||
|
// Monitor file size for progress
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
if (fs.existsSync(filename)) {
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(filename);
|
||||||
|
downloadedBytes = stats.size;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const timeDiff = (now - lastTime) / 1000;
|
||||||
|
const bytesDiff = downloadedBytes - lastBytes;
|
||||||
|
const speed = timeDiff > 0 ? bytesDiff / timeDiff : 0;
|
||||||
|
|
||||||
|
lastBytes = downloadedBytes;
|
||||||
|
lastTime = now;
|
||||||
|
|
||||||
|
onProgress({
|
||||||
|
id: itemId,
|
||||||
|
progress: -1, // Unknown total
|
||||||
|
speed: formatSpeed(speed),
|
||||||
|
eta: '',
|
||||||
|
status: `Part ${partNum}/${totalParts}: ${formatBytes(downloadedBytes)}`,
|
||||||
|
currentPart: partNum,
|
||||||
|
totalParts: totalParts,
|
||||||
|
downloadedBytes: downloadedBytes
|
||||||
|
});
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
proc.stdout?.on('data', (data: Buffer) => {
|
proc.stdout?.on('data', (data: Buffer) => {
|
||||||
const line = data.toString();
|
const line = data.toString();
|
||||||
console.log('Streamlink:', line);
|
console.log('Streamlink:', line);
|
||||||
|
|
||||||
// Parse progress from streamlink output
|
// Parse progress
|
||||||
const match = line.match(/(\d+\.\d+)%/);
|
const match = line.match(/(\d+\.\d+)%/);
|
||||||
if (match) {
|
if (match) {
|
||||||
lastProgress = parseFloat(match[1]);
|
const percent = parseFloat(match[1]);
|
||||||
onProgress({
|
onProgress({
|
||||||
id: item.id,
|
id: itemId,
|
||||||
progress: lastProgress,
|
progress: percent,
|
||||||
speed: '',
|
speed: '',
|
||||||
eta: '',
|
eta: '',
|
||||||
status: `Downloading: ${lastProgress.toFixed(1)}%`
|
status: `Part ${partNum}/${totalParts}: ${percent.toFixed(1)}%`,
|
||||||
|
currentPart: partNum,
|
||||||
|
totalParts: totalParts
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -335,6 +627,7 @@ function downloadVOD(item: QueueItem, onProgress: (progress: DownloadProgress) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
proc.on('close', (code) => {
|
proc.on('close', (code) => {
|
||||||
|
clearInterval(progressInterval);
|
||||||
currentProcess = null;
|
currentProcess = null;
|
||||||
|
|
||||||
if (currentDownloadCancelled) {
|
if (currentDownloadCancelled) {
|
||||||
@ -344,14 +637,7 @@ function downloadVOD(item: QueueItem, onProgress: (progress: DownloadProgress) =
|
|||||||
|
|
||||||
if (code === 0 && fs.existsSync(filename)) {
|
if (code === 0 && fs.existsSync(filename)) {
|
||||||
const stats = fs.statSync(filename);
|
const stats = fs.statSync(filename);
|
||||||
if (stats.size > 1024 * 1024) { // At least 1MB
|
if (stats.size > 1024 * 1024) {
|
||||||
onProgress({
|
|
||||||
id: item.id,
|
|
||||||
progress: 100,
|
|
||||||
speed: '',
|
|
||||||
eta: '',
|
|
||||||
status: 'Completed'
|
|
||||||
});
|
|
||||||
resolve(true);
|
resolve(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -361,6 +647,7 @@ function downloadVOD(item: QueueItem, onProgress: (progress: DownloadProgress) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
proc.on('error', (err) => {
|
proc.on('error', (err) => {
|
||||||
|
clearInterval(progressInterval);
|
||||||
console.error('Process error:', err);
|
console.error('Process error:', err);
|
||||||
currentProcess = null;
|
currentProcess = null;
|
||||||
resolve(false);
|
resolve(false);
|
||||||
@ -368,6 +655,62 @@ function downloadVOD(item: QueueItem, onProgress: (progress: DownloadProgress) =
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadVOD(
|
||||||
|
item: QueueItem,
|
||||||
|
onProgress: (progress: DownloadProgress) => void
|
||||||
|
): Promise<boolean> {
|
||||||
|
const streamer = item.streamer.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||||
|
const date = new Date(item.date);
|
||||||
|
const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`;
|
||||||
|
|
||||||
|
const folder = path.join(config.download_path, streamer, dateStr);
|
||||||
|
fs.mkdirSync(folder, { recursive: true });
|
||||||
|
|
||||||
|
const safeTitle = item.title.replace(/[^a-zA-Z0-9_\- ]/g, '').substring(0, 50);
|
||||||
|
const totalDuration = parseDuration(item.duration_str);
|
||||||
|
|
||||||
|
// Check download mode
|
||||||
|
if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) {
|
||||||
|
// Full download
|
||||||
|
const filename = path.join(folder, `${safeTitle}.mp4`);
|
||||||
|
return await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1);
|
||||||
|
} else {
|
||||||
|
// Part-based download
|
||||||
|
const partDuration = config.part_minutes * 60;
|
||||||
|
const numParts = Math.ceil(totalDuration / partDuration);
|
||||||
|
const downloadedFiles: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < numParts; i++) {
|
||||||
|
if (currentDownloadCancelled) break;
|
||||||
|
|
||||||
|
const startSec = i * partDuration;
|
||||||
|
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 success = await downloadVODPart(
|
||||||
|
item.url,
|
||||||
|
partFilename,
|
||||||
|
formatDuration(startSec),
|
||||||
|
formatDuration(duration),
|
||||||
|
onProgress,
|
||||||
|
item.id,
|
||||||
|
i + 1,
|
||||||
|
numParts
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadedFiles.push(partFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloadedFiles.length === numParts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function processQueue(): Promise<void> {
|
async function processQueue(): Promise<void> {
|
||||||
if (isDownloading || downloadQueue.length === 0) return;
|
if (isDownloading || downloadQueue.length === 0) return;
|
||||||
|
|
||||||
@ -420,7 +763,6 @@ function createWindow(): void {
|
|||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for updates on startup
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
}, 3000);
|
}, 3000);
|
||||||
@ -431,7 +773,7 @@ async function checkForUpdates(): Promise<{ hasUpdate: boolean; version?: string
|
|||||||
const response = await axios.get(UPDATE_CHECK_URL, { timeout: 5000 });
|
const response = await axios.get(UPDATE_CHECK_URL, { timeout: 5000 });
|
||||||
const latest = response.data.version;
|
const latest = response.data.version;
|
||||||
|
|
||||||
if (latest !== APP_VERSION.replace('v', '')) {
|
if (latest !== APP_VERSION) {
|
||||||
return {
|
return {
|
||||||
hasUpdate: true,
|
hasUpdate: true,
|
||||||
version: latest,
|
version: latest,
|
||||||
@ -515,6 +857,16 @@ ipcMain.handle('select-folder', async () => {
|
|||||||
return result.filePaths[0] || null;
|
return result.filePaths[0] || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('select-video-file', async () => {
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [
|
||||||
|
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return result.filePaths[0] || null;
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('open-folder', (_, folderPath: string) => {
|
ipcMain.handle('open-folder', (_, folderPath: string) => {
|
||||||
if (fs.existsSync(folderPath)) {
|
if (fs.existsSync(folderPath)) {
|
||||||
shell.openPath(folderPath);
|
shell.openPath(folderPath);
|
||||||
@ -528,7 +880,6 @@ ipcMain.handle('check-update', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
||||||
// Extract clip ID from URL
|
|
||||||
let clipId = '';
|
let clipId = '';
|
||||||
const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/);
|
const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/);
|
||||||
const match2 = clipUrl.match(/twitch\.tv\/[^/]+\/clip\/([A-Za-z0-9_-]+)/);
|
const match2 = clipUrl.match(/twitch\.tv\/[^/]+\/clip\/([A-Za-z0-9_-]+)/);
|
||||||
@ -571,6 +922,59 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
|||||||
|
|
||||||
ipcMain.handle('is-downloading', () => isDownloading);
|
ipcMain.handle('is-downloading', () => isDownloading);
|
||||||
|
|
||||||
|
// Video Cutter IPC
|
||||||
|
ipcMain.handle('get-video-info', async (_, filePath: string) => {
|
||||||
|
return await getVideoInfo(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('extract-frame', async (_, filePath: string, timeSeconds: number) => {
|
||||||
|
return await extractFrame(filePath, timeSeconds);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('cut-video', async (_, inputFile: string, startTime: number, endTime: number) => {
|
||||||
|
const dir = path.dirname(inputFile);
|
||||||
|
const baseName = path.basename(inputFile, path.extname(inputFile));
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(11, 19);
|
||||||
|
const outputFile = path.join(dir, `${baseName}_cut_${timestamp}.mp4`);
|
||||||
|
|
||||||
|
let lastProgress = 0;
|
||||||
|
const success = await cutVideo(inputFile, outputFile, startTime, endTime, (percent) => {
|
||||||
|
lastProgress = percent;
|
||||||
|
mainWindow?.webContents.send('cut-progress', percent);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success, outputFile: success ? outputFile : null };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge IPC
|
||||||
|
ipcMain.handle('merge-videos', async (_, inputFiles: string[], outputFile: string) => {
|
||||||
|
const success = await mergeVideos(inputFiles, outputFile, (percent) => {
|
||||||
|
mainWindow?.webContents.send('merge-progress', percent);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success, outputFile: success ? outputFile : null };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('select-multiple-videos', async () => {
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||||
|
properties: ['openFile', 'multiSelections'],
|
||||||
|
filters: [
|
||||||
|
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return result.filePaths;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('save-video-dialog', async (_, defaultName: string) => {
|
||||||
|
const result = await dialog.showSaveDialog(mainWindow!, {
|
||||||
|
defaultPath: defaultName,
|
||||||
|
filters: [
|
||||||
|
{ name: 'MP4 Video', extensions: ['mp4'] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return result.filePath || null;
|
||||||
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// APP LIFECYCLE
|
// APP LIFECYCLE
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@ -10,6 +10,10 @@ interface QueueItem {
|
|||||||
duration_str: string;
|
duration_str: string;
|
||||||
status: 'pending' | 'downloading' | 'completed' | 'error';
|
status: 'pending' | 'downloading' | 'completed' | 'error';
|
||||||
progress: number;
|
progress: number;
|
||||||
|
currentPart?: number;
|
||||||
|
totalParts?: number;
|
||||||
|
speed?: string;
|
||||||
|
eta?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadProgress {
|
interface DownloadProgress {
|
||||||
@ -18,6 +22,17 @@ interface DownloadProgress {
|
|||||||
speed: string;
|
speed: string;
|
||||||
eta: string;
|
eta: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
currentPart?: number;
|
||||||
|
totalParts?: number;
|
||||||
|
downloadedBytes?: number;
|
||||||
|
totalBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoInfo {
|
||||||
|
duration: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fps: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose protected methods to renderer
|
// Expose protected methods to renderer
|
||||||
@ -47,8 +62,21 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
|
|
||||||
// Files
|
// Files
|
||||||
selectFolder: () => ipcRenderer.invoke('select-folder'),
|
selectFolder: () => ipcRenderer.invoke('select-folder'),
|
||||||
|
selectVideoFile: () => ipcRenderer.invoke('select-video-file'),
|
||||||
|
selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'),
|
||||||
|
saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName),
|
||||||
openFolder: (path: string) => ipcRenderer.invoke('open-folder', path),
|
openFolder: (path: string) => ipcRenderer.invoke('open-folder', path),
|
||||||
|
|
||||||
|
// Video Cutter
|
||||||
|
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
|
||||||
|
extractFrame: (filePath: string, timeSeconds: number): Promise<string | null> => ipcRenderer.invoke('extract-frame', filePath, timeSeconds),
|
||||||
|
cutVideo: (inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }> =>
|
||||||
|
ipcRenderer.invoke('cut-video', inputFile, startTime, endTime),
|
||||||
|
|
||||||
|
// Merge Videos
|
||||||
|
mergeVideos: (inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }> =>
|
||||||
|
ipcRenderer.invoke('merge-videos', inputFiles, outputFile),
|
||||||
|
|
||||||
// App
|
// App
|
||||||
getVersion: () => ipcRenderer.invoke('get-version'),
|
getVersion: () => ipcRenderer.invoke('get-version'),
|
||||||
checkUpdate: () => ipcRenderer.invoke('check-update'),
|
checkUpdate: () => ipcRenderer.invoke('check-update'),
|
||||||
@ -65,5 +93,11 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
},
|
},
|
||||||
onDownloadFinished: (callback: () => void) => {
|
onDownloadFinished: (callback: () => void) => {
|
||||||
ipcRenderer.on('download-finished', () => callback());
|
ipcRenderer.on('download-finished', () => callback());
|
||||||
|
},
|
||||||
|
onCutProgress: (callback: (percent: number) => void) => {
|
||||||
|
ipcRenderer.on('cut-progress', (_, percent) => callback(percent));
|
||||||
|
},
|
||||||
|
onMergeProgress: (callback: (percent: number) => void) => {
|
||||||
|
ipcRenderer.on('merge-progress', (_, percent) => callback(percent));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user