fix(web/session): feed asciinema-player inline data, not a Blob URL

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: <string>} 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.
This commit is contained in:
2026-04-24 01:26:07 -04:00
parent ec2360a5da
commit e684feb1fe

View File

@@ -117,22 +117,37 @@ const SessionDrawer: React.FC<SessionDrawerProps> = ({ 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;