From e684feb1fe5d8ca76f107e7447ba0550c99ecc91 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 24 Apr 2026 01:26:07 -0400 Subject: [PATCH] fix(web/session): feed asciinema-player inline data, not a Blob URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionDrawer built a cast blob, pushed it through URL.createObjectURL, and passed the blob URL to AsciinemaPlayer.create(). That's racy with useEffect's cleanup: each new page of events re-fires the effect, the cleanup revokes the URL, and the player's already-in-flight async loadRecording() lands on a dead URL with no visible error — result was a centered play button with an empty black pane, playback never starts. asciinema-player v3's recording driver accepts {data: } as a first-class source (see core-DnNOMtZn.js:905-930 doFetch — string/ ArrayBuffer data is wrapped in `new Response(value)` and handed to the parser). Skip the blob detour entirely, pass the cast text inline. Also filter events to valid asciicast channels (o/i/r) before feeding so a future stray SD field can't derail the parser, and log mount errors to console for next-time debugging. --- decnet_web/src/components/SessionDrawer.tsx | 35 +++++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/decnet_web/src/components/SessionDrawer.tsx b/decnet_web/src/components/SessionDrawer.tsx index 2a3e8fd8..ebd5b528 100644 --- a/decnet_web/src/components/SessionDrawer.tsx +++ b/decnet_web/src/components/SessionDrawer.tsx @@ -117,22 +117,37 @@ const SessionDrawer: React.FC = ({ decky, sid, fields, onClo // Re-mount the player whenever the event window grows. asciinema-player // doesn't expose a public feed() API in v3, so we rebuild from the full - // in-memory blob each time — cheap for v1-scale sessions (≤ 10 MB cap). + // in-memory cast each time — cheap for v1-scale sessions (≤ 10 MB cap). + // + // Pass the cast as {data: ...} directly rather than a Blob URL. The + // URL path silently fails when the browser's fetch for the blob races + // the createObjectURL revoke, or when the mime-type guess trips the + // player's loader — either way the user gets a play button that does + // nothing on click. Inline data skips the whole fetch detour. useEffect(() => { - if (!header || !playerContainer.current || events.length === 0) return; + if (!header || !playerContainer.current) return; + // Asciicast v2 ch values: "o" (output), "i" (input), "r" (resize). + // Drop anything else so a stray malformed line can't derail parsing. + const playable = events.filter(([, ch]) => ch === 'o' || ch === 'i' || ch === 'r'); + if (playable.length === 0) return; + if (playerInstance.current) { try { playerInstance.current.dispose(); } catch { /* ignore */ } playerInstance.current = null; } - const cast = buildCastBlob(header, events); - const blob = new Blob([cast], { type: 'application/x-asciicast' }); - const url = URL.createObjectURL(blob); - playerInstance.current = AsciinemaPlayer.create(url, playerContainer.current, { - fit: 'width', - terminalFontSize: '12px', - }); + const cast = buildCastBlob(header, playable); + try { + playerInstance.current = AsciinemaPlayer.create( + { data: cast }, + playerContainer.current, + { fit: 'width', terminalFontSize: '12px' }, + ); + } catch (err) { + // Surface to console so we have a fingerprint next time a cast + // parses as "valid" but refuses to play. + console.error('asciinema-player failed to mount', err); + } return () => { - URL.revokeObjectURL(url); if (playerInstance.current) { try { playerInstance.current.dispose(); } catch { /* ignore */ } playerInstance.current = null;