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

@@ -76,6 +76,7 @@ import {
} from '@app/features/voice/components/voice_participant_tile/hooks';
import LastFrameSnapshotCache from '@app/features/voice/components/voice_participant_tile/LastFrameSnapshotCache';
import {ScreenSharePlaceholder} from '@app/features/voice/components/voice_participant_tile/ScreenSharePlaceholder';
import {screenShareVideoSubscriptionRecoveryCoordinator} from '@app/features/voice/components/voice_participant_tile/ScreenShareVideoSubscriptionRecovery';
import {
CAMERA_BUFFERING_DESCRIPTOR,
CAMERA_HIDDEN_DESCRIPTOR,
@@ -113,6 +114,7 @@ import {
selectVoiceMediaGraphDeferredStopKeys,
selectVoiceMediaGraphFailure,
selectVoiceMediaGraphViewerStreamKeys,
selectVoiceMediaGraphWatchGeneration,
} from '@app/features/voice/engine/VoiceMediaGraph';
import {voiceMediaGraphStore} from '@app/features/voice/engine/VoiceMediaGraphStore';
import {selectVoiceMediaGraphStreamTileState} from '@app/features/voice/engine/VoiceMediaGraphTileState';
@@ -405,11 +407,22 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
const isConnectedToTileChannel =
Boolean(channelId) && MediaEngine.channelId === channelId && MediaEngine.guildId === (guildId ?? null);
const isWatching = isConnectedToTileChannel && graphViewerStreamKeys.includes(streamKey);
const graphTileState = selectVoiceMediaGraphStreamTileState(graphSnapshot, {
streamKey: streamKey || null,
participantIdentity: identity || null,
source: VoiceTrackSource.ScreenShare,
});
const graphWatchGeneration = selectVoiceMediaGraphWatchGeneration(graphSnapshot, streamKey);
const graphTileState = selectVoiceMediaGraphStreamTileState(
graphSnapshot,
{
streamKey: streamKey || null,
participantIdentity: identity || null,
source: VoiceTrackSource.ScreenShare,
},
{
hasRecoveryBudget: screenShareVideoSubscriptionRecoveryCoordinator.hasFirstFrameRecoveryBudget(
streamKey,
graphWatchGeneration,
),
nowMs: voiceMediaGraphStore.nowMs(),
},
);
const graphWatchFailure = isScreenShare
? selectVoiceMediaGraphFailure(graphSnapshot, {
streamKey,
@@ -30,6 +30,7 @@ const GRAPH_TILE_STATES: ReadonlyArray<VoiceMediaGraphStreamTileState> = [
'attaching',
'subscribedAwaitingFrame',
'rendering',
'recovering',
'failed',
];
@@ -39,6 +40,7 @@ const WATCH_INTENT_GRAPH_TILE_STATES: ReadonlyArray<VoiceMediaGraphStreamTileSta
'attaching',
'subscribedAwaitingFrame',
'rendering',
'recovering',
'failed',
];
@@ -217,6 +219,18 @@ describe('VoiceParticipantTileStateMachine graph-derived screen share state', ()
expect(selectVoiceParticipantTileScreenShareState(signals({graphTileState: 'rendering'}))).toBe('idle');
});
it('shows buffering while screen share recovery is still running', () => {
expect(selectVoiceParticipantTileScreenShareState(signals({graphTileState: 'recovering'}))).toBe('buffering');
expect(
selectVoiceParticipantTileScreenShareState(signals({graphTileState: 'recovering', isTrackReference: false})),
).toBe('buffering');
});
it('holds the error code back until recovery has given up', () => {
expect(shouldShowWatchFailed(signals({graphTileState: 'recovering'}))).toBe(false);
expect(shouldShowWatchFailed(signals({graphTileState: 'failed'}))).toBe(true);
});
it('shows the watch failed overlay when the graph reports a failure', () => {
expect(selectVoiceParticipantTileScreenShareState(signals({graphTileState: 'failed'}))).toBe('watchFailed');
expect(
@@ -87,6 +87,8 @@ export function graphTileStateHoldsWatchIntent(graphTileState: VoiceMediaGraphSt
return true;
case 'rendering':
return true;
case 'recovering':
return true;
case 'failed':
return true;
case 'idle':
@@ -109,6 +111,8 @@ export function shouldShowScreenShareBuffering(signals: VoiceParticipantTileScre
return true;
case 'subscribedAwaitingFrame':
return true;
case 'recovering':
return true;
case 'publicationMissing':
if (signals.isRepublishGracePending) return true;
return signals.isTrackReference;
@@ -79,9 +79,9 @@ function createWatchedSnapshot(failure: Partial<VoiceMediaGraphFailure> | null):
});
}
function createGraphPort(snapshot: VoiceMediaGraphSnapshot): ScreenShareVideoSubscriptionRecoveryGraph {
function createGraphPort(getSnapshot: () => VoiceMediaGraphSnapshot): ScreenShareVideoSubscriptionRecoveryGraph {
return {
getGraphSnapshot: () => snapshot,
getGraphSnapshot: getSnapshot,
nowMs: () => 1000,
transition: () => undefined,
};
@@ -95,20 +95,47 @@ function createReceivablePublication(): ScreenShareVideoSubscriptionRecoveryPubl
};
}
function acquireWatchingTile(failure: Partial<VoiceMediaGraphFailure> | null) {
const snapshot = createWatchedSnapshot(failure);
function createMutedPublication(): ScreenShareVideoSubscriptionRecoveryPublication {
return {
trackSid: TRACK_SID,
isSubscribed: true,
track: {mediaStreamTrack: {readyState: 'live', muted: true}},
};
}
function createWatchingTile(
failure: Partial<VoiceMediaGraphFailure> | null,
publication: ScreenShareVideoSubscriptionRecoveryPublication = createReceivablePublication(),
) {
let snapshot = createWatchedSnapshot(failure);
const {scheduler, fire, pendingCount, scheduledDelays} = createScheduler();
const coordinator = new ScreenShareVideoSubscriptionRecoveryCoordinator(scheduler, createGraphPort(snapshot));
const coordinator = new ScreenShareVideoSubscriptionRecoveryCoordinator(
scheduler,
createGraphPort(() => snapshot),
);
const recover = vi.fn();
coordinator.acquire({
key: TRACK_SID,
publication: createReceivablePublication(),
streamKey: STREAM_KEY,
participantIdentity: PARTICIPANT_IDENTITY,
isStillWanted: () => isScreenShareVideoSubscriptionRecoveryWanted(snapshot, STREAM_KEY),
recover,
});
return {coordinator, fire, pendingCount, scheduledDelays, recover};
const acquire = () =>
coordinator.acquire({
key: TRACK_SID,
publication,
streamKey: STREAM_KEY,
participantIdentity: PARTICIPANT_IDENTITY,
isStillWanted: () => isScreenShareVideoSubscriptionRecoveryWanted(snapshot, STREAM_KEY),
recover,
});
const restartWatch = (nextFailure: Partial<VoiceMediaGraphFailure> | null) => {
snapshot = createWatchedSnapshot(nextFailure);
};
return {coordinator, fire, pendingCount, scheduledDelays, recover, acquire, restartWatch};
}
function acquireWatchingTile(
failure: Partial<VoiceMediaGraphFailure> | null,
publication?: ScreenShareVideoSubscriptionRecoveryPublication,
) {
const tile = createWatchingTile(failure, publication);
tile.acquire();
return tile;
}
describe('isScreenShareVideoSubscriptionRecoveryWanted', () => {
@@ -180,4 +207,57 @@ describe('ScreenShareVideoSubscriptionRecoveryCoordinator first-frame recovery',
expect(recover).not.toHaveBeenCalled();
expect(coordinator.getActiveSessionCount()).toBe(0);
});
it('keeps the recovery count when the lease is released and taken again', () => {
const {coordinator, fire, recover, acquire} = createWatchingTile(FIRST_FRAME_TIMEOUT_FAILURE);
const release = acquire();
fire();
expect(recover).toHaveBeenCalledTimes(1);
release();
expect(coordinator.getActiveSessionCount()).toBe(0);
acquire();
fire();
fire();
expect(recover).toHaveBeenCalledTimes(3);
fire();
expect(recover).toHaveBeenCalledTimes(3);
expect(coordinator.getActiveSessionCount()).toBe(0);
});
it('hands the next watch of the same stream a fresh recovery budget', () => {
const {fire, recover, acquire, restartWatch} = createWatchingTile(FIRST_FRAME_TIMEOUT_FAILURE);
const release = acquire();
fire();
fire();
fire();
expect(recover).toHaveBeenCalledTimes(3);
release();
restartWatch(null);
acquire();
restartWatch(FIRST_FRAME_TIMEOUT_FAILURE);
fire();
expect(recover).toHaveBeenCalledTimes(4);
});
it('leaves a muted track alone until a failure is recorded', () => {
const {coordinator, fire, recover} = acquireWatchingTile(null, createMutedPublication());
fire();
fire();
expect(recover).not.toHaveBeenCalled();
expect(coordinator.getActiveSessionCount()).toBe(1);
});
});
@@ -62,7 +62,6 @@ interface ScreenShareVideoSubscriptionRecoverySession {
key: string;
leaseCount: number;
attempt: number;
firstFrameRecoveries: number;
timeoutId: number | null;
graphGeneration: number | null;
options: ScreenShareVideoSubscriptionRecoveryLeaseOptions;
@@ -74,6 +73,7 @@ const SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS = 2500;
const SCREEN_SHARE_VIDEO_SUBSCRIPTION_REFRESH_ATTEMPTS = 0;
const SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_ATTEMPTS = 3;
const SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_DELAY_MS = 20_000;
const SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_RECORDS = 32;
const defaultScheduler: ScreenShareVideoSubscriptionRecoveryScheduler = {
setTimeout: (callback, delayMs) => window.setTimeout(callback, delayMs),
@@ -97,20 +97,28 @@ function asScreenShareRecoveryTrackLike(track: unknown): ScreenShareRecoveryTrac
return track as ScreenShareRecoveryTrackLike;
}
function isTrackReceivable(track: ScreenShareRecoveryTrackLike): boolean {
function isTrackReceivable(track: ScreenShareRecoveryTrackLike, {allowMuted}: {allowMuted: boolean}): boolean {
const mediaStreamTrack = track.mediaStreamTrack;
if (mediaStreamTrack?.readyState === 'ended') return false;
if (allowMuted) return true;
if (mediaStreamTrack?.muted === true) return false;
if (track.isMuted === true) return false;
if (track.muted === true) return false;
return true;
}
function hasReceivableTrack(publication: ScreenShareVideoSubscriptionRecoveryPublication): boolean {
function hasReceivableTrack(
publication: ScreenShareVideoSubscriptionRecoveryPublication,
{allowMuted}: {allowMuted: boolean},
): boolean {
if (publication.isSubscribed === false) return false;
const track = asScreenShareRecoveryTrackLike(publication.track);
if (track === null) return false;
return isTrackReceivable(track);
return isTrackReceivable(track, {allowMuted});
}
function firstFrameRecoveryKey(streamKey: string, generation: number): string {
return `${streamKey}:${generation}`;
}
export function isScreenShareVideoSubscriptionRecoveryWanted(
@@ -151,6 +159,7 @@ export function selectScreenShareVideoSubscriptionRecoveryMode(
export class ScreenShareVideoSubscriptionRecoveryCoordinator {
private readonly sessions = new Map<string, ScreenShareVideoSubscriptionRecoverySession>();
private readonly firstFrameRecoveriesByWatch = new Map<string, number>();
private readonly scheduler: ScreenShareVideoSubscriptionRecoveryScheduler;
private readonly graph: ScreenShareVideoSubscriptionRecoveryGraph;
@@ -163,13 +172,13 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
}
acquire(options: ScreenShareVideoSubscriptionRecoveryLeaseOptions): () => void {
this.resetFirstFrameRecoveriesUnlessStalled(options.streamKey ?? null);
let session = this.sessions.get(options.key);
if (!session) {
session = {
key: options.key,
leaseCount: 0,
attempt: 0,
firstFrameRecoveries: 0,
timeoutId: null,
graphGeneration: null,
options,
@@ -198,6 +207,19 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
return this.sessions.size;
}
getFirstFrameRecoveryCount(streamKey: string | null | undefined, generation: number): number {
if (!streamKey) return 0;
return this.firstFrameRecoveriesByWatch.get(firstFrameRecoveryKey(streamKey, generation)) ?? 0;
}
hasFirstFrameRecoveryBudget(streamKey: string | null | undefined, generation: number): boolean {
if (!streamKey) return false;
return (
this.getFirstFrameRecoveryCount(streamKey, generation) <
SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_ATTEMPTS
);
}
private release(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
@@ -219,7 +241,8 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
private ensureScheduled(session: ScreenShareVideoSubscriptionRecoverySession): void {
if (session.timeoutId !== null) return;
const delayMs = hasReceivableTrack(session.options.publication)
const allowMuted = this.firstFrameStalledStreamKey(session) === null;
const delayMs = hasReceivableTrack(session.options.publication, {allowMuted})
? SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS
: getScreenShareVideoSubscriptionRetryDelayMs(session.attempt + 1);
this.schedule(session, delayMs);
@@ -230,9 +253,13 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
session.timeoutId = this.scheduler.setTimeout(() => this.run(session.key), delayMs);
}
private watchGeneration(streamKey: string): number {
return selectVoiceMediaGraphWatchGeneration(this.graph.getGraphSnapshot(), streamKey);
}
private captureGraphGeneration(streamKey: string | null): number | null {
if (!streamKey) return null;
return selectVoiceMediaGraphWatchGeneration(this.graph.getGraphSnapshot(), streamKey);
return this.watchGeneration(streamKey);
}
private isGraphStateCurrent(session: ScreenShareVideoSubscriptionRecoverySession): boolean {
@@ -243,13 +270,35 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
return selectVoiceMediaGraphWatchGeneration(snapshot, streamKey) === session.graphGeneration;
}
private hasFirstFrameTimeoutFailure(session: ScreenShareVideoSubscriptionRecoverySession): boolean {
const streamKey = session.options.streamKey ?? null;
if (!streamKey) return false;
private hasFirstFrameTimeout(streamKey: string): boolean {
const failure = selectVoiceMediaGraphFailure(this.graph.getGraphSnapshot(), {streamKey});
return failure?.code === ScreenShareWatchErrorCode.FirstFrameTimeout;
}
private firstFrameStalledStreamKey(session: ScreenShareVideoSubscriptionRecoverySession): string | null {
const streamKey = session.options.streamKey ?? null;
if (!streamKey) return null;
return this.hasFirstFrameTimeout(streamKey) ? streamKey : null;
}
private resetFirstFrameRecoveriesUnlessStalled(streamKey: string | null): void {
if (!streamKey) return;
if (this.hasFirstFrameTimeout(streamKey)) return;
this.firstFrameRecoveriesByWatch.delete(firstFrameRecoveryKey(streamKey, this.watchGeneration(streamKey)));
}
private noteFirstFrameRecovery(streamKey: string, generation: number): void {
const key = firstFrameRecoveryKey(streamKey, generation);
const next = this.getFirstFrameRecoveryCount(streamKey, generation) + 1;
this.firstFrameRecoveriesByWatch.delete(key);
this.firstFrameRecoveriesByWatch.set(key, next);
while (this.firstFrameRecoveriesByWatch.size > SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_RECORDS) {
const oldest = this.firstFrameRecoveriesByWatch.keys().next().value;
if (oldest === undefined) break;
this.firstFrameRecoveriesByWatch.delete(oldest);
}
}
private run(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
@@ -259,20 +308,21 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
this.closeSession(session);
return;
}
const firstFrameStalled = this.hasFirstFrameTimeoutFailure(session);
if (
firstFrameStalled &&
session.firstFrameRecoveries >= SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_ATTEMPTS
) {
this.closeSession(session);
return;
const stalledStreamKey = this.firstFrameStalledStreamKey(session);
if (stalledStreamKey === null) {
if (hasReceivableTrack(options.publication, {allowMuted: true})) {
session.attempt = 0;
this.schedule(session, SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS);
return;
}
} else {
const generation = this.watchGeneration(stalledStreamKey);
if (!this.hasFirstFrameRecoveryBudget(stalledStreamKey, generation)) {
this.closeSession(session);
return;
}
this.noteFirstFrameRecovery(stalledStreamKey, generation);
}
if (!firstFrameStalled && hasReceivableTrack(options.publication)) {
session.attempt = 0;
this.schedule(session, SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS);
return;
}
if (firstFrameStalled) session.firstFrameRecoveries += 1;
session.attempt += 1;
const mode = selectScreenShareVideoSubscriptionRecoveryMode(session.attempt);
const delayMs = getScreenShareVideoSubscriptionRetryDelayMs(session.attempt);
@@ -286,7 +336,7 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
this.recoverPublication(session, mode);
this.schedule(
session,
firstFrameStalled
stalledStreamKey !== null
? SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_DELAY_MS
: getScreenShareVideoSubscriptionRetryDelayMs(session.attempt + 1),
);
@@ -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';