perf(app): defer media capture routing and skip offscreen skeleton layout (#1732)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-19 02:58:01 +02:00
1 parent 591e9fe2ba
commit 698469fa96
9 files changed
+168 -34

No files matched your search

@@ -19,10 +19,12 @@ export interface PlaceholderMessageGroup {
readonly usernameWidth: number; readonly usernameWidth: number;
readonly timestampWidth: number; readonly timestampWidth: number;
readonly attachment: PlaceholderAttachmentSize | null; readonly attachment: PlaceholderAttachmentSize | null;
readonly height: number;
} }
interface MutablePlaceholderMessageGroup extends PlaceholderMessageGroup { interface MutablePlaceholderMessageGroup extends PlaceholderMessageGroup {
attachment: PlaceholderAttachmentSize | null; attachment: PlaceholderAttachmentSize | null;
height: number;
} }
export interface PlaceholderSpecs { export interface PlaceholderSpecs {
@@ -100,20 +102,20 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh
for (let line = 0; line < lineCount; line++) { for (let line = 0; line < lineCount; line++) {
lineWidths.push(LINE_WIDTH_MIN + random() * LINE_WIDTH_RANGE); lineWidths.push(LINE_WIDTH_MIN + random() * LINE_WIDTH_RANGE);
} }
const groupHeight = compact
? MESSAGE_HEIGHT_COMPACT * lineCount
: COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (lineCount - 1);
groups.push({ groups.push({
lineWidths, lineWidths,
usernameWidth: USERNAME_WIDTH_MIN + random() * USERNAME_WIDTH_RANGE, usernameWidth: USERNAME_WIDTH_MIN + random() * USERNAME_WIDTH_RANGE,
timestampWidth: TIMESTAMP_WIDTH_MIN + random() * TIMESTAMP_WIDTH_RANGE, timestampWidth: TIMESTAMP_WIDTH_MIN + random() * TIMESTAMP_WIDTH_RANGE,
attachment: null, attachment: null,
height: groupHeight,
}); });
if (index > 0) { if (index > 0) {
totalHeight += groupSpacing; totalHeight += groupSpacing;
} }
if (compact) { totalHeight += groupHeight;
totalHeight += MESSAGE_HEIGHT_COMPACT * lineCount;
} else {
totalHeight += COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (lineCount - 1);
}
} }
const availableGroupIndices = Array.from(Array(groups.length).keys()); const availableGroupIndices = Array.from(Array(groups.length).keys());
for (let index = 0; index < attachments && availableGroupIndices.length > 0; index++) { for (let index = 0; index < attachments && availableGroupIndices.length > 0; index++) {
@@ -123,6 +125,7 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh
height: randomInRange(random, ATTACHMENT_HEIGHT_MIN, ATTACHMENT_HEIGHT_MAX), height: randomInRange(random, ATTACHMENT_HEIGHT_MIN, ATTACHMENT_HEIGHT_MAX),
}; };
groups[groupIndex].attachment = attachment; groups[groupIndex].attachment = attachment;
groups[groupIndex].height += attachment.height + ATTACHMENT_MARGIN;
totalHeight += attachment.height + ATTACHMENT_MARGIN; totalHeight += attachment.height + ATTACHMENT_MARGIN;
} }
return {compact, compactAvatarsVisible, groups, totalHeight, groupSpacing}; return {compact, compactAvatarsVisible, groups, totalHeight, groupSpacing};
@@ -44,6 +44,7 @@
.messageGroup { .messageGroup {
position: relative; position: relative;
z-index: 0; z-index: 0;
content-visibility: auto;
} }
.group { .group {
@@ -52,7 +52,10 @@ function CozyScrollFillerSkeletonGroup({
return ( return (
<flx-message-list-skeleton-group <flx-message-list-skeleton-group
className={flxElementClassName(styles.messageGroup)} className={flxElementClassName(styles.messageGroup)}
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}} style={{
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
}}
data-flx="app.skeleton.scroll-filler-skeleton.cozy-scroll-filler-skeleton-group.message-group" data-flx="app.skeleton.scroll-filler-skeleton.cozy-scroll-filler-skeleton-group.message-group"
> >
<flx-message-list-skeleton-group-row <flx-message-list-skeleton-group-row
@@ -170,7 +173,10 @@ function CompactScrollFillerSkeletonGroup({
return ( return (
<flx-message-list-skeleton-group <flx-message-list-skeleton-group
className={flxElementClassName(styles.messageGroup)} className={flxElementClassName(styles.messageGroup)}
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}} style={{
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
}}
data-flx="app.skeleton.scroll-filler-skeleton.compact-scroll-filler-skeleton-group.message-group" data-flx="app.skeleton.scroll-filler-skeleton.compact-scroll-filler-skeleton-group.message-group"
> >
<flx-message-list-skeleton-compact-messages <flx-message-list-skeleton-compact-messages
@@ -16,7 +16,6 @@ class Initialization {
state: InitializationState = InitializationState.LOADING; state: InitializationState = InitializationState.LOADING;
hasCompletedInitialLoad = false; hasCompletedInitialLoad = false;
error: string | null = null; error: string | null = null;
readyPayload: unknown = null;
constructor() { constructor() {
makeAutoObservable(this, {}, {autoBind: true}); makeAutoObservable(this, {}, {autoBind: true});
@@ -46,29 +45,25 @@ class Initialization {
setLoading(): void { setLoading(): void {
this.state = InitializationState.LOADING; this.state = InitializationState.LOADING;
this.error = null; this.error = null;
this.readyPayload = null;
} }
@action @action
setConnecting(): void { setConnecting(): void {
this.state = InitializationState.CONNECTING; this.state = InitializationState.CONNECTING;
this.error = null; this.error = null;
this.readyPayload = null;
} }
@action @action
setReady(payload: unknown): void { setReady(): void {
this.state = InitializationState.READY; this.state = InitializationState.READY;
this.hasCompletedInitialLoad = true; this.hasCompletedInitialLoad = true;
this.error = null; this.error = null;
this.readyPayload = payload;
} }
@action @action
setError(error: string): void { setError(error: string): void {
this.state = InitializationState.ERROR; this.state = InitializationState.ERROR;
this.error = error; this.error = error;
this.readyPayload = null;
} }
@action @action
@@ -76,7 +71,6 @@ class Initialization {
this.state = InitializationState.LOADING; this.state = InitializationState.LOADING;
this.hasCompletedInitialLoad = false; this.hasCompletedInitialLoad = false;
this.error = null; this.error = null;
this.readyPayload = null;
} }
} }
@@ -183,7 +183,7 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext)
GuildReadState.handleConnectionOpen(); GuildReadState.handleConnectionOpen();
Presence.handleConnectionOpen(data.user, guilds, data.presences); Presence.handleConnectionOpen(data.user, guilds, data.presences);
MediaEngine.handleConnectionOpen(guilds); MediaEngine.handleConnectionOpen(guilds);
Initialization.setReady(data); Initialization.setReady();
context.setReady(); context.setReady();
Messages.handleConnectionOpen((channelId) => ReadStates.getUnreadJumpAnchor(channelId)); Messages.handleConnectionOpen((channelId) => ReadStates.getUnreadJumpAnchor(channelId));
} }
@@ -236,10 +236,33 @@ export function getSoundCaptureMasterGainNode(): GainNode {
return getMasterGainNode(); return getMasterGainNode();
} }
const soundCaptureActivationListeners: Set<() => void> = new Set();
export function isSoundCaptureActive(): boolean {
return captureTaps.size > 0;
}
export function onSoundCaptureActivated(listener: () => void): () => void {
soundCaptureActivationListeners.add(listener);
return () => {
soundCaptureActivationListeners.delete(listener);
};
}
export function addSoundCaptureDestination(node: AudioNode): void { export function addSoundCaptureDestination(node: AudioNode): void {
if (captureTaps.has(node)) return; if (captureTaps.has(node)) return;
const master = getMasterGainNode(); const master = getMasterGainNode();
const wasInactive = captureTaps.size === 0;
captureTaps.add(node); captureTaps.add(node);
if (wasInactive) {
for (const listener of soundCaptureActivationListeners) {
try {
listener();
} catch (error) {
logger.warn('Sound capture activation listener failed', error);
}
}
}
try { try {
master.connect(node); master.connect(node);
} catch (error) { } catch (error) {
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeEach, describe, expect, it, vi} from 'vitest';
const master = {connect: vi.fn(), disconnect: vi.fn()};
const createdGains: Array<{connect: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn>}> = [];
let captureActive = true;
vi.mock('@app/features/notification/utils/SoundUtils', () => ({
getSoundCaptureAudioContext: () => ({
createMediaElementSource: vi.fn(() => ({connect: vi.fn(), disconnect: vi.fn()})),
createGain: vi.fn(() => {
const gain = {connect: vi.fn(), disconnect: vi.fn(), gain: {value: 1}};
createdGains.push(gain);
return gain;
}),
}),
getSoundCaptureMasterGainNode: () => master,
isSoundCaptureActive: () => captureActive,
onSoundCaptureActivated: () => () => undefined,
}));
const {routeMediaElementForSoundCapture} = await import('@app/features/voice/utils/InAppMediaSoundCapture');
function createElement(): HTMLMediaElement {
const listeners = new Map<string, () => void>();
return {
volume: 1,
muted: false,
addEventListener: (type: string, listener: () => void) => listeners.set(type, listener),
removeEventListener: (type: string) => listeners.delete(type),
listenerCount: () => listeners.size,
} as unknown as HTMLMediaElement & {listenerCount: () => number};
}
describe('in-app media sound capture', () => {
beforeEach(() => {
createdGains.length = 0;
captureActive = true;
});
it('disconnects the element from the master graph when the caller disposes', () => {
const element = createElement();
const dispose = routeMediaElementForSoundCapture(element);
const gain = createdGains.at(-1);
expect(gain?.connect).toHaveBeenCalledWith(master);
dispose();
expect(gain?.disconnect).toHaveBeenCalled();
});
it('removes the volume listener when the caller disposes', () => {
const element = createElement() as HTMLMediaElement & {listenerCount: () => number};
const dispose = routeMediaElementForSoundCapture(element);
expect(element.listenerCount()).toBe(1);
dispose();
expect(element.listenerCount()).toBe(0);
});
it('reuses the existing source node when the same element is routed again', () => {
const element = createElement();
routeMediaElementForSoundCapture(element)();
const gainsAfterFirst = createdGains.length;
const dispose = routeMediaElementForSoundCapture(element);
expect(createdGains.length).toBe(gainsAfterFirst);
expect(createdGains.at(-1)?.connect).toHaveBeenCalledTimes(2);
dispose();
});
});
@@ -1,17 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {getSoundCaptureAudioContext, getSoundCaptureMasterGainNode} from '@app/features/notification/utils/SoundUtils'; import {
getSoundCaptureAudioContext,
getSoundCaptureMasterGainNode,
isSoundCaptureActive,
onSoundCaptureActivated,
} from '@app/features/notification/utils/SoundUtils';
import {Logger} from '@app/features/platform/utils/AppLogger'; import {Logger} from '@app/features/platform/utils/AppLogger';
const logger = new Logger('InAppMediaSoundCapture'); const logger = new Logger('InAppMediaSoundCapture');
interface RoutedElement { interface RoutedElement {
connected: boolean;
source: MediaElementAudioSourceNode; source: MediaElementAudioSourceNode;
gain: GainNode; gain: GainNode;
volumeListener: () => void; volumeListener: () => void;
} }
const routedElements: WeakMap<HTMLMediaElement, RoutedElement> = new WeakMap(); const routedElements: WeakMap<HTMLMediaElement, RoutedElement> = new WeakMap();
const pendingElements: Set<WeakRef<HTMLMediaElement>> = new Set();
function syncGainFromElement(el: HTMLMediaElement, gain: GainNode): void { function syncGainFromElement(el: HTMLMediaElement, gain: GainNode): void {
const volume = el.muted ? 0 : Math.max(0, Math.min(1, el.volume)); const volume = el.muted ? 0 : Math.max(0, Math.min(1, el.volume));
@@ -20,9 +27,20 @@ function syncGainFromElement(el: HTMLMediaElement, gain: GainNode): void {
} catch {} } catch {}
} }
export function routeMediaElementForSoundCapture(element: HTMLMediaElement): () => void { function attachToCaptureGraph(element: HTMLMediaElement): void {
if (routedElements.has(element)) { const existing = routedElements.get(element);
return () => undefined; if (existing) {
if (existing.connected) return;
try {
existing.gain.connect(getSoundCaptureMasterGainNode());
} catch (error) {
logger.debug('Failed to reconnect media element to sound capture graph', {error});
return;
}
element.addEventListener('volumechange', existing.volumeListener);
syncGainFromElement(element, existing.gain);
existing.connected = true;
return;
} }
let ctx: AudioContext; let ctx: AudioContext;
let master: GainNode; let master: GainNode;
@@ -31,7 +49,7 @@ export function routeMediaElementForSoundCapture(element: HTMLMediaElement): ()
master = getSoundCaptureMasterGainNode(); master = getSoundCaptureMasterGainNode();
} catch (error) { } catch (error) {
logger.debug('Sound capture context unavailable; leaving media element on native playback', {error}); logger.debug('Sound capture context unavailable; leaving media element on native playback', {error});
return () => undefined; return;
} }
let source: MediaElementAudioSourceNode; let source: MediaElementAudioSourceNode;
let gain: GainNode; let gain: GainNode;
@@ -42,23 +60,45 @@ export function routeMediaElementForSoundCapture(element: HTMLMediaElement): ()
gain.connect(master); gain.connect(master);
} catch (error) { } catch (error) {
logger.debug('Failed to route media element through sound capture graph', {error}); logger.debug('Failed to route media element through sound capture graph', {error});
return () => undefined; return;
} }
syncGainFromElement(element, gain); syncGainFromElement(element, gain);
const volumeListener = (): void => syncGainFromElement(element, gain); const volumeListener = (): void => syncGainFromElement(element, gain);
element.addEventListener('volumechange', volumeListener); element.addEventListener('volumechange', volumeListener);
const routed: RoutedElement = {source, gain, volumeListener}; routedElements.set(element, {source, gain, volumeListener, connected: true});
routedElements.set(element, routed); }
function detachFromCaptureGraph(element: HTMLMediaElement): void {
const entry = routedElements.get(element);
if (!entry || !entry.connected) return;
entry.connected = false;
element.removeEventListener('volumechange', entry.volumeListener);
try {
entry.gain.disconnect();
} catch {}
}
function flushPendingElements(): void {
for (const ref of pendingElements) {
const element = ref.deref();
pendingElements.delete(ref);
if (element) {
attachToCaptureGraph(element);
}
}
}
onSoundCaptureActivated(flushPendingElements);
export function routeMediaElementForSoundCapture(element: HTMLMediaElement): () => void {
if (isSoundCaptureActive()) {
attachToCaptureGraph(element);
return () => detachFromCaptureGraph(element);
}
const ref = new WeakRef(element);
pendingElements.add(ref);
return () => { return () => {
const entry = routedElements.get(element); pendingElements.delete(ref);
if (!entry || entry !== routed) return; detachFromCaptureGraph(element);
routedElements.delete(element);
element.removeEventListener('volumechange', volumeListener);
try {
gain.disconnect();
} catch {}
try {
source.disconnect();
} catch {}
}; };
} }
@@ -27,7 +27,6 @@ export function startMediaDeviceStartupPreload(): () => void {
logger.debug('Failed to preload media devices', {error}); logger.debug('Failed to preload media devices', {error});
}); });
}; };
preloadDevices();
const disposePermissionListener = MediaPermission.addChangeListener(preloadDevices); const disposePermissionListener = MediaPermission.addChangeListener(preloadDevices);
return () => { return () => {
stopped = true; stopped = true;