release: 5.0.1 — fix VOD hover preview + merge-group progress bar

Bug 1 — VOD-Hover Storyboard zeigte am unteren Rand einen statischen Streifen vom Original-Thumbnail (Subpixel-Mismatch + Aspect-Ratio-Konflikt). Fix: Overlay haengt jetzt an .vod-thumb-wrap statt .vod-card, mit explizitem width+height aus dem Thumbnail-BoundingRect — keine CSS-aspect-ratio-Interferenz mehr.

Bug 2 — Merge-Group Download zeigte einen eingefrorenen Progress-Bar bei Multi-Part-VODs (Part X/Y). Root Cause: der weighted-progress Wrapper clamped progress=-1 (HLS unknown-total 1s-Tick) auf 0, was overallProgress auf priorWeight fix-nagelte. Bar oszillierte zwischen indeterminate-animation und einem fixen ~10% Wert. Fix: lastVodProgress persistiert zwischen Path-A-Ticks und Path-B-Streamlink-%-Lines, sodass der Bar smooth waehrend einer Part hochzaehlt.

210 unit tests + e2e:release gruen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-13 14:30:21 +02:00
parent c2b9b5759a
commit 44daa65fe6
4 changed files with 34 additions and 16 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
"version": "5.1.0-alpha.2",
"version": "5.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "5.1.0-alpha.2",
"version": "5.0.1",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

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

View File

@ -5373,15 +5373,24 @@ async function processDownloadMergeGroup(
const vodWeight = vodDuration / totalDurationSec;
const priorWeight = mg.items.slice(0, i).reduce((s, v) => s + parseDuration(v.duration_str), 0) / totalDurationSec;
// Persistente per-part vodProgress, damit Path-A-Ticks (progress=-1,
// 1s-rhythmus mit unknown-total) den determinate Bar nicht auf
// priorWeight zuruecksetzen. Wir merken uns die letzte positive
// Streamlink-Prozentangabe (Path B) und nutzen sie, bis ein neuer
// Wert kommt. Ohne das oszilliert die Bar zwischen indeterminate
// und priorWeight, optisch eingefroren.
let lastVodProgress = 0;
const result = await downloadVODPart(
vodItem.url,
tmpFilename,
null, // startTime: null = full VOD
null, // endTime: null = full VOD
(progress) => {
if (progress.progress > 0 && progress.progress <= 100) {
lastVodProgress = progress.progress;
}
// Weighted progress: download phase = 0-70%
const vodProgress = progress.progress > 0 ? progress.progress : 0;
const overallProgress = (priorWeight + vodWeight * (vodProgress / 100)) * 70;
const overallProgress = (priorWeight + vodWeight * (lastVodProgress / 100)) * 70;
onProgress({
...progress,
id: item.id,

View File

@ -12,6 +12,7 @@ interface ActiveHover {
vodId: string;
intervalId: number;
overlay: HTMLElement;
card: HTMLElement; // .vod-card, fuer preview-active toggle (separat vom overlay-host)
}
const vodStoryboardClientCache = new Map<string, VodStoryboard | null>();
@ -79,8 +80,7 @@ function clearHoverPreview(): void {
pendingHoverVodId = null;
if (!activeHover) return;
window.clearInterval(activeHover.intervalId);
const card = activeHover.overlay.parentElement;
if (card) card.classList.remove('preview-active');
activeHover.card.classList.remove('preview-active');
// Brief opacity fade-out, then remove from DOM.
activeHover.overlay.style.opacity = '0';
const overlayToRemove = activeHover.overlay;
@ -124,20 +124,29 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<v
const overlay = document.createElement('div');
overlay.className = 'vod-storyboard-preview';
// Scale the sprite so a single cell exactly fills the card width.
// The thumbnail aspect-ratio (16:9) matches typical cell aspect
// (e.g. 220x124 ≈ 1.77) so width-stretch keeps proportions.
const cardWidth = card.getBoundingClientRect().width;
const cellAspect = storyboard.cellWidth / storyboard.cellHeight;
const scale = cardWidth / storyboard.cellWidth;
// Anchor an .vod-thumb-wrap statt .vod-card: das wrap-Element hat
// exakt Thumbnail-Bounds (kein card-border, kein info/actions-Bereich
// darunter). Faellt zurueck auf das card selbst, falls das Markup
// mal anders ist.
const anchor = card.querySelector('.vod-thumb-wrap') as HTMLElement | null;
const host = anchor ?? card;
const thumb = card.querySelector('.vod-thumbnail') as HTMLElement | null;
const thumbRect = (thumb ?? host).getBoundingClientRect();
const width = thumbRect.width;
const height = thumbRect.height;
const scale = width / storyboard.cellWidth;
overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`;
overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scale}px ${storyboard.rows * storyboard.cellHeight * scale}px`;
overlay.style.height = `${cardWidth / cellAspect}px`;
overlay.style.backgroundRepeat = 'no-repeat';
overlay.style.width = `${width}px`;
overlay.style.height = `${height}px`;
// Initial position = first chosen cell.
const first = cellsToShow[0];
overlay.style.backgroundPosition = `-${first.col * storyboard.cellWidth * scale}px -${first.row * storyboard.cellHeight * scale}px`;
card.appendChild(overlay);
host.appendChild(overlay);
// Trigger CSS transition to opacity:1 on the next frame.
requestAnimationFrame(() => { card.classList.add('preview-active'); });
@ -148,7 +157,7 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<v
frameIdx++;
}, FRAME_INTERVAL_MS);
activeHover = { vodId, intervalId, overlay };
activeHover = { vodId, intervalId, overlay, card };
}
(window as unknown as { ensureVodHoverHandlersBound: typeof ensureVodHoverHandlersBound }).ensureVodHoverHandlersBound = ensureVodHoverHandlersBound;