fix(voice): show buffering while screen share recovery runs (#2682)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-11 15:03:21 +02:00
1 parent 53b3fa2f4a
commit c541b86c00
7 files changed
+302 -49

No files matched your search

@@ -5,12 +5,16 @@ import {
transitionVoiceMediaGraph,
type VoiceMediaGraphSnapshot,
} from '@app/features/voice/engine/VoiceMediaGraph';
import {selectVoiceMediaGraphStreamTileState} from '@app/features/voice/engine/VoiceMediaGraphTileState';
import {
selectVoiceMediaGraphStreamTileState,
VOICE_MEDIA_GRAPH_FIRST_FRAME_RECOVERY_VISIBLE_MS,
} from '@app/features/voice/engine/VoiceMediaGraphTileState';
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
import {describe, expect, it} from 'vitest';
const STREAM_KEY = 'dm:channel-a:connection-a';
const PARTICIPANT_IDENTITY = 'user_2_connection-a';
const FAILURE_REPORTED_AT = 300;
const target = {
streamKey: STREAM_KEY,
@@ -42,6 +46,24 @@ function attach(graph: VoiceMediaGraphSnapshot): VoiceMediaGraphSnapshot {
});
}
function reportFailure(graph: VoiceMediaGraphSnapshot, code: number, reason: string): VoiceMediaGraphSnapshot {
return transitionVoiceMediaGraph(graph, {
type: 'failure.reported',
failure: {
code,
reason,
reportedAt: FAILURE_REPORTED_AT,
streamKey: STREAM_KEY,
participantIdentity: PARTICIPANT_IDENTITY,
source: 'screen_share',
},
});
}
function watchedWithFailure(code: number, reason: string): VoiceMediaGraphSnapshot {
return reportFailure(attach(subscribe(createVoiceMediaGraphSnapshot(), true)), code, reason);
}
describe('selectVoiceMediaGraphStreamTileState', () => {
it('returns idle when the graph has no state for the tile', () => {
expect(selectVoiceMediaGraphStreamTileState(createVoiceMediaGraphSnapshot(), target)).toBe('idle');
@@ -140,6 +162,51 @@ describe('selectVoiceMediaGraphStreamTileState', () => {
expect(selectVoiceMediaGraphStreamTileState(graph, target)).toBe('subscribedAwaitingFrame');
});
it('returns recovering while a first-frame timeout still has recovery attempts left', () => {
const graph = watchedWithFailure(-2303, 'first-frame-timeout');
expect(
selectVoiceMediaGraphStreamTileState(graph, target, {
hasRecoveryBudget: true,
nowMs: FAILURE_REPORTED_AT + 1000,
}),
).toBe('recovering');
});
it('returns failed once the first-frame recovery budget is spent', () => {
const graph = watchedWithFailure(-2303, 'first-frame-timeout');
expect(
selectVoiceMediaGraphStreamTileState(graph, target, {
hasRecoveryBudget: false,
nowMs: FAILURE_REPORTED_AT + 1000,
}),
).toBe('failed');
});
it('returns failed once a first-frame timeout has been on screen for the visible cap', () => {
const graph = watchedWithFailure(-2303, 'first-frame-timeout');
expect(
selectVoiceMediaGraphStreamTileState(graph, target, {
hasRecoveryBudget: true,
nowMs: FAILURE_REPORTED_AT + VOICE_MEDIA_GRAPH_FIRST_FRAME_RECOVERY_VISIBLE_MS,
}),
).toBe('failed');
});
it('returns failed for a first-frame timeout when the caller tracks no recovery', () => {
const graph = watchedWithFailure(-2303, 'first-frame-timeout');
expect(selectVoiceMediaGraphStreamTileState(graph, target)).toBe('failed');
});
it('keeps every other failure code failed while recovery attempts remain', () => {
const graph = watchedWithFailure(-2202, 'remote-track-subscription-failed');
expect(
selectVoiceMediaGraphStreamTileState(graph, target, {
hasRecoveryBudget: true,
nowMs: FAILURE_REPORTED_AT + 1000,
}),
).toBe('failed');
});
it('returns watchDesired for entry-less streams and idle after the watch ends', () => {
let graph = transitionVoiceMediaGraph(createVoiceMediaGraphSnapshot(), {
type: 'watchIntent.add',
@@ -4,11 +4,15 @@ import {
selectVoiceMediaGraphAttempt,
selectVoiceMediaGraphFailure,
selectVoiceMediaGraphSubscriptionEntry,
type VoiceMediaGraphFailure,
type VoiceMediaGraphSnapshot,
type VoiceMediaGraphSubscriptionEntry,
} from './VoiceMediaGraph';
import {VOICE_MEDIA_GRAPH_FIRST_FRAME_TIMEOUT_FAILURE} from './VoiceMediaGraphDeadlines';
import type {VoiceTrackSource} from './VoiceTrackSource';
export const VOICE_MEDIA_GRAPH_FIRST_FRAME_RECOVERY_VISIBLE_MS = 30_000;
export type VoiceMediaGraphStreamTileState =
| 'idle'
| 'watchDesired'
@@ -16,6 +20,7 @@ export type VoiceMediaGraphStreamTileState =
| 'attaching'
| 'subscribedAwaitingFrame'
| 'rendering'
| 'recovering'
| 'failed';
export interface VoiceMediaGraphStreamTileTarget {
@@ -24,14 +29,32 @@ export interface VoiceMediaGraphStreamTileTarget {
source: VoiceTrackSource;
}
function tileHasFailure(snapshot: VoiceMediaGraphSnapshot, target: VoiceMediaGraphStreamTileTarget): boolean {
if (!target.streamKey && !target.participantIdentity) return false;
const failure = selectVoiceMediaGraphFailure(snapshot, {
export interface VoiceMediaGraphStreamTileRecovery {
hasRecoveryBudget: boolean;
nowMs: number;
}
function selectTileFailure(
snapshot: VoiceMediaGraphSnapshot,
target: VoiceMediaGraphStreamTileTarget,
): VoiceMediaGraphFailure | null {
if (!target.streamKey && !target.participantIdentity) return null;
return selectVoiceMediaGraphFailure(snapshot, {
streamKey: target.streamKey,
participantIdentity: target.participantIdentity,
source: target.source,
});
return failure !== null;
}
function selectFailureTileState(
failure: VoiceMediaGraphFailure,
recovery: VoiceMediaGraphStreamTileRecovery | undefined,
): VoiceMediaGraphStreamTileState {
if (!recovery) return 'failed';
if (failure.code !== VOICE_MEDIA_GRAPH_FIRST_FRAME_TIMEOUT_FAILURE.code) return 'failed';
if (!recovery.hasRecoveryBudget) return 'failed';
if (recovery.nowMs - failure.reportedAt >= VOICE_MEDIA_GRAPH_FIRST_FRAME_RECOVERY_VISIBLE_MS) return 'failed';
return 'recovering';
}
function tileIsRendering(
@@ -48,11 +71,13 @@ function tileIsRendering(
export function selectVoiceMediaGraphStreamTileState(
snapshot: VoiceMediaGraphSnapshot,
target: VoiceMediaGraphStreamTileTarget,
recovery?: VoiceMediaGraphStreamTileRecovery,
): VoiceMediaGraphStreamTileState {
const entry = target.participantIdentity
? selectVoiceMediaGraphSubscriptionEntry(snapshot, target.participantIdentity, target.source)
: null;
if (tileHasFailure(snapshot, target)) return 'failed';
const failure = selectTileFailure(snapshot, target);
if (failure !== null) return selectFailureTileState(failure, recovery);
if (tileIsRendering(snapshot, target, entry)) return 'rendering';
if (entry?.actual.lastError) return 'failed';
if (entry?.actual.subscribed === true) return 'subscribedAwaitingFrame';