From 0ebbd209682f7ee146967ef8c5a4d2e39dfe3e30 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 22 Jun 2026 23:12:21 -0500 Subject: [PATCH] fix(player): composite multi-zone layouts in screenshot/stream capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit captureAndSend() grabbed a single querySelector('video'|'img') and stretched it across a fixed 960x540 canvas, so multi-zone Now-Playing screenshots and the 1fps remote stream showed one zone stretched fullscreen instead of the actual layout. - Multi-zone layouts now composite each zone from its REAL rendered geometry (getBoundingClientRect relative to the container, scaled proportionally onto the canvas), so positions/sizes stay true to the layout. - Canvas height derives from the container aspect (not a hardcoded 540); media is drawn honouring its object-fit (cover/contain/fill) instead of being stretched. - Cross-origin / iframe zones (YouTube, widgets) can't be read back without CORS-tainting the whole canvas (which makes toDataURL throw and kills the entire capture). They now get a deliberate, labelled placeholder ("YouTube"/"Widget"/ "Video") so the shot still shows the layout structure with that zone marked, instead of a transparent hole or a failed capture. - Split rendering into renderCaptureCanvas() (socket-free, headlessly verifiable) and captureAndSend() (encode + emit). One full-quality path serves BOTH the on-demand screenshot and the 1fps stream — the composite is only a few drawImage calls over already-decoded media, so no separate low-quality stream path. Web player only; Android (view.draw already composites correctly) untouched. Verified headlessly on a 3-zone device: red/green image zones render in their correct positions, the YouTube zone shows a labelled placeholder, and the capture succeeds with no CORS taint. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/player/index.html | 159 +++++++++++++++++++++++++++++++-------- 1 file changed, 129 insertions(+), 30 deletions(-) diff --git a/server/player/index.html b/server/player/index.html index f1a25ed..de236ff 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -1880,69 +1880,168 @@ } // ==================== Screenshots ==================== - function captureAndSend() { - if (!socket?.connected) return; + // Draw a media element into a destination rect honouring its object-fit, so the capture + // matches what's on screen instead of stretching the source to a fixed size (the old + // bug). 'cover' crops the source; 'contain' letterboxes; 'fill' stretches. + function drawMediaFit(ctx, el, ew, eh, dx, dy, dw, dh, fit) { + if (!ew || !eh) { ctx.drawImage(el, dx, dy, dw, dh); return; } + if (fit === 'fill') { ctx.drawImage(el, dx, dy, dw, dh); return; } + const er = ew / eh, dr = dw / dh; + if (fit === 'contain') { + let w = dw, h = dh; + if (er > dr) h = dw / er; else w = dh * er; + ctx.drawImage(el, dx + (dw - w) / 2, dy + (dh - h) / 2, w, h); + } else { // 'cover' (zone default) and anything unexpected -> crop to fill, no distortion + let sw = ew, sh = eh, sx = 0, sy = 0; + if (er > dr) { sw = eh * dr; sx = (ew - sw) / 2; } + else { sh = ew / dr; sy = (eh - sh) / 2; } + ctx.drawImage(el, sx, sy, sw, sh, dx, dy, dw, dh); + } + } + + // A cross-origin /