Compare commits
2 Commits
5098510d53
...
2f1e5f4a9e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f1e5f4a9e | ||
|
|
fab263ae4c |
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.8",
|
"version": "4.6.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.8",
|
"version": "4.6.9",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.8",
|
"version": "4.6.9",
|
||||||
"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",
|
||||||
|
|||||||
@ -136,6 +136,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Events Viewer Modal -->
|
||||||
|
<div class="modal-overlay" id="eventsViewerModal">
|
||||||
|
<div class="modal" style="max-width: 700px; max-height: 80vh; display:flex; flex-direction:column;">
|
||||||
|
<button class="modal-close" onclick="closeEventsViewer()">x</button>
|
||||||
|
<h2 id="eventsViewerTitle" style="margin-top:0;">Stream events</h2>
|
||||||
|
<div id="eventsViewerStatus" style="color:var(--text-secondary); font-size:12px; margin-bottom:8px;"></div>
|
||||||
|
<div id="eventsViewerList" style="flex:1; overflow-y:auto; background: var(--bg-main); border:1px solid var(--border-soft); border-radius:6px; padding:8px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Chat Replay Viewer Modal -->
|
<!-- Chat Replay Viewer Modal -->
|
||||||
<div class="modal-overlay" id="chatViewerModal">
|
<div class="modal-overlay" id="chatViewerModal">
|
||||||
<div class="modal" style="max-width: 800px; height: 80vh; display:flex; flex-direction:column;">
|
<div class="modal" style="max-width: 800px; height: 80vh; display:flex; flex-direction:column;">
|
||||||
|
|||||||
23
src/main.ts
23
src/main.ts
@ -3904,10 +3904,31 @@ async function downloadLiveStream(
|
|||||||
|
|
||||||
const recordingStartedAt = Date.now();
|
const recordingStartedAt = Date.now();
|
||||||
|
|
||||||
|
// Wrap onProgress so live recordings get a useful meta line. Without
|
||||||
|
// this the queue meta only shows raw bytes ("4.7 GB heruntergeladen")
|
||||||
|
// which doesn't tell the user how long the recording has been running
|
||||||
|
// or whether the bitrate is healthy. Substitutes:
|
||||||
|
// "{HH:MM:SS} · {size} · {avg Mbps}"
|
||||||
|
// and clears speed/eta so the renderer doesn't double-up on data.
|
||||||
|
const wrappedProgress = (p: DownloadProgress): void => {
|
||||||
|
const elapsed = Math.max(1, Math.floor((Date.now() - recordingStartedAt) / 1000));
|
||||||
|
const bytes = Number(p.downloadedBytes) || 0;
|
||||||
|
const avgBitrateMbps = (bytes * 8) / elapsed / 1_000_000;
|
||||||
|
const parts: string[] = [formatDuration(elapsed)];
|
||||||
|
if (bytes > 0) parts.push(formatBytes(bytes));
|
||||||
|
if (avgBitrateMbps > 0) parts.push(`${avgBitrateMbps.toFixed(1)} Mbps`);
|
||||||
|
onProgress({
|
||||||
|
...p,
|
||||||
|
speed: '',
|
||||||
|
eta: '',
|
||||||
|
status: parts.join(' · ')
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// No start/end times for live streams — streamlink records until the
|
// No start/end times for live streams — streamlink records until the
|
||||||
// stream actually ends or we kill it. downloadVODPart already handles
|
// stream actually ends or we kill it. downloadVODPart already handles
|
||||||
// null start/end correctly.
|
// null start/end correctly.
|
||||||
const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1);
|
const result = await downloadVODPart(item.url, filename, null, null, wrappedProgress, item.id, 1, 1);
|
||||||
|
|
||||||
if (chatSession) {
|
if (chatSession) {
|
||||||
stopLiveChatCapture(chatSession);
|
stopLiveChatCapture(chatSession);
|
||||||
|
|||||||
@ -237,6 +237,13 @@ const UI_TEXT_DE = {
|
|||||||
viewChatFailed: 'Chat-Datei konnte nicht gelesen werden',
|
viewChatFailed: 'Chat-Datei konnte nicht gelesen werden',
|
||||||
viewChatCount: '{count} Nachrichten',
|
viewChatCount: '{count} Nachrichten',
|
||||||
viewChatTruncatedSuffix: ' (gekuerzt)',
|
viewChatTruncatedSuffix: ' (gekuerzt)',
|
||||||
|
viewEvents: 'Events ansehen',
|
||||||
|
viewEventsCount: '{count} Events',
|
||||||
|
viewEventsEmpty: 'Keine Events aufgezeichnet.',
|
||||||
|
eventStartedAs: 'Gestartet als',
|
||||||
|
eventEndedAfter: 'Beendet nach',
|
||||||
|
eventTitleFromTo: 'Titel: {from} -> {to}',
|
||||||
|
eventGameFromTo: 'Game: {from} -> {to}',
|
||||||
statusBarSummary: '{downloading} aktiv, {pending} wartet',
|
statusBarSummary: '{downloading} aktiv, {pending} wartet',
|
||||||
ctxMoveTop: 'Nach oben verschieben',
|
ctxMoveTop: 'Nach oben verschieben',
|
||||||
ctxMoveBottom: 'Nach unten verschieben',
|
ctxMoveBottom: 'Nach unten verschieben',
|
||||||
|
|||||||
@ -237,6 +237,13 @@ const UI_TEXT_EN = {
|
|||||||
viewChatFailed: 'Could not read chat file',
|
viewChatFailed: 'Could not read chat file',
|
||||||
viewChatCount: '{count} messages',
|
viewChatCount: '{count} messages',
|
||||||
viewChatTruncatedSuffix: ' (truncated)',
|
viewChatTruncatedSuffix: ' (truncated)',
|
||||||
|
viewEvents: 'View events',
|
||||||
|
viewEventsCount: '{count} events',
|
||||||
|
viewEventsEmpty: 'No events recorded.',
|
||||||
|
eventStartedAs: 'Started as',
|
||||||
|
eventEndedAfter: 'Ended after',
|
||||||
|
eventTitleFromTo: 'Title: {from} -> {to}',
|
||||||
|
eventGameFromTo: 'Game: {from} -> {to}',
|
||||||
statusBarSummary: '{downloading} dl, {pending} queued',
|
statusBarSummary: '{downloading} dl, {pending} queued',
|
||||||
ctxMoveTop: 'Move to top',
|
ctxMoveTop: 'Move to top',
|
||||||
ctxMoveBottom: 'Move to bottom',
|
ctxMoveBottom: 'Move to bottom',
|
||||||
|
|||||||
@ -25,6 +25,13 @@ function renderQueueItemFileActions(item: QueueItem): string {
|
|||||||
buttons.push(`<button class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
|
buttons.push(`<button class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same pattern for the .events.jsonl sidecar — title/game change timeline.
|
||||||
|
const eventsFile = item.outputFiles.find((f) => /\.events\.jsonl$/i.test(f));
|
||||||
|
if (eventsFile) {
|
||||||
|
const safeEventsAttr = eventsFile.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||||
|
buttons.push(`<button class="queue-detail-btn" onclick="openEventsViewer('${safeEventsAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewEvents)}</button>`);
|
||||||
|
}
|
||||||
|
|
||||||
const fileLabel = item.outputFiles.length === 1
|
const fileLabel = item.outputFiles.length === 1
|
||||||
? safeFirst
|
? safeFirst
|
||||||
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;
|
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;
|
||||||
|
|||||||
112
src/renderer.ts
112
src/renderer.ts
@ -246,6 +246,112 @@ function openTwitchDevConsole(): void {
|
|||||||
void window.api.openExternal('https://dev.twitch.tv/console/apps');
|
void window.api.openExternal('https://dev.twitch.tv/console/apps');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EventLogEntry {
|
||||||
|
t?: string;
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
game?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
streamer?: string;
|
||||||
|
durationSeconds?: number;
|
||||||
|
success?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEventsViewer(filePath: string, title: string): Promise<void> {
|
||||||
|
const modal = byId('eventsViewerModal');
|
||||||
|
const list = byId('eventsViewerList');
|
||||||
|
const status = byId('eventsViewerStatus');
|
||||||
|
byId('eventsViewerTitle').textContent = title || UI_TEXT.queue.viewEvents;
|
||||||
|
list.replaceChildren();
|
||||||
|
status.textContent = UI_TEXT.queue.viewChatLoading;
|
||||||
|
modal.classList.add('show');
|
||||||
|
|
||||||
|
const result = await window.api.readChatFile(filePath);
|
||||||
|
if (!result.success || !Array.isArray(result.messages)) {
|
||||||
|
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const events = result.messages as EventLogEntry[];
|
||||||
|
status.textContent = UI_TEXT.queue.viewEventsCount.replace('{count}', String(events.length));
|
||||||
|
renderEventsList(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEventsViewer(): void {
|
||||||
|
byId('eventsViewerModal').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventTime(iso?: string): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE');
|
||||||
|
} catch { return iso; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEventsList(events: EventLogEntry[]): void {
|
||||||
|
const list = byId('eventsViewerList');
|
||||||
|
list.replaceChildren();
|
||||||
|
if (events.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.style.color = 'var(--text-secondary)';
|
||||||
|
empty.style.padding = '12px';
|
||||||
|
empty.style.textAlign = 'center';
|
||||||
|
empty.textContent = UI_TEXT.queue.viewEventsEmpty;
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.style.padding = '8px 10px';
|
||||||
|
row.style.borderBottom = '1px solid var(--border-soft)';
|
||||||
|
row.style.fontSize = '12px';
|
||||||
|
|
||||||
|
const time = document.createElement('span');
|
||||||
|
time.style.color = 'var(--text-secondary)';
|
||||||
|
time.style.marginRight = '8px';
|
||||||
|
time.textContent = formatEventTime(ev.t);
|
||||||
|
row.appendChild(time);
|
||||||
|
|
||||||
|
const tag = document.createElement('span');
|
||||||
|
tag.style.fontWeight = '600';
|
||||||
|
tag.style.marginRight = '8px';
|
||||||
|
const tagColors: Record<string, string> = {
|
||||||
|
recording_start: '#00c853',
|
||||||
|
recording_end: '#9146ff',
|
||||||
|
title_change: '#ffab00',
|
||||||
|
game_change: '#ff4444'
|
||||||
|
};
|
||||||
|
tag.style.color = tagColors[ev.type || ''] || 'var(--accent)';
|
||||||
|
tag.textContent = ev.type || 'event';
|
||||||
|
row.appendChild(tag);
|
||||||
|
|
||||||
|
const detail = document.createElement('div');
|
||||||
|
detail.style.marginTop = '4px';
|
||||||
|
detail.style.color = 'var(--text)';
|
||||||
|
|
||||||
|
if (ev.type === 'recording_start') {
|
||||||
|
detail.textContent = `${UI_TEXT.queue.eventStartedAs}: "${ev.title || '-'}" — ${ev.game || '-'}`;
|
||||||
|
} else if (ev.type === 'recording_end') {
|
||||||
|
const dur = typeof ev.durationSeconds === 'number'
|
||||||
|
? `${Math.floor(ev.durationSeconds / 3600)}h ${Math.floor((ev.durationSeconds % 3600) / 60)}m ${ev.durationSeconds % 60}s`
|
||||||
|
: '?';
|
||||||
|
const ok = ev.success ? '✓' : '✗';
|
||||||
|
detail.textContent = `${ok} ${UI_TEXT.queue.eventEndedAfter}: ${dur}${ev.error ? ` — ${ev.error}` : ''}`;
|
||||||
|
} else if (ev.type === 'title_change') {
|
||||||
|
detail.textContent = `${UI_TEXT.queue.eventTitleFromTo.replace('{from}', `"${ev.from || '-'}"`).replace('{to}', `"${ev.to || '-'}"`)}`;
|
||||||
|
} else if (ev.type === 'game_change') {
|
||||||
|
detail.textContent = `${UI_TEXT.queue.eventGameFromTo.replace('{from}', ev.from || '-').replace('{to}', ev.to || '-')}`;
|
||||||
|
} else {
|
||||||
|
detail.textContent = JSON.stringify(ev);
|
||||||
|
}
|
||||||
|
row.appendChild(detail);
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatViewerMessage {
|
interface ChatViewerMessage {
|
||||||
t?: string;
|
t?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
@ -391,6 +497,12 @@ function renderChatViewerList(messages: ChatViewerMessage[]): void {
|
|||||||
|
|
||||||
function closeTopmostOpenModal(): boolean {
|
function closeTopmostOpenModal(): boolean {
|
||||||
// Try each known modal in priority order
|
// Try each known modal in priority order
|
||||||
|
const eventsViewerModal = document.getElementById('eventsViewerModal');
|
||||||
|
if (eventsViewerModal?.classList.contains('show')) {
|
||||||
|
closeEventsViewer();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const chatViewerModal = document.getElementById('chatViewerModal');
|
const chatViewerModal = document.getElementById('chatViewerModal');
|
||||||
if (chatViewerModal?.classList.contains('show')) {
|
if (chatViewerModal?.classList.contains('show')) {
|
||||||
closeChatViewer();
|
closeChatViewer();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user