perf(call): JPEG thumbnails + memoized picker cards unfreeze the grid

The picker still felt frozen while thumbnails were streaming in because
each result was both (a) large — PNG @ 320×180 landed at 60-150 KB
base64 — and (b) triggering a high-priority React re-render of the whole
grid. Three fixes together restore interactivity:

- Thumbnails encoded as JPEG @ Q70 at 240×135 instead of PNG @ 320×180.
  Drops the typical payload from ~100 KB to ~20 KB, so IPC JSON-parsing
  on arrival is 5× faster.
- SourceCard wrapped in React.memo so only the card whose thumbnail just
  landed re-renders. Previously one new thumbnail caused all ~20 cards
  to re-evaluate their props.
- setSources updates run inside startTransition so scroll / click events
  stay on the high-priority lane while the grid backfills.

Also: when the user enables "Sound mit übertragen" AND has a source
picked, the picker now surfaces an inline amber note explaining that
the OS picker will appear for the audio capture path. Matches the
existing console info log but is visible pre-click so users don't
experience it as a bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-04-22 21:58:05 +02:00
parent a5e930ac17
commit eac19823ea
3 changed files with 68 additions and 35 deletions
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { memo, startTransition, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -74,13 +74,20 @@ export function ScreenSourcePicker({ open, onClose, onStart }: Props) {
const png = await captureScreenSourceThumbnail(src.id);
if (cancelled) return;
if (png !== null) {
setSources((prev) => {
if (!prev) return prev;
const idx = prev.findIndex((s) => s.id === src.id);
if (idx === -1) return prev;
const next = prev.slice();
next[idx] = { ...prev[idx]!, thumbnailPng: png };
return next;
// startTransition marks the setState as low-priority so the
// browser keeps processing scroll / click events between
// thumbnail arrivals. Without this, 20 state updates land
// as high-priority work and the grid freezes until they all
// flush.
startTransition(() => {
setSources((prev) => {
if (!prev) return prev;
const idx = prev.findIndex((s) => s.id === src.id);
if (idx === -1) return prev;
const next = prev.slice();
next[idx] = { ...prev[idx]!, thumbnailPng: png };
return next;
});
});
}
const nextSrc = queue.shift();
@@ -238,6 +245,14 @@ export function ScreenSourcePicker({ open, onClose, onStart }: Props) {
</span>
</label>
</div>
{includeAudio && selectedId && (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-700 dark:text-amber-300">
{t('app:call.share_audio_uses_os_picker', {
defaultValue:
'Mit System-Sound fragt der Browser noch einmal nach der Quelle — Video-Direktpfad geht nur ohne Audio.',
})}
</p>
)}
{error && (
<p role="alert" className="text-xs text-rose-600 dark:text-rose-300">
{error}
@@ -303,7 +318,11 @@ function SourceSection({
);
}
function SourceCard({
// Memoized so a thumbnail arriving for card B doesn't re-render card A.
// Keeps re-render work proportional to the number of updates instead of
// "whole grid on every update" — which was the main reason scrolling felt
// frozen during the initial thumbnail fan-in.
const SourceCard = memo(function SourceCard({
source,
selected,
onClick,
@@ -345,4 +364,4 @@ function SourceCard({
</div>
</button>
);
}
});
+8 -3
View File
@@ -12,7 +12,10 @@ export interface ScreenSource {
id: string;
name: string;
kind: ScreenSourceKind;
/** Base64-encoded PNG without a data-URL prefix. Null when capture failed. */
/** Base64-encoded JPEG without a data-URL prefix. Null when capture failed.
* Kept under `thumbnailPng` key for rollout stability — the server-side
* format switched from PNG to JPEG for payload size, but the field name
* preserves the wire contract during the transition. */
thumbnailPng: string | null;
width: number;
height: number;
@@ -69,8 +72,10 @@ export async function enumerateScreenSources(): Promise<ScreenSource[]> {
}
// Data-URL helper — picker tiles bind `src={thumbnailDataUrl(src)}` so the
// thumbnail bytes never leave the component's render pass.
// thumbnail bytes never leave the component's render pass. Rust encodes
// JPEG now (smaller payload, faster decode); the mime type here must
// match or the <img> element silently fails to paint.
export function thumbnailDataUrl(src: ScreenSource): string | null {
if (!src.thumbnailPng) return null;
return 'data:image/png;base64,' + src.thumbnailPng;
return 'data:image/jpeg;base64,' + src.thumbnailPng;
}