mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(voice): show buffering while screen share recovery runs (#2682)
This commit is contained in:
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;
|
||||
|
||||
+94
-14
@@ -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);
|
||||
});
|
||||
});
|
||||
+74
-24
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user