diff --git a/apps/desktop/src-tauri/src/screen_sources.rs b/apps/desktop/src-tauri/src/screen_sources.rs index d50f865..866dbff 100644 --- a/apps/desktop/src-tauri/src/screen_sources.rs +++ b/apps/desktop/src-tauri/src/screen_sources.rs @@ -23,8 +23,14 @@ use base64::Engine; use image::{ImageBuffer, Rgba}; use serde::Serialize; -const THUMB_MAX_W: u32 = 320; -const THUMB_MAX_H: u32 = 180; +// Thumbnail dimensions chosen to balance grid legibility against IPC +// payload size. JPEG at Q70 / 240×135 lands around 12-25 KB per source; +// PNG at the same dimensions was 60-150 KB which blocked the JS main +// thread for 100+ ms per arrival when a full enumeration of 20+ windows +// came back in parallel. +const THUMB_MAX_W: u32 = 240; +const THUMB_MAX_H: u32 = 135; +const THUMB_JPEG_QUALITY: u8 = 70; #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] @@ -149,7 +155,7 @@ fn monitor_label(m: &xcap::Monitor, idx: usize) -> String { fn capture_monitor_thumbnail(m: &xcap::Monitor) -> Option { let image = m.capture_image().ok()?; - encode_scaled_png(image) + encode_scaled_jpeg(image) } // --------------------------------------------------------------------------- @@ -234,7 +240,7 @@ fn is_shareable_window(w: &xcap::Window) -> bool { fn capture_window_thumbnail(w: &xcap::Window) -> Option { let image = w.capture_image().ok()?; - encode_scaled_png(image) + encode_scaled_jpeg(image) } // --------------------------------------------------------------------------- @@ -243,9 +249,12 @@ fn capture_window_thumbnail(w: &xcap::Window) -> Option { // Letterbox-shrink the captured image so the long edge is at most // THUMB_MAX_W / THUMB_MAX_H. Keeps aspect ratio, skips upscaling entirely -// (tiny windows stay their captured size). Returns a base64 PNG string or -// None if encoding fails. -fn encode_scaled_png(src: ImageBuffer, Vec>) -> Option { +// (tiny windows stay their captured size). Returns a base64 JPEG string +// (no data-URL prefix) or None if encoding fails. JPEG is used rather +// than PNG because thumbnails are lossy-friendly previews and the +// 5-8× size reduction meaningfully unblocks the JS main thread when a +// full enumeration comes back in parallel. +fn encode_scaled_jpeg(src: ImageBuffer, Vec>) -> Option { let (w, h) = src.dimensions(); if w == 0 || h == 0 { return None; @@ -260,20 +269,20 @@ fn encode_scaled_png(src: ImageBuffer, Vec>) -> Option { } else { src }; - let mut buf: Vec = Vec::new(); - let encoder = image::codecs::png::PngEncoder::new_with_quality( - &mut buf, - image::codecs::png::CompressionType::Fast, - image::codecs::png::FilterType::Adaptive, - ); - use image::ImageEncoder; - encoder - .write_image( - scaled.as_raw(), - scaled.width(), - scaled.height(), - image::ExtendedColorType::Rgba8, - ) - .ok()?; + // JPEG encoder doesn't accept RGBA — strip alpha into a packed RGB + // buffer first. Alpha carries no info for a visible thumbnail anyway. + let (sw, sh) = (scaled.width(), scaled.height()); + let mut rgb: Vec = Vec::with_capacity((sw * sh * 3) as usize); + for p in scaled.pixels() { + rgb.extend_from_slice(&[p.0[0], p.0[1], p.0[2]]); + } + let mut buf: Vec = Vec::with_capacity((sw * sh / 8) as usize); + { + use image::codecs::jpeg::JpegEncoder; + let mut encoder = JpegEncoder::new_with_quality(&mut buf, THUMB_JPEG_QUALITY); + encoder + .encode(&rgb, sw, sh, image::ExtendedColorType::Rgb8) + .ok()?; + } Some(base64::engine::general_purpose::STANDARD.encode(&buf)) } diff --git a/apps/desktop/src/components/ScreenSourcePicker.tsx b/apps/desktop/src/components/ScreenSourcePicker.tsx index f8b6d09..0b47381 100644 --- a/apps/desktop/src/components/ScreenSourcePicker.tsx +++ b/apps/desktop/src/components/ScreenSourcePicker.tsx @@ -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) { + {includeAudio && selectedId && ( +

+ {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.', + })} +

+ )} {error && (

{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({ ); -} +}); diff --git a/apps/desktop/src/lib/screenSources.ts b/apps/desktop/src/lib/screenSources.ts index fead693..e36e3c6 100644 --- a/apps/desktop/src/lib/screenSources.ts +++ b/apps/desktop/src/lib/screenSources.ts @@ -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 { } // 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 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; }