mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
refactor(voice): remove screen share codec renegotiation (#2417)
This commit is contained in:
1 parent
f5f60c66e7
commit
c97bc53342
80 files changed
+1360
-4726
No files matched your search
@@ -58,7 +58,6 @@ import {
|
||||
saveCurrentVoiceSessionRestoreSnapshot,
|
||||
type VoiceSessionRestoreSyncHandle,
|
||||
} from '@app/features/voice/engine/media_engine_facade/VoiceSessionRestoreSync';
|
||||
import ScreenShareCodecNegotiation from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import ScreenSharePublicationMigration from '@app/features/voice/engine/ScreenSharePublicationMigration';
|
||||
import {Store, useStoreVersion} from '@app/features/voice/engine/Store';
|
||||
import {shouldMoveToAfkOnTick} from '@app/features/voice/engine/VoiceAfkTracking';
|
||||
@@ -101,11 +100,6 @@ import {
|
||||
} from '@app/features/voice/engine/VoiceStreamWatchState';
|
||||
import {type VoiceConnectionQuality, VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
|
||||
import {bindVoiceEngineV2AppAudioPreferencesSync} from '@app/features/voice/engine/v2/VoiceEngineV2AppAudioPreferencesSyncBinding';
|
||||
import {getLocalDecodableVideoCodecs} from '@app/features/voice/engine/v2/VoiceEngineV2AppCodecCapability';
|
||||
import {
|
||||
computeVoiceEngineV2WatchedStreamGossip,
|
||||
ingestVoiceEngineV2CodecGossip,
|
||||
} from '@app/features/voice/engine/v2/VoiceEngineV2AppCodecGossipAdapter';
|
||||
import voiceEngineV2AppConnectionHostAdapter, {
|
||||
type VoiceServerUpdateData,
|
||||
} from '@app/features/voice/engine/v2/VoiceEngineV2AppConnectionHostAdapter';
|
||||
@@ -149,7 +143,6 @@ import VoiceCallLayout from '@app/features/voice/state/VoiceCallLayout';
|
||||
import VoiceRegionTeleport from '@app/features/voice/state/VoiceRegionTeleport';
|
||||
import VoiceSessionRestore, {type VoiceSessionRestoreSnapshot} from '@app/features/voice/state/VoiceSessionRestore';
|
||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import type {CodecPreference} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {setNativeAudioCaptureBridgeLifecycleBridge} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
||||
import {areOrderedStringArraysEqual} from '@app/features/voice/utils/StringArrayUtils';
|
||||
import {buildVoiceParticipantIdentity} from '@app/features/voice/utils/VoiceParticipantIdentity';
|
||||
@@ -171,29 +164,22 @@ import type {
|
||||
VoiceEngineV2DisconnectReason,
|
||||
VoiceEngineV2GatewayVoiceState,
|
||||
VoiceEngineV2LatencyDataPoint,
|
||||
VoiceEngineV2LocalStreamSource,
|
||||
VoiceEngineV2MicrophoneOptions,
|
||||
VoiceEngineV2Model,
|
||||
VoiceEngineV2PerTrackStats,
|
||||
VoiceEngineV2Snapshot,
|
||||
VoiceEngineV2StatsSample,
|
||||
VoiceEngineV2TransportInfo,
|
||||
VoiceEngineV2VideoCodec,
|
||||
VoiceEngineV2VoiceStats,
|
||||
} from '@fluxer/voice_engine_v2';
|
||||
import {
|
||||
encodeVoiceEngineV2CodecGossip,
|
||||
shouldApplyGatewayVoiceStateEcho,
|
||||
VOICE_ENGINE_V2_CODEC_GOSSIP_TOPIC,
|
||||
} from '@fluxer/voice_engine_v2';
|
||||
import {shouldApplyGatewayVoiceStateEcho} from '@fluxer/voice_engine_v2';
|
||||
import {type I18n, i18n as linguiI18n, type MessageDescriptor} from '@lingui/core';
|
||||
import {
|
||||
type ConnectionQuality as LiveKitConnectionQuality,
|
||||
type Participant,
|
||||
type Room,
|
||||
RoomEvent,
|
||||
type ScreenShareCaptureOptions,
|
||||
type TrackPublishOptions,
|
||||
import type {
|
||||
ConnectionQuality as LiveKitConnectionQuality,
|
||||
Participant,
|
||||
Room,
|
||||
ScreenShareCaptureOptions,
|
||||
TrackPublishOptions,
|
||||
} from 'livekit-client';
|
||||
import {makeObservable, observable} from 'mobx';
|
||||
|
||||
@@ -278,14 +264,8 @@ function buildRoomEventDependencies(facade: {
|
||||
didChangeLocalState,
|
||||
publication,
|
||||
),
|
||||
isScreenShareCodecRepublishInFlight: () =>
|
||||
voiceEngineV2AppScreenShareExecutionAdapter.isScreenShareCodecRepublishInFlight(),
|
||||
renegotiateActiveScreenShareCodec: (activeRoom, selection) =>
|
||||
voiceEngineV2AppScreenShareExecutionAdapter.renegotiateActiveScreenShareCodec(
|
||||
activeRoom,
|
||||
selection.codec,
|
||||
selection.reason,
|
||||
),
|
||||
isScreenSharePublicationReplaceInFlight: () =>
|
||||
voiceEngineV2AppScreenShareExecutionAdapter.isScreenSharePublicationReplaceInFlight(),
|
||||
},
|
||||
subscriptions: {
|
||||
isScreenShareSubscribed: (participantIdentity) =>
|
||||
@@ -336,17 +316,9 @@ class MediaEngineFacade extends Store {
|
||||
private voiceSessionRestoreSync: VoiceSessionRestoreSyncHandle | null = null;
|
||||
private outputDeviceSyncDisposer: (() => void) | null = null;
|
||||
private audioPreferencesSyncDisposer: (() => void) | null = null;
|
||||
private videoCodecDecodeCapResyncDisposer: (() => void) | null = null;
|
||||
private videoCodecPublishOverrideSyncDisposer: (() => void) | null = null;
|
||||
private localStreamCodecReconcileScheduled = false;
|
||||
private localAudioReconcileCoalescer: VoiceLocalAudioReconcileCoalescerSnapshot =
|
||||
createVoiceLocalAudioReconcileCoalescerSnapshot();
|
||||
private applyingGatewayVoiceStateEcho = false;
|
||||
private previousWatchedStreamCodecGossip = new Map<
|
||||
string,
|
||||
{identity: string; source: VoiceEngineV2LocalStreamSource}
|
||||
>();
|
||||
private videoCodecGossipReceiverDisposer: (() => void) | null = null;
|
||||
private i18n: I18n | null = linguiI18n;
|
||||
private pendingServerDisconnectTimeout: NodeJS.Timeout | null = null;
|
||||
private facadeSnapshot: MediaEngineFacadeSnapshot = createMediaEngineFacadeSnapshot();
|
||||
@@ -497,7 +469,6 @@ class MediaEngineFacade extends Store {
|
||||
|
||||
private initializeEngineStoreSync(): void {
|
||||
const forwardChange = () => {
|
||||
this.scheduleLocalStreamCodecReconcile();
|
||||
this.emitChange();
|
||||
};
|
||||
this.voiceEngineV2ProjectionStore.subscribe(forwardChange);
|
||||
@@ -766,17 +737,6 @@ class MediaEngineFacade extends Store {
|
||||
await voiceEngineV2AppMediaExecutionAdapter.refreshCameraCapture();
|
||||
}
|
||||
|
||||
async refreshActiveScreenShareCodecNegotiation(): Promise<void> {
|
||||
const room = this.room;
|
||||
const selection = await ScreenShareCodecNegotiation.publishLocalCapabilities(room, 'manual', {});
|
||||
const codec =
|
||||
selection?.codec ??
|
||||
ScreenShareCodecNegotiation.selectScreenShareCodec(VoiceSettings.getPreferredScreenShareCodec());
|
||||
await voiceEngineV2AppScreenShareExecutionAdapter.renegotiateActiveScreenShareCodec(room, codec, 'manual', {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
private reconcileLocalAudioStateInBackground(reason: string): void {
|
||||
const previous = this.localAudioReconcileCoalescer;
|
||||
const next = transitionVoiceLocalAudioReconcileCoalescerSnapshot(previous, {type: 'run.requested', reason});
|
||||
@@ -2343,12 +2303,6 @@ class MediaEngineFacade extends Store {
|
||||
this.outputDeviceSyncDisposer = bindOutputDeviceSync(room);
|
||||
this.audioPreferencesSyncDisposer?.();
|
||||
this.audioPreferencesSyncDisposer = this.bindAudioPreferencesSync(room);
|
||||
this.videoCodecDecodeCapResyncDisposer?.();
|
||||
this.videoCodecDecodeCapResyncDisposer = this.bindVideoDecodeCapResync();
|
||||
this.videoCodecPublishOverrideSyncDisposer?.();
|
||||
this.videoCodecPublishOverrideSyncDisposer = this.bindVideoCodecPublishOverrideSync();
|
||||
this.videoCodecGossipReceiverDisposer?.();
|
||||
this.videoCodecGossipReceiverDisposer = this.bindVideoCodecGossipReceiver(room);
|
||||
this.startAfkTracking();
|
||||
const channelId = voiceEngineV2AppConnectionHostAdapter.channelId;
|
||||
if (channelId) {
|
||||
@@ -2373,13 +2327,6 @@ class MediaEngineFacade extends Store {
|
||||
this.outputDeviceSyncDisposer = null;
|
||||
this.audioPreferencesSyncDisposer?.();
|
||||
this.audioPreferencesSyncDisposer = null;
|
||||
this.videoCodecDecodeCapResyncDisposer?.();
|
||||
this.videoCodecDecodeCapResyncDisposer = null;
|
||||
this.videoCodecPublishOverrideSyncDisposer?.();
|
||||
this.videoCodecPublishOverrideSyncDisposer = null;
|
||||
this.videoCodecGossipReceiverDisposer?.();
|
||||
this.videoCodecGossipReceiverDisposer = null;
|
||||
this.previousWatchedStreamCodecGossip = new Map();
|
||||
this.stopAfkTracking();
|
||||
logger.info('All tracking stopped');
|
||||
}
|
||||
@@ -2389,103 +2336,6 @@ class MediaEngineFacade extends Store {
|
||||
voiceEngineV2AppConnectionHostAdapter.abortConnection();
|
||||
}
|
||||
|
||||
private bindVideoCodecPublishOverrideSync(): () => void {
|
||||
assert.ok(this.voiceEngineV2Host != null, 'video codec publish override sync requires the v2 host');
|
||||
const apply = (source: VoiceEngineV2LocalStreamSource, preference: CodecPreference): void => {
|
||||
this.voiceEngineV2Controller.setVideoCodecOverride(source, preference === 'auto' ? null : preference);
|
||||
};
|
||||
let previousScreen: CodecPreference = VoiceSettings.getPreferredScreenShareCodec();
|
||||
let previousCamera: CodecPreference = VoiceSettings.getPreferredVideoCodec();
|
||||
apply('screen', previousScreen);
|
||||
apply('camera', previousCamera);
|
||||
return VoiceSettings.subscribe(() => {
|
||||
const screen = VoiceSettings.getPreferredScreenShareCodec();
|
||||
if (screen !== previousScreen) {
|
||||
previousScreen = screen;
|
||||
apply('screen', screen);
|
||||
}
|
||||
const camera = VoiceSettings.getPreferredVideoCodec();
|
||||
if (camera !== previousCamera) {
|
||||
previousCamera = camera;
|
||||
apply('camera', camera);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private bindVideoDecodeCapResync(): () => void {
|
||||
assert.ok(this.voiceEngineV2Host != null, 'video decode cap resync requires the v2 host');
|
||||
let previous: CodecPreference = VoiceSettings.getEmulatedDecodeVideoCodecCap();
|
||||
return VoiceSettings.subscribe(() => {
|
||||
const current = VoiceSettings.getEmulatedDecodeVideoCodecCap();
|
||||
if (current === previous) return;
|
||||
previous = current;
|
||||
this.previousWatchedStreamCodecGossip = new Map();
|
||||
this.syncWatchedStreamCodecGossip();
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleLocalStreamCodecReconcile(): void {
|
||||
if (this.localStreamCodecReconcileScheduled) return;
|
||||
this.localStreamCodecReconcileScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this.localStreamCodecReconcileScheduled = false;
|
||||
if (this.voiceEngineV2Host == null) return;
|
||||
this.reconcileLocalStreamCodec('camera', this.voiceEngineV2Snapshot.camera);
|
||||
this.reconcileLocalStreamCodec('screen', this.voiceEngineV2Snapshot.screen);
|
||||
this.syncWatchedStreamCodecGossip();
|
||||
});
|
||||
}
|
||||
|
||||
private syncWatchedStreamCodecGossip(): void {
|
||||
const result = computeVoiceEngineV2WatchedStreamGossip(
|
||||
this.previousWatchedStreamCodecGossip,
|
||||
this.voiceEngineV2Snapshot.watchedStreams,
|
||||
getLocalDecodableVideoCodecs(VoiceSettings.getEmulatedDecodeVideoCodecCap()),
|
||||
);
|
||||
this.previousWatchedStreamCodecGossip = result.next;
|
||||
for (const {destinationIdentity, message} of result.messages) {
|
||||
this.voiceEngineV2Controller.publishData({
|
||||
payload: encodeVoiceEngineV2CodecGossip(message),
|
||||
reliable: true,
|
||||
topic: VOICE_ENGINE_V2_CODEC_GOSSIP_TOPIC,
|
||||
destinationIdentities: [destinationIdentity],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private bindVideoCodecGossipReceiver(room: Room): () => void {
|
||||
const handler = (payload: Uint8Array, participant?: Participant, _kind?: unknown, topic?: string): void => {
|
||||
if (topic !== VOICE_ENGINE_V2_CODEC_GOSSIP_TOPIC) return;
|
||||
const identity = participant?.identity;
|
||||
if (!identity) return;
|
||||
ingestVoiceEngineV2CodecGossip(this.voiceEngineV2Controller, identity, payload);
|
||||
};
|
||||
room.on(RoomEvent.DataReceived, handler);
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handler);
|
||||
};
|
||||
}
|
||||
|
||||
private reconcileLocalStreamCodec(
|
||||
source: VoiceEngineV2LocalStreamSource,
|
||||
media: {
|
||||
status: string;
|
||||
published: {codec?: VoiceEngineV2VideoCodec} | null;
|
||||
desired: {codec?: VoiceEngineV2VideoCodec} | null;
|
||||
},
|
||||
): void {
|
||||
const registered = this.voiceEngineV2Snapshot.codecNegotiation.streams[source] != null;
|
||||
const active = media.published != null || media.status === 'publishing';
|
||||
if (!active) {
|
||||
if (registered) this.voiceEngineV2Controller.unregisterLocalStreamCodec(source);
|
||||
return;
|
||||
}
|
||||
if (registered) return;
|
||||
const codec: VoiceEngineV2VideoCodec = media.published?.codec ?? media.desired?.codec ?? '';
|
||||
const streamIdentity = `${source}:${crypto.randomUUID()}`;
|
||||
this.voiceEngineV2Controller.registerLocalStreamCodec(source, streamIdentity, codec);
|
||||
}
|
||||
|
||||
private bindAudioPreferencesSync(room: Room): () => void {
|
||||
return bindVoiceEngineV2AppAudioPreferencesSync(
|
||||
room,
|
||||
|
||||
@@ -72,7 +72,7 @@ const VIDEO_CODEC_PROTOCOL_TABLE: Record<
|
||||
type FluxerVideoCodecName = 'AV1' | 'H265' | 'H264' | 'VP9' | 'VP8';
|
||||
type FluxerCodecName = 'opus' | FluxerVideoCodecName;
|
||||
type FluxerCodecType = 'audio' | 'video';
|
||||
export type NegotiationReason =
|
||||
type NegotiationReason =
|
||||
| 'connected'
|
||||
| 'data'
|
||||
| 'participant-connected'
|
||||
@@ -115,7 +115,7 @@ export interface FluxerSessionUpdateMessage {
|
||||
|
||||
export type FluxerCodecNegotiationMessage = FluxerSelectProtocolMessage | FluxerSessionUpdateMessage;
|
||||
|
||||
export interface CodecNegotiationSelection {
|
||||
interface CodecNegotiationSelection {
|
||||
codec: VideoCodec;
|
||||
reason: NegotiationReason;
|
||||
candidates: Array<VideoCodec>;
|
||||
@@ -138,10 +138,6 @@ type ScreenShareCodecNegotiationMachineEvent =
|
||||
}
|
||||
| {type: 'negotiation.reset'};
|
||||
|
||||
interface BindOptions {
|
||||
onSelectedCodecChanged?: (selection: CodecNegotiationSelection) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function createId(prefix: string): string {
|
||||
const cryptoObject = globalThis.crypto as Crypto | undefined;
|
||||
if (typeof cryptoObject?.randomUUID === 'function') return `${prefix}_${cryptoObject.randomUUID()}`;
|
||||
@@ -502,12 +498,10 @@ class ScreenShareCodecNegotiation {
|
||||
private selectedCodec: VideoCodec | null = null;
|
||||
private localCodecs: Array<FluxerCodecAdvertisement> = [];
|
||||
private remoteCodecsByIdentity = new Map<string, Array<FluxerCodecAdvertisement>>();
|
||||
private bindOptions: BindOptions = {};
|
||||
private rtcConnectionId = createId('rtc');
|
||||
private mediaSessionId = createId('media');
|
||||
private negotiationSnapshot = createScreenShareCodecNegotiationSnapshot();
|
||||
private bindingRevision = 0;
|
||||
private selectionRevision = 0;
|
||||
|
||||
getSelectedCodec(): VideoCodec | null {
|
||||
return this.selectedCodec;
|
||||
@@ -585,10 +579,9 @@ class ScreenShareCodecNegotiation {
|
||||
return {knownRemoteCodecs, unknownParticipants};
|
||||
}
|
||||
|
||||
bind(room: Room, options: BindOptions = {}): () => void {
|
||||
bind(room: Room): () => void {
|
||||
this.dispose();
|
||||
this.room = room;
|
||||
this.bindOptions = options;
|
||||
const bindingRevision = this.bindingRevision;
|
||||
const onDataReceived = (
|
||||
payload: Uint8Array,
|
||||
@@ -597,24 +590,24 @@ class ScreenShareCodecNegotiation {
|
||||
topic?: string,
|
||||
): void => {
|
||||
if (topic !== PROTOCOL_TOPIC || !participant) return;
|
||||
this.handleDataMessage(room, participant, payload, options, bindingRevision);
|
||||
this.handleDataMessage(room, participant, payload, bindingRevision);
|
||||
};
|
||||
const onParticipantConnected = (): void => {
|
||||
void this.publishBoundLocalCapabilities(room, 'participant-connected', options, bindingRevision);
|
||||
void this.publishBoundLocalCapabilities(room, 'participant-connected', bindingRevision);
|
||||
};
|
||||
const onParticipantDisconnected = (participant: Participant): void => {
|
||||
if (!this.isBindingCurrent(room, bindingRevision)) return;
|
||||
this.remoteCodecsByIdentity.delete(participant.identity);
|
||||
void this.updateSelection(room, 'participant-disconnected', options, bindingRevision);
|
||||
void this.updateSelection(room, 'participant-disconnected', bindingRevision);
|
||||
};
|
||||
const onReconnected = (): void => {
|
||||
void this.publishBoundLocalCapabilities(room, 'reconnected', options, bindingRevision);
|
||||
void this.publishBoundLocalCapabilities(room, 'reconnected', bindingRevision);
|
||||
};
|
||||
room.on(RoomEvent.DataReceived, onDataReceived);
|
||||
room.on(RoomEvent.ParticipantConnected, onParticipantConnected);
|
||||
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
||||
room.on(RoomEvent.Reconnected, onReconnected);
|
||||
void this.publishBoundLocalCapabilities(room, 'connected', options, bindingRevision);
|
||||
void this.publishBoundLocalCapabilities(room, 'connected', bindingRevision);
|
||||
this.bindDisposer = () => {
|
||||
room.off(RoomEvent.DataReceived, onDataReceived);
|
||||
room.off(RoomEvent.ParticipantConnected, onParticipantConnected);
|
||||
@@ -626,13 +619,11 @@ class ScreenShareCodecNegotiation {
|
||||
|
||||
dispose(): void {
|
||||
this.bindingRevision += 1;
|
||||
this.selectionRevision += 1;
|
||||
this.bindDisposer?.();
|
||||
this.bindDisposer = null;
|
||||
this.room = null;
|
||||
this.selectedCodec = null;
|
||||
this.localCodecs = [];
|
||||
this.bindOptions = {};
|
||||
this.remoteCodecsByIdentity.clear();
|
||||
this.mediaSessionId = createId('media');
|
||||
this.negotiationSnapshot = createScreenShareCodecNegotiationSnapshot();
|
||||
@@ -641,16 +632,14 @@ class ScreenShareCodecNegotiation {
|
||||
async publishLocalCapabilities(
|
||||
room: Room | null = this.room,
|
||||
reason: NegotiationReason = 'manual',
|
||||
options: BindOptions = this.bindOptions,
|
||||
): Promise<CodecNegotiationSelection | null> {
|
||||
if (!room) return null;
|
||||
return await this.publishBoundLocalCapabilities(room, reason, options, this.bindingRevision);
|
||||
return await this.publishBoundLocalCapabilities(room, reason, this.bindingRevision);
|
||||
}
|
||||
|
||||
private async publishBoundLocalCapabilities(
|
||||
room: Room,
|
||||
reason: NegotiationReason,
|
||||
options: BindOptions,
|
||||
bindingRevision: number,
|
||||
): Promise<CodecNegotiationSelection | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
@@ -677,22 +666,16 @@ class ScreenShareCodecNegotiation {
|
||||
};
|
||||
await this.publishMessage(room, message);
|
||||
if (!this.isBindingCurrent(room, bindingRevision)) return null;
|
||||
return await this.updateSelection(room, reason, options, bindingRevision);
|
||||
return await this.updateSelection(room, reason, bindingRevision);
|
||||
}
|
||||
|
||||
private handleDataMessage(
|
||||
room: Room,
|
||||
participant: Participant,
|
||||
payload: Uint8Array,
|
||||
options: BindOptions,
|
||||
bindingRevision: number,
|
||||
): void {
|
||||
private handleDataMessage(room: Room, participant: Participant, payload: Uint8Array, bindingRevision: number): void {
|
||||
if (!this.isBindingCurrent(room, bindingRevision)) return;
|
||||
const message = parseMessage(payload);
|
||||
if (!message) return;
|
||||
if (message.op === SELECT_PROTOCOL_OP) {
|
||||
this.remoteCodecsByIdentity.set(participant.identity, message.d.codecs);
|
||||
void this.updateSelection(room, 'data', options, bindingRevision);
|
||||
void this.updateSelection(room, 'data', bindingRevision);
|
||||
} else if (message.op === SESSION_UPDATE_OP) {
|
||||
this.remoteCodecsByIdentity.set(participant.identity, message.d.codecs);
|
||||
logger.debug('Received remote codec session update', {
|
||||
@@ -701,14 +684,13 @@ class ScreenShareCodecNegotiation {
|
||||
mediaSessionId: message.d.media_session_id,
|
||||
reason: message.d.reason,
|
||||
});
|
||||
void this.updateSelection(room, 'data', options, bindingRevision);
|
||||
void this.updateSelection(room, 'data', bindingRevision);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateSelection(
|
||||
room: Room,
|
||||
reason: NegotiationReason,
|
||||
options: BindOptions,
|
||||
bindingRevision: number,
|
||||
): Promise<CodecNegotiationSelection | null> {
|
||||
if (!room.localParticipant || !this.isBindingCurrent(room, bindingRevision)) return null;
|
||||
@@ -733,13 +715,10 @@ class ScreenShareCodecNegotiation {
|
||||
if (!selection) return null;
|
||||
this.selectedCodec = selection.codec;
|
||||
if (selection.codec === previousCodec) return selection;
|
||||
const selectionRevision = ++this.selectionRevision;
|
||||
this.mediaSessionId = createId('media');
|
||||
logger.info('Selected screen share codec from XState capability intersection', selection);
|
||||
await this.publishSessionUpdate(room, selection);
|
||||
if (!this.isBindingCurrent(room, bindingRevision) || selectionRevision !== this.selectionRevision) return null;
|
||||
await options.onSelectedCodecChanged?.(selection);
|
||||
if (!this.isBindingCurrent(room, bindingRevision) || selectionRevision !== this.selectionRevision) return null;
|
||||
if (!this.isBindingCurrent(room, bindingRevision)) return null;
|
||||
return selection;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,20 +3,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {
|
||||
createLocalMigrationReadinessState,
|
||||
isLocalMigrationReadinessComplete,
|
||||
type ScreenShareLocalMigrationReadinessState,
|
||||
type ScreenShareMigrationReadinessResult,
|
||||
type ScreenShareRemoteMigrationEvent,
|
||||
type ScreenShareRemoteMigrationState,
|
||||
selectLocalMigrationReadinessResult,
|
||||
transitionLocalMigrationReadinessState,
|
||||
transitionRemoteScreenShareMigrationState,
|
||||
} from '@app/features/voice/engine/ScreenSharePublicationMigrationStateMachine';
|
||||
import {Store} from '@app/features/voice/engine/Store';
|
||||
import {selectVoiceMediaGraphViewerStreamKeys} from '@app/features/voice/engine/VoiceMediaGraph';
|
||||
import {voiceMediaGraphStore} from '@app/features/voice/engine/VoiceMediaGraphStore';
|
||||
import {createVoiceMediaIdentity} from '@app/features/voice/engine/VoiceMediaIdentity';
|
||||
import {getStreamKeyForParticipantIdentity} from '@app/features/voice/engine/VoiceStreamWatchState';
|
||||
import {isScreenShareVideoCodecValue} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareNativePublishOptions';
|
||||
import {
|
||||
@@ -47,13 +40,11 @@ const MIGRATION_MESSAGE_BYTES_MAX = 4096;
|
||||
const MIGRATION_IDENTIFIER_CHARS_MAX = 256;
|
||||
const TRACK_SID_CHARS_MAX = 256;
|
||||
const MIGRATION_REASON_CHARS_MAX = 512;
|
||||
const DEFAULT_READY_TIMEOUT_MS = 5000;
|
||||
const REMOTE_READY_PROBE_TIMEOUT_MS = 6500;
|
||||
const REMOTE_MIGRATION_STATE_TIMEOUT_MS = 10000;
|
||||
const REMOTE_READY_PROBE_STATS_INTERVAL_MS = 200;
|
||||
const CANDIDATE_TRACK_NAME_MARKER = '.candidate.';
|
||||
export const REMOTE_MIGRATION_STATES_MAX = 256;
|
||||
export const LOCAL_MIGRATION_SESSIONS_MAX = 16;
|
||||
export const READY_PROBES_MAX = 64;
|
||||
|
||||
export interface ScreenShareMigrationCandidateMessage {
|
||||
@@ -115,34 +106,10 @@ export type ScreenShareMigrationMessage =
|
||||
| ScreenShareMigrationCommitMessage
|
||||
| ScreenShareMigrationAbortMessage;
|
||||
|
||||
interface LocalMigrationInput {
|
||||
room: Room;
|
||||
publisherIdentity: string;
|
||||
migrationId: string;
|
||||
generation: number;
|
||||
previousTrackSid: string | null;
|
||||
candidateTrackSid: string;
|
||||
codec: VideoCodec;
|
||||
reason: string;
|
||||
targetIdentities: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
interface LocalBreakBeforeMakeMigrationInput {
|
||||
migrationId: string;
|
||||
generation: number;
|
||||
previousTrackSid: string | null;
|
||||
codec: VideoCodec;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface ReadyProbe {
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
function createId(prefix: string): string {
|
||||
return `${prefix}_${createVoiceMediaIdentity()}`;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
@@ -344,97 +311,20 @@ function publishMigrationMessage(
|
||||
});
|
||||
}
|
||||
|
||||
class ScreenShareLocalMigrationSession {
|
||||
readonly migrationId: string;
|
||||
readonly generation: number;
|
||||
readonly previousTrackSid: string | null;
|
||||
readonly candidateTrackSid: string;
|
||||
private readinessState: ScreenShareLocalMigrationReadinessState;
|
||||
private waitResolve: ((result: ScreenShareMigrationReadinessResult) => void) | null = null;
|
||||
private waitTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(input: LocalMigrationInput) {
|
||||
this.migrationId = input.migrationId;
|
||||
this.generation = input.generation;
|
||||
this.previousTrackSid = input.previousTrackSid;
|
||||
this.candidateTrackSid = input.candidateTrackSid;
|
||||
this.readinessState = createLocalMigrationReadinessState(input.targetIdentities);
|
||||
}
|
||||
|
||||
markReady(participantIdentity: string): void {
|
||||
this.readinessState = transitionLocalMigrationReadinessState(this.readinessState, {
|
||||
type: 'watcher.ready',
|
||||
participantIdentity,
|
||||
});
|
||||
this.resolveIfComplete(false);
|
||||
}
|
||||
|
||||
waitForReady(timeoutMs = DEFAULT_READY_TIMEOUT_MS): Promise<ScreenShareMigrationReadinessResult> {
|
||||
if (isLocalMigrationReadinessComplete(this.readinessState)) {
|
||||
return Promise.resolve(this.buildResult(false));
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.waitResolve = resolve;
|
||||
this.waitTimer = setTimeout(() => {
|
||||
this.waitTimer = null;
|
||||
this.finish(true);
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
finish(timedOut = false): ScreenShareMigrationReadinessResult {
|
||||
if (this.waitTimer) {
|
||||
clearTimeout(this.waitTimer);
|
||||
this.waitTimer = null;
|
||||
}
|
||||
const result = this.buildResult(timedOut);
|
||||
this.waitResolve?.(result);
|
||||
this.waitResolve = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
private resolveIfComplete(timedOut: boolean): void {
|
||||
if (!isLocalMigrationReadinessComplete(this.readinessState)) return;
|
||||
this.finish(timedOut);
|
||||
}
|
||||
|
||||
private buildResult(timedOut: boolean): ScreenShareMigrationReadinessResult {
|
||||
return selectLocalMigrationReadinessResult(this.readinessState, timedOut);
|
||||
}
|
||||
}
|
||||
|
||||
class ScreenSharePublicationMigration extends Store {
|
||||
private room: Room | null = null;
|
||||
private bindDisposer: (() => void) | null = null;
|
||||
private guildId: string | null = null;
|
||||
private channelId: string | null = null;
|
||||
private remoteStatesByIdentity = new Map<string, ScreenShareRemoteMigrationState>();
|
||||
private localSessionsById = new Map<string, ScreenShareLocalMigrationSession>();
|
||||
private readyProbesByKey = new Map<string, ReadyProbe>();
|
||||
private remoteStateExpiryTimersByIdentity = new Map<string, NodeJS.Timeout>();
|
||||
version = 0;
|
||||
|
||||
createMigrationId(): string {
|
||||
return createId('ssm');
|
||||
}
|
||||
|
||||
getDefaultReadyTimeoutMs(): number {
|
||||
return DEFAULT_READY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
getRemoteMigrationStateTimeoutMs(): number {
|
||||
return REMOTE_MIGRATION_STATE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
shouldAbortLocalMigrationForReadiness(readiness: ScreenShareMigrationReadinessResult): boolean {
|
||||
return readiness.timedOut && readiness.missingIdentities.length > 0;
|
||||
}
|
||||
|
||||
createCandidateTrackName(baseName: string | undefined, migrationId: string, generation: number): string {
|
||||
const prefix = baseName && baseName.length > 0 ? baseName : 'screen_share';
|
||||
return `${prefix}${CANDIDATE_TRACK_NAME_MARKER}${generation}.${migrationId}`;
|
||||
}
|
||||
|
||||
bind(room: Room, options: {guildId?: string | null; channelId?: string | null} = {}): () => void {
|
||||
this.dispose();
|
||||
this.room = room;
|
||||
@@ -485,10 +375,6 @@ class ScreenSharePublicationMigration extends Store {
|
||||
probe.dispose();
|
||||
}
|
||||
this.readyProbesByKey.clear();
|
||||
for (const session of this.localSessionsById.values()) {
|
||||
session.finish(true);
|
||||
}
|
||||
this.localSessionsById.clear();
|
||||
for (const timer of this.remoteStateExpiryTimersByIdentity.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -500,84 +386,6 @@ class ScreenSharePublicationMigration extends Store {
|
||||
this.bumpVersion();
|
||||
}
|
||||
|
||||
beginLocalMigration(input: LocalMigrationInput): ScreenShareLocalMigrationSession {
|
||||
const session = new ScreenShareLocalMigrationSession(input);
|
||||
if (this.hasLocalSessionCapacity(input.migrationId)) {
|
||||
this.localSessionsById.set(input.migrationId, session);
|
||||
}
|
||||
this.commitRemoteMigrationState(input.publisherIdentity, {
|
||||
type: 'migration.candidate',
|
||||
migrationId: input.migrationId,
|
||||
generation: input.generation,
|
||||
previousTrackSid: input.previousTrackSid,
|
||||
candidateTrackSid: input.candidateTrackSid,
|
||||
codec: input.codec,
|
||||
readySent: true,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
markLocalMigrationBreaking(publisherIdentity: string, message: ScreenShareMigrationBreakMessage['d']): void {
|
||||
this.applyBreak(publisherIdentity, message);
|
||||
}
|
||||
|
||||
finishLocalMigration(migrationId: string): void {
|
||||
const session = this.localSessionsById.get(migrationId);
|
||||
session?.finish(false);
|
||||
this.localSessionsById.delete(migrationId);
|
||||
}
|
||||
|
||||
markLocalMigrationCommitted(publisherIdentity: string, message: ScreenShareMigrationCommitMessage['d']): void {
|
||||
this.applyCommit(publisherIdentity, message);
|
||||
this.finishLocalMigration(message.migration_id);
|
||||
}
|
||||
|
||||
markLocalMigrationAborted(publisherIdentity: string, message: ScreenShareMigrationAbortMessage['d']): void {
|
||||
this.applyAbort(publisherIdentity, message);
|
||||
this.finishLocalMigration(message.migration_id);
|
||||
}
|
||||
|
||||
async publishCandidate(room: Room, input: LocalMigrationInput): Promise<void> {
|
||||
await publishMigrationMessage(room.localParticipant, {
|
||||
op: CANDIDATE_OP,
|
||||
d: {
|
||||
migration_id: input.migrationId,
|
||||
generation: input.generation,
|
||||
previous_track_sid: input.previousTrackSid,
|
||||
candidate_track_sid: input.candidateTrackSid,
|
||||
codec: input.codec,
|
||||
reason: input.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async publishBreak(room: Room, input: LocalBreakBeforeMakeMigrationInput): Promise<void> {
|
||||
await publishMigrationMessage(room.localParticipant, {
|
||||
op: BREAK_OP,
|
||||
d: {
|
||||
migration_id: input.migrationId,
|
||||
generation: input.generation,
|
||||
previous_track_sid: input.previousTrackSid,
|
||||
codec: input.codec,
|
||||
reason: input.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async publishCommit(room: Room, message: ScreenShareMigrationCommitMessage['d']): Promise<void> {
|
||||
await publishMigrationMessage(room.localParticipant, {
|
||||
op: COMMIT_OP,
|
||||
d: message,
|
||||
});
|
||||
}
|
||||
|
||||
async publishAbort(room: Room, message: ScreenShareMigrationAbortMessage['d']): Promise<void> {
|
||||
await publishMigrationMessage(room.localParticipant, {
|
||||
op: ABORT_OP,
|
||||
d: message,
|
||||
});
|
||||
}
|
||||
|
||||
selectScreenSharePublication(
|
||||
participant: Pick<Participant, 'identity' | 'trackPublications'> | null | undefined,
|
||||
): RemoteTrackPublication | undefined {
|
||||
@@ -648,9 +456,6 @@ class ScreenSharePublicationMigration extends Store {
|
||||
this.applyCandidate(participant.identity, message.d);
|
||||
this.ensureRemoteCandidateSubscription(room, participant as RemoteParticipant);
|
||||
break;
|
||||
case READY_OP:
|
||||
this.applyReady(participant.identity, message.d);
|
||||
break;
|
||||
case COMMIT_OP:
|
||||
this.applyCommit(participant.identity, message.d);
|
||||
break;
|
||||
@@ -681,13 +486,6 @@ class ScreenSharePublicationMigration extends Store {
|
||||
});
|
||||
}
|
||||
|
||||
private applyReady(participantIdentity: string, message: ScreenShareMigrationReadyMessage['d']): void {
|
||||
const session = this.localSessionsById.get(message.migration_id);
|
||||
if (!session) return;
|
||||
if (session.generation !== message.generation || session.candidateTrackSid !== message.candidate_track_sid) return;
|
||||
session.markReady(participantIdentity);
|
||||
}
|
||||
|
||||
private applyCommit(participantIdentity: string, message: ScreenShareMigrationCommitMessage['d']): void {
|
||||
this.commitRemoteMigrationState(participantIdentity, {
|
||||
type: 'migration.commit',
|
||||
@@ -1045,18 +843,6 @@ class ScreenSharePublicationMigration extends Store {
|
||||
return false;
|
||||
}
|
||||
|
||||
private hasLocalSessionCapacity(migrationId: string): boolean {
|
||||
assert.ok(this.localSessionsById.size <= LOCAL_MIGRATION_SESSIONS_MAX, 'local sessions must stay within cap');
|
||||
if (this.localSessionsById.has(migrationId)) return true;
|
||||
if (this.localSessionsById.size < LOCAL_MIGRATION_SESSIONS_MAX) return true;
|
||||
logger.error('Refusing local screen share migration session beyond cap; a stale session may have leaked', {
|
||||
migrationId,
|
||||
trackedCount: this.localSessionsById.size,
|
||||
cap: LOCAL_MIGRATION_SESSIONS_MAX,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
private hasReadyProbeCapacity(participantIdentity: string, trackSid: string): boolean {
|
||||
assert.ok(this.readyProbesByKey.size <= READY_PROBES_MAX, 'ready probes must stay within cap');
|
||||
if (this.readyProbesByKey.size < READY_PROBES_MAX) return true;
|
||||
@@ -1093,6 +879,4 @@ if (typeof window !== 'undefined') {
|
||||
)._screenSharePublicationMigration = instance;
|
||||
}
|
||||
|
||||
export type {ScreenShareMigrationReadinessResult, ScreenShareLocalMigrationSession};
|
||||
|
||||
export default instance;
|
||||
-38
@@ -1,11 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createLocalMigrationReadinessState,
|
||||
isLocalMigrationReadinessComplete,
|
||||
type ScreenShareRemoteMigrationState,
|
||||
selectLocalMigrationReadinessResult,
|
||||
transitionLocalMigrationReadinessState,
|
||||
transitionRemoteScreenShareMigrationState,
|
||||
} from '@app/features/voice/engine/ScreenSharePublicationMigrationStateMachine';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
@@ -289,37 +285,3 @@ describe('ScreenSharePublicationMigrationStateMachine remote migration', () => {
|
||||
expect(transitionRemoteScreenShareMigrationState(ready, {type: 'migration.readySent'})).toBe(ready);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScreenSharePublicationMigrationStateMachine local readiness', () => {
|
||||
it('dedupes targets and completes only after every known watcher is ready', () => {
|
||||
let state = createLocalMigrationReadinessState(['viewer-a', 'viewer-a', 'viewer-b', '']);
|
||||
|
||||
state = transitionLocalMigrationReadinessState(state, {
|
||||
type: 'watcher.ready',
|
||||
participantIdentity: 'unknown-viewer',
|
||||
});
|
||||
state = transitionLocalMigrationReadinessState(state, {
|
||||
type: 'watcher.ready',
|
||||
participantIdentity: 'viewer-a',
|
||||
});
|
||||
|
||||
expect(isLocalMigrationReadinessComplete(state)).toBe(false);
|
||||
expect(selectLocalMigrationReadinessResult(state, false)).toEqual({
|
||||
readyIdentities: ['viewer-a'],
|
||||
missingIdentities: ['viewer-b'],
|
||||
timedOut: false,
|
||||
});
|
||||
|
||||
state = transitionLocalMigrationReadinessState(state, {
|
||||
type: 'watcher.ready',
|
||||
participantIdentity: 'viewer-b',
|
||||
});
|
||||
|
||||
expect(isLocalMigrationReadinessComplete(state)).toBe(true);
|
||||
expect(selectLocalMigrationReadinessResult(state, false)).toEqual({
|
||||
readyIdentities: ['viewer-a', 'viewer-b'],
|
||||
missingIdentities: [],
|
||||
timedOut: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -48,21 +48,6 @@ export type ScreenShareRemoteMigrationEvent =
|
||||
| {type: 'migration.committedUnpublished'; trackSid: string}
|
||||
| {type: 'migration.readySent'};
|
||||
|
||||
export interface ScreenShareLocalMigrationReadinessState {
|
||||
targetIdentities: ReadonlySet<string>;
|
||||
readyIdentities: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export type ScreenShareLocalMigrationReadinessEvent =
|
||||
| {type: 'watcher.ready'; participantIdentity: string}
|
||||
| {type: 'watcher.timeout'};
|
||||
|
||||
export interface ScreenShareMigrationReadinessResult {
|
||||
readyIdentities: Array<string>;
|
||||
missingIdentities: Array<string>;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
function isSameMigration(state: ScreenShareRemoteMigrationState | null, event: {migrationId: string}): boolean {
|
||||
return state?.migrationId === event.migrationId;
|
||||
}
|
||||
@@ -220,41 +205,3 @@ export function transitionRemoteScreenShareMigrationState(
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalMigrationReadinessState(
|
||||
targetIdentities: ReadonlyArray<string>,
|
||||
): ScreenShareLocalMigrationReadinessState {
|
||||
return {
|
||||
targetIdentities: new Set(targetIdentities.filter(Boolean)),
|
||||
readyIdentities: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionLocalMigrationReadinessState(
|
||||
state: ScreenShareLocalMigrationReadinessState,
|
||||
event: ScreenShareLocalMigrationReadinessEvent,
|
||||
): ScreenShareLocalMigrationReadinessState {
|
||||
if (event.type === 'watcher.timeout') return state;
|
||||
if (!state.targetIdentities.has(event.participantIdentity)) return state;
|
||||
if (state.readyIdentities.has(event.participantIdentity)) return state;
|
||||
return {
|
||||
...state,
|
||||
readyIdentities: new Set([...state.readyIdentities, event.participantIdentity]),
|
||||
};
|
||||
}
|
||||
|
||||
export function isLocalMigrationReadinessComplete(state: ScreenShareLocalMigrationReadinessState): boolean {
|
||||
return state.readyIdentities.size >= state.targetIdentities.size;
|
||||
}
|
||||
|
||||
export function selectLocalMigrationReadinessResult(
|
||||
state: ScreenShareLocalMigrationReadinessState,
|
||||
timedOut: boolean,
|
||||
): ScreenShareMigrationReadinessResult {
|
||||
const readyIdentities = Array.from(state.readyIdentities);
|
||||
return {
|
||||
readyIdentities,
|
||||
missingIdentities: Array.from(state.targetIdentities).filter((identity) => !state.readyIdentities.has(identity)),
|
||||
timedOut,
|
||||
};
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export class VoiceMediaIdentityCapabilityError extends Error {
|
||||
constructor() {
|
||||
super('Voice media identity generation requires crypto.randomUUID');
|
||||
this.name = 'VoiceMediaIdentityCapabilityError';
|
||||
}
|
||||
}
|
||||
|
||||
const RANDOM_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
||||
|
||||
export class VoiceMediaIdentityInvariantError extends Error {
|
||||
constructor() {
|
||||
super('crypto.randomUUID must return a canonical version 4 UUID');
|
||||
this.name = 'VoiceMediaIdentityInvariantError';
|
||||
}
|
||||
}
|
||||
|
||||
export function createVoiceMediaIdentity(): string {
|
||||
const cryptoPort = globalThis.crypto;
|
||||
if (cryptoPort == null || typeof cryptoPort.randomUUID !== 'function') {
|
||||
throw new VoiceMediaIdentityCapabilityError();
|
||||
}
|
||||
const identity = cryptoPort.randomUUID();
|
||||
if (!RANDOM_UUID_PATTERN.test(identity)) {
|
||||
throw new VoiceMediaIdentityInvariantError();
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
import {SoundType} from '@app/features/notification/utils/SoundUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as SoundCommands from '@app/features/ui/commands/SoundCommands';
|
||||
import ScreenShareCodecNegotiation, {
|
||||
type CodecNegotiationSelection,
|
||||
} from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import ScreenShareCodecNegotiation from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import ScreenSharePublicationMigration from '@app/features/voice/engine/ScreenSharePublicationMigration';
|
||||
import {getEffectiveAudioState} from '@app/features/voice/engine/VoiceEffectiveAudioState';
|
||||
import {noteLocalVoiceActivity} from '@app/features/voice/engine/VoiceIdleActivityBridge';
|
||||
@@ -92,8 +90,7 @@ export interface RoomEventDependencies {
|
||||
didChangeLocalState: boolean,
|
||||
publication?: LocalTrackPublication,
|
||||
) => void;
|
||||
isScreenShareCodecRepublishInFlight: () => boolean;
|
||||
renegotiateActiveScreenShareCodec: (room: Room, selection: CodecNegotiationSelection) => Promise<unknown>;
|
||||
isScreenSharePublicationReplaceInFlight: () => boolean;
|
||||
};
|
||||
subscriptions: {
|
||||
isScreenShareSubscribed: (participantIdentity: string) => boolean;
|
||||
@@ -243,17 +240,7 @@ export function bindRoomEvents(
|
||||
};
|
||||
const bindCodecNegotiation = (): void => {
|
||||
codecNegotiationDisposer?.();
|
||||
codecNegotiationDisposer = ScreenShareCodecNegotiation.bind(room, {
|
||||
onSelectedCodecChanged: (selection) => {
|
||||
void dependencies.screenShare.renegotiateActiveScreenShareCodec(room, selection).catch((error) => {
|
||||
logger.warn('Failed to apply negotiated screen share codec', {
|
||||
error,
|
||||
codec: selection.codec,
|
||||
reason: selection.reason,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
codecNegotiationDisposer = ScreenShareCodecNegotiation.bind(room);
|
||||
};
|
||||
const unbindCodecNegotiation = (): void => {
|
||||
ScreenShareCodecNegotiation.dispose();
|
||||
@@ -550,7 +537,10 @@ export function bindRoomEvents(
|
||||
RoomEvent.LocalTrackUnpublished,
|
||||
guard(attemptId, (pub, p: Participant) => {
|
||||
if (p.isLocal) {
|
||||
if (pub.source === Track.Source.ScreenShare && dependencies.screenShare.isScreenShareCodecRepublishInFlight()) {
|
||||
if (
|
||||
pub.source === Track.Source.ScreenShare &&
|
||||
dependencies.screenShare.isScreenSharePublicationReplaceInFlight()
|
||||
) {
|
||||
dependencies.participants.upsertParticipant(p);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
createScreenShareCodecPublicationSnapshot,
|
||||
selectScreenShareCodecRepublishDecision,
|
||||
transitionScreenShareCodecPublicationSnapshot,
|
||||
} from './VoiceScreenShareCodecRepublishPolicy';
|
||||
|
||||
describe('selectScreenShareCodecRepublishDecision', () => {
|
||||
it('does not republish when the selected codec already matches the active sender', () => {
|
||||
expect(
|
||||
selectScreenShareCodecRepublishDecision({
|
||||
currentCodec: 'vp8',
|
||||
nextCodec: 'vp8',
|
||||
reason: 'data',
|
||||
}),
|
||||
).toEqual({action: 'noop', reason: 'same-codec'});
|
||||
});
|
||||
|
||||
it('republishes automatic negotiation changes in-place while a share is already live', () => {
|
||||
for (const reason of ['participant-connected', 'data', 'connected', 'reconnected'] as const) {
|
||||
expect(
|
||||
selectScreenShareCodecRepublishDecision({
|
||||
currentCodec: 'vp8',
|
||||
nextCodec: 'h264',
|
||||
reason,
|
||||
}),
|
||||
).toEqual({action: 'republish', reason: 'automatic'});
|
||||
}
|
||||
});
|
||||
|
||||
it('republishes manual and forced live changes by default', () => {
|
||||
expect(
|
||||
selectScreenShareCodecRepublishDecision({
|
||||
currentCodec: 'vp8',
|
||||
nextCodec: 'h264',
|
||||
reason: 'manual',
|
||||
}),
|
||||
).toEqual({action: 'republish', reason: 'manual'});
|
||||
expect(
|
||||
selectScreenShareCodecRepublishDecision({
|
||||
currentCodec: 'vp8',
|
||||
nextCodec: 'h264',
|
||||
reason: 'participant-connected',
|
||||
force: true,
|
||||
}),
|
||||
).toEqual({action: 'republish', reason: 'forced'});
|
||||
});
|
||||
|
||||
it('keeps the XState snapshot in republishing state for repeated live codec changes', () => {
|
||||
let snapshot = createScreenShareCodecPublicationSnapshot();
|
||||
snapshot = transitionScreenShareCodecPublicationSnapshot(snapshot, {
|
||||
type: 'codec.selection',
|
||||
currentCodec: 'h265',
|
||||
nextCodec: 'h264',
|
||||
reason: 'participant-connected',
|
||||
});
|
||||
expect(snapshot.value).toBe('republishing');
|
||||
expect(snapshot.context.decision).toEqual({action: 'republish', reason: 'automatic'});
|
||||
snapshot = transitionScreenShareCodecPublicationSnapshot(snapshot, {
|
||||
type: 'codec.selection',
|
||||
currentCodec: 'h265',
|
||||
nextCodec: 'vp8',
|
||||
reason: 'data',
|
||||
});
|
||||
expect(snapshot.value).toBe('republishing');
|
||||
expect(snapshot.context.nextCodec).toBe('vp8');
|
||||
});
|
||||
|
||||
it('retains an explicit switch to defer live republishing', () => {
|
||||
expect(
|
||||
selectScreenShareCodecRepublishDecision({
|
||||
currentCodec: 'vp8',
|
||||
nextCodec: 'h264',
|
||||
reason: 'participant-connected',
|
||||
allowLiveRepublish: false,
|
||||
}),
|
||||
).toEqual({action: 'defer', reason: 'active-share-stability'});
|
||||
expect(
|
||||
selectScreenShareCodecRepublishDecision({
|
||||
currentCodec: 'vp8',
|
||||
nextCodec: 'h264',
|
||||
reason: 'participant-connected',
|
||||
force: true,
|
||||
allowLiveRepublish: false,
|
||||
}),
|
||||
).toEqual({action: 'defer', reason: 'live-republish-disabled'});
|
||||
});
|
||||
|
||||
it('keeps explicit live republish decisions stable', () => {
|
||||
expect(
|
||||
selectScreenShareCodecRepublishDecision({
|
||||
currentCodec: 'vp8',
|
||||
nextCodec: 'h264',
|
||||
reason: 'participant-connected',
|
||||
force: true,
|
||||
allowLiveRepublish: true,
|
||||
}),
|
||||
).toEqual({action: 'republish', reason: 'forced'});
|
||||
expect(
|
||||
selectScreenShareCodecRepublishDecision({
|
||||
currentCodec: 'vp8',
|
||||
nextCodec: 'h264',
|
||||
reason: 'manual',
|
||||
allowLiveRepublish: true,
|
||||
}),
|
||||
).toEqual({action: 'republish', reason: 'manual'});
|
||||
});
|
||||
|
||||
it('republishes a downgrade when a weaker client joins mid-share', () => {
|
||||
expect(
|
||||
selectScreenShareCodecRepublishDecision({
|
||||
currentCodec: 'av1',
|
||||
nextCodec: 'h264',
|
||||
reason: 'participant-connected',
|
||||
allowLiveRepublish: true,
|
||||
}),
|
||||
).toEqual({action: 'republish', reason: 'automatic'});
|
||||
});
|
||||
|
||||
it('republishes an upgrade when the constraining client leaves', () => {
|
||||
expect(
|
||||
selectScreenShareCodecRepublishDecision({
|
||||
currentCodec: 'h264',
|
||||
nextCodec: 'av1',
|
||||
reason: 'participant-disconnected',
|
||||
allowLiveRepublish: true,
|
||||
}),
|
||||
).toEqual({action: 'republish', reason: 'automatic'});
|
||||
});
|
||||
|
||||
it('republishes an upgrade when a late advertisement improves the intersection', () => {
|
||||
expect(
|
||||
selectScreenShareCodecRepublishDecision({
|
||||
currentCodec: 'vp8',
|
||||
nextCodec: 'av1',
|
||||
reason: 'data',
|
||||
allowLiveRepublish: true,
|
||||
}),
|
||||
).toEqual({action: 'republish', reason: 'automatic'});
|
||||
});
|
||||
|
||||
it('does not republish unchanged selections under repeated evaluations', () => {
|
||||
let snapshot = createScreenShareCodecPublicationSnapshot();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
snapshot = transitionScreenShareCodecPublicationSnapshot(snapshot, {
|
||||
type: 'codec.selection',
|
||||
currentCodec: 'av1',
|
||||
nextCodec: 'av1',
|
||||
reason: 'data',
|
||||
allowLiveRepublish: true,
|
||||
});
|
||||
expect(snapshot.value).toBe('stable');
|
||||
expect(snapshot.context.decision).toEqual({action: 'noop', reason: 'same-codec'});
|
||||
}
|
||||
});
|
||||
|
||||
it('honours explicit user preference changes in both directions', () => {
|
||||
expect(
|
||||
selectScreenShareCodecRepublishDecision({
|
||||
currentCodec: 'av1',
|
||||
nextCodec: 'vp8',
|
||||
reason: 'manual',
|
||||
allowLiveRepublish: true,
|
||||
}),
|
||||
).toEqual({action: 'republish', reason: 'manual'});
|
||||
expect(
|
||||
selectScreenShareCodecRepublishDecision({
|
||||
currentCodec: 'vp8',
|
||||
nextCodec: 'av1',
|
||||
reason: 'manual',
|
||||
allowLiveRepublish: true,
|
||||
}),
|
||||
).toEqual({action: 'republish', reason: 'manual'});
|
||||
});
|
||||
|
||||
it('walks the state machine through downgrade, stability, and upgrade transitions', () => {
|
||||
let snapshot = createScreenShareCodecPublicationSnapshot();
|
||||
expect(snapshot.value).toBe('stable');
|
||||
snapshot = transitionScreenShareCodecPublicationSnapshot(snapshot, {
|
||||
type: 'codec.selection',
|
||||
currentCodec: 'av1',
|
||||
nextCodec: 'h264',
|
||||
reason: 'participant-connected',
|
||||
allowLiveRepublish: true,
|
||||
});
|
||||
expect(snapshot.value).toBe('republishing');
|
||||
expect(snapshot.context.decision).toEqual({action: 'republish', reason: 'automatic'});
|
||||
snapshot = transitionScreenShareCodecPublicationSnapshot(snapshot, {
|
||||
type: 'codec.selection',
|
||||
currentCodec: 'h264',
|
||||
nextCodec: 'h264',
|
||||
reason: 'data',
|
||||
allowLiveRepublish: true,
|
||||
});
|
||||
expect(snapshot.value).toBe('stable');
|
||||
expect(snapshot.context.decision).toEqual({action: 'noop', reason: 'same-codec'});
|
||||
snapshot = transitionScreenShareCodecPublicationSnapshot(snapshot, {
|
||||
type: 'codec.selection',
|
||||
currentCodec: 'h264',
|
||||
nextCodec: 'av1',
|
||||
reason: 'participant-disconnected',
|
||||
allowLiveRepublish: true,
|
||||
});
|
||||
expect(snapshot.value).toBe('republishing');
|
||||
expect(snapshot.context.decision).toEqual({action: 'republish', reason: 'automatic'});
|
||||
expect(snapshot.context.nextCodec).toBe('av1');
|
||||
});
|
||||
|
||||
it('moves deferred selections into republishing once live republish is allowed again', () => {
|
||||
let snapshot = createScreenShareCodecPublicationSnapshot();
|
||||
snapshot = transitionScreenShareCodecPublicationSnapshot(snapshot, {
|
||||
type: 'codec.selection',
|
||||
currentCodec: 'av1',
|
||||
nextCodec: 'h264',
|
||||
reason: 'participant-connected',
|
||||
allowLiveRepublish: false,
|
||||
});
|
||||
expect(snapshot.value).toBe('deferred');
|
||||
expect(snapshot.context.decision).toEqual({action: 'defer', reason: 'active-share-stability'});
|
||||
snapshot = transitionScreenShareCodecPublicationSnapshot(snapshot, {
|
||||
type: 'codec.selection',
|
||||
currentCodec: 'av1',
|
||||
nextCodec: 'h264',
|
||||
reason: 'data',
|
||||
allowLiveRepublish: true,
|
||||
});
|
||||
expect(snapshot.value).toBe('republishing');
|
||||
expect(snapshot.context.decision).toEqual({action: 'republish', reason: 'automatic'});
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {NegotiationReason} from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import type {VideoCodec} from 'livekit-client';
|
||||
import {assign, getInitialSnapshot, type SnapshotFrom, setup, transition} from 'xstate';
|
||||
|
||||
export type ScreenShareCodecRepublishDecision =
|
||||
| {
|
||||
action: 'noop';
|
||||
reason: 'same-codec';
|
||||
}
|
||||
| {
|
||||
action: 'republish';
|
||||
reason: 'forced' | 'manual' | 'automatic';
|
||||
}
|
||||
| {
|
||||
action: 'defer';
|
||||
reason: 'active-share-stability' | 'live-republish-disabled';
|
||||
};
|
||||
|
||||
interface ScreenShareCodecPublicationContext {
|
||||
currentCodec: VideoCodec | undefined;
|
||||
nextCodec: VideoCodec | null;
|
||||
reason: NegotiationReason | null;
|
||||
force: boolean;
|
||||
allowLiveRepublish: boolean;
|
||||
decision: ScreenShareCodecRepublishDecision;
|
||||
}
|
||||
|
||||
type ScreenShareCodecPublicationEvent = {
|
||||
type: 'codec.selection';
|
||||
currentCodec: VideoCodec | undefined;
|
||||
nextCodec: VideoCodec;
|
||||
reason: NegotiationReason;
|
||||
force?: boolean;
|
||||
allowLiveRepublish?: boolean;
|
||||
};
|
||||
|
||||
function initialContext(): ScreenShareCodecPublicationContext {
|
||||
return {
|
||||
currentCodec: undefined,
|
||||
nextCodec: null,
|
||||
reason: null,
|
||||
force: false,
|
||||
allowLiveRepublish: false,
|
||||
decision: {action: 'noop', reason: 'same-codec'},
|
||||
};
|
||||
}
|
||||
|
||||
function getRepublishDecision(event: ScreenShareCodecPublicationEvent): ScreenShareCodecRepublishDecision {
|
||||
if (event.currentCodec === event.nextCodec && event.force !== true) {
|
||||
return {action: 'noop', reason: 'same-codec'};
|
||||
}
|
||||
if (event.allowLiveRepublish === false) {
|
||||
return {action: 'defer', reason: event.force === true ? 'live-republish-disabled' : 'active-share-stability'};
|
||||
}
|
||||
if (event.force === true) {
|
||||
return {action: 'republish', reason: 'forced'};
|
||||
}
|
||||
if (event.reason === 'manual') {
|
||||
return {action: 'republish', reason: 'manual'};
|
||||
}
|
||||
return {action: 'republish', reason: 'automatic'};
|
||||
}
|
||||
|
||||
export const screenShareCodecPublicationStateMachine = setup({
|
||||
types: {} as {
|
||||
context: ScreenShareCodecPublicationContext;
|
||||
events: ScreenShareCodecPublicationEvent;
|
||||
},
|
||||
actions: {
|
||||
recordSelection: assign(({event}) => ({
|
||||
currentCodec: event.currentCodec,
|
||||
nextCodec: event.nextCodec,
|
||||
reason: event.reason,
|
||||
force: event.force === true,
|
||||
allowLiveRepublish: event.allowLiveRepublish === true,
|
||||
decision: getRepublishDecision(event),
|
||||
})),
|
||||
},
|
||||
guards: {
|
||||
isNoop: ({event}) => getRepublishDecision(event).action === 'noop',
|
||||
isRepublish: ({event}) => getRepublishDecision(event).action === 'republish',
|
||||
},
|
||||
}).createMachine({
|
||||
id: 'screenShareCodecPublication',
|
||||
context: () => initialContext(),
|
||||
initial: 'stable',
|
||||
states: {
|
||||
stable: {
|
||||
on: {
|
||||
'codec.selection': [
|
||||
{guard: 'isNoop', target: 'stable', actions: 'recordSelection'},
|
||||
{guard: 'isRepublish', target: 'republishing', actions: 'recordSelection'},
|
||||
{target: 'deferred', actions: 'recordSelection'},
|
||||
],
|
||||
},
|
||||
},
|
||||
deferred: {
|
||||
on: {
|
||||
'codec.selection': [
|
||||
{guard: 'isNoop', target: 'stable', actions: 'recordSelection'},
|
||||
{guard: 'isRepublish', target: 'republishing', actions: 'recordSelection'},
|
||||
{target: 'deferred', actions: 'recordSelection'},
|
||||
],
|
||||
},
|
||||
},
|
||||
republishing: {
|
||||
on: {
|
||||
'codec.selection': [
|
||||
{guard: 'isNoop', target: 'stable', actions: 'recordSelection'},
|
||||
{guard: 'isRepublish', target: 'republishing', actions: 'recordSelection'},
|
||||
{target: 'deferred', actions: 'recordSelection'},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type ScreenShareCodecPublicationSnapshot = SnapshotFrom<typeof screenShareCodecPublicationStateMachine>;
|
||||
|
||||
export function createScreenShareCodecPublicationSnapshot(): ScreenShareCodecPublicationSnapshot {
|
||||
return getInitialSnapshot(screenShareCodecPublicationStateMachine);
|
||||
}
|
||||
|
||||
export function transitionScreenShareCodecPublicationSnapshot(
|
||||
snapshot: ScreenShareCodecPublicationSnapshot,
|
||||
event: ScreenShareCodecPublicationEvent,
|
||||
): ScreenShareCodecPublicationSnapshot {
|
||||
return transition(screenShareCodecPublicationStateMachine, snapshot, event)[0] as ScreenShareCodecPublicationSnapshot;
|
||||
}
|
||||
|
||||
export function selectScreenShareCodecRepublishDecision(options: {
|
||||
currentCodec: VideoCodec | undefined;
|
||||
nextCodec: VideoCodec;
|
||||
reason: NegotiationReason;
|
||||
force?: boolean;
|
||||
allowLiveRepublish?: boolean;
|
||||
}): ScreenShareCodecRepublishDecision {
|
||||
const snapshot = transitionScreenShareCodecPublicationSnapshot(createScreenShareCodecPublicationSnapshot(), {
|
||||
type: 'codec.selection',
|
||||
currentCodec: options.currentCodec,
|
||||
nextCodec: options.nextCodec,
|
||||
reason: options.reason,
|
||||
...(options.force !== undefined ? {force: options.force} : {}),
|
||||
...(options.allowLiveRepublish !== undefined ? {allowLiveRepublish: options.allowLiveRepublish} : {}),
|
||||
});
|
||||
return snapshot.context.decision;
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {NegotiationReason} from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import {
|
||||
createVoiceScreenShareSnapshot,
|
||||
type PendingScreenShareCodecRepublishRequest,
|
||||
transitionVoiceScreenShareSnapshot,
|
||||
type VoiceScreenShareEvent,
|
||||
type VoiceScreenShareSnapshot,
|
||||
} from '@app/features/voice/engine/VoiceScreenShareStateMachine';
|
||||
import type {PendingScreenShareStopRequest} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||
import type {VideoCodec} from 'livekit-client';
|
||||
|
||||
export interface VoiceScreenShareLifecycleHost {
|
||||
update(fn: () => void): void;
|
||||
@@ -19,7 +16,6 @@ export interface VoiceScreenShareLifecycleHost {
|
||||
export interface VoiceScreenShareQueuedRequestExecutors {
|
||||
isScreenShareEnabled: () => boolean;
|
||||
applyStop: (request: PendingScreenShareStopRequest) => Promise<void>;
|
||||
applyCodecRepublish: (request: PendingScreenShareCodecRepublishRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export class VoiceScreenShareLifecycleStore {
|
||||
@@ -40,8 +36,8 @@ export class VoiceScreenShareLifecycleStore {
|
||||
return this.snapshotInternal.context.pendingOperation != null;
|
||||
}
|
||||
|
||||
get codecRepublishInFlight(): boolean {
|
||||
return this.snapshotInternal.context.codecRepublishInFlight;
|
||||
get publicationReplaceInFlight(): boolean {
|
||||
return this.snapshotInternal.context.publicationReplaceInFlight;
|
||||
}
|
||||
|
||||
get streamingPriorityHeld(): boolean {
|
||||
@@ -66,28 +62,6 @@ export class VoiceScreenShareLifecycleStore {
|
||||
});
|
||||
}
|
||||
|
||||
queueCodecRepublishRequest(codec: VideoCodec, reason: NegotiationReason, options: {force?: boolean} = {}): void {
|
||||
this.transition({
|
||||
type: 'share.codecRepublish.queue',
|
||||
request: {
|
||||
codec,
|
||||
reason,
|
||||
force: options.force === true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deferCodecRepublishRequest(codec: VideoCodec, reason: NegotiationReason, options: {force?: boolean} = {}): void {
|
||||
this.transition({
|
||||
type: 'share.codecRepublish.defer',
|
||||
request: {
|
||||
codec,
|
||||
reason,
|
||||
force: options.force === true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
takeQueuedStopRequest(): PendingScreenShareStopRequest | null {
|
||||
const request = this.snapshotInternal.context.queuedStopRequest;
|
||||
this.transition({type: 'share.queuedStop.clear'});
|
||||
@@ -95,26 +69,11 @@ export class VoiceScreenShareLifecycleStore {
|
||||
return request;
|
||||
}
|
||||
|
||||
takeQueuedCodecRepublishRequest(): PendingScreenShareCodecRepublishRequest | null {
|
||||
const request = this.snapshotInternal.context.queuedCodecRepublishRequest;
|
||||
this.transition({type: 'share.queuedCodecRepublish.clear'});
|
||||
assert.equal(
|
||||
this.snapshotInternal.context.queuedCodecRepublishRequest,
|
||||
null,
|
||||
'queued codec republish request must clear on take',
|
||||
);
|
||||
return request;
|
||||
}
|
||||
|
||||
async drainQueuedRequests(executors: VoiceScreenShareQueuedRequestExecutors): Promise<void> {
|
||||
assert.ok(executors, 'queued request executors are required');
|
||||
const stopRequest = this.takeQueuedStopRequest();
|
||||
if (stopRequest && executors.isScreenShareEnabled()) {
|
||||
await executors.applyStop(stopRequest);
|
||||
}
|
||||
const republishRequest = this.takeQueuedCodecRepublishRequest();
|
||||
if (republishRequest && executors.isScreenShareEnabled()) {
|
||||
await executors.applyCodecRepublish(republishRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ describe('VoiceScreenShareStateMachine', () => {
|
||||
snapshot = transitionVoiceScreenShareSnapshot(snapshot, {type: 'share.cancel', active: false, sourceType: null});
|
||||
expect(getVoiceScreenShareStateValue(snapshot)).toBe('inactive');
|
||||
expect(snapshot.context.pendingOperation).toBeNull();
|
||||
expect(snapshot.context.codecRepublishInFlight).toBe(false);
|
||||
expect(snapshot.context.publicationReplaceInFlight).toBe(false);
|
||||
snapshot = transitionVoiceScreenShareSnapshot(snapshot, {type: 'share.start', sourceType: 'device'});
|
||||
snapshot = transitionVoiceScreenShareSnapshot(snapshot, {type: 'share.reject', active: false, sourceType: null});
|
||||
expect(getVoiceScreenShareStateValue(snapshot)).toBe('inactive');
|
||||
@@ -103,30 +103,25 @@ describe('VoiceScreenShareStateMachine', () => {
|
||||
expect(snapshot.context.sourceType).toBe('device');
|
||||
});
|
||||
|
||||
it('holds the codec republish lock only while codec publication work is pending', () => {
|
||||
it('holds the replace lock only while a replacing publication is pending', () => {
|
||||
let snapshot = resolveActive(
|
||||
transitionVoiceScreenShareSnapshot(createVoiceScreenShareSnapshot(), {
|
||||
type: 'share.start',
|
||||
sourceType: 'display',
|
||||
}),
|
||||
);
|
||||
snapshot = transitionVoiceScreenShareSnapshot(snapshot, {type: 'share.codecRepublish'});
|
||||
expect(snapshot.context.pendingOperation).toBe('codecRepublishing');
|
||||
expect(snapshot.context.codecRepublishInFlight).toBe(true);
|
||||
snapshot = resolveActive(snapshot, 'display');
|
||||
expect(snapshot.context.codecRepublishInFlight).toBe(false);
|
||||
snapshot = transitionVoiceScreenShareSnapshot(snapshot, {
|
||||
type: 'share.replace',
|
||||
sourceType: 'display',
|
||||
codecRepublishInFlight: true,
|
||||
publicationReplaceInFlight: true,
|
||||
});
|
||||
expect(snapshot.context.codecRepublishInFlight).toBe(true);
|
||||
expect(snapshot.context.publicationReplaceInFlight).toBe(true);
|
||||
snapshot = transitionVoiceScreenShareSnapshot(snapshot, {
|
||||
type: 'share.cancel',
|
||||
active: true,
|
||||
sourceType: 'display',
|
||||
});
|
||||
expect(snapshot.context.codecRepublishInFlight).toBe(false);
|
||||
expect(snapshot.context.publicationReplaceInFlight).toBe(false);
|
||||
});
|
||||
|
||||
it('tracks codec readiness through the xstate context while starting', () => {
|
||||
@@ -158,76 +153,6 @@ describe('VoiceScreenShareStateMachine', () => {
|
||||
expect(snapshot.context.codecReadiness).toBe('idle');
|
||||
});
|
||||
|
||||
it('retains the latest queued codec republish request while screen share work is pending', () => {
|
||||
let snapshot = transitionVoiceScreenShareSnapshot(createVoiceScreenShareSnapshot(), {
|
||||
type: 'share.start',
|
||||
sourceType: 'display',
|
||||
});
|
||||
snapshot = transitionVoiceScreenShareSnapshot(snapshot, {
|
||||
type: 'share.codecRepublish.queue',
|
||||
request: {codec: 'vp9', reason: 'manual', force: true},
|
||||
});
|
||||
snapshot = transitionVoiceScreenShareSnapshot(snapshot, {
|
||||
type: 'share.codecRepublish.queue',
|
||||
request: {codec: 'vp9', reason: 'data', force: false},
|
||||
});
|
||||
expect(snapshot.context.pendingOperation).toBe('starting');
|
||||
expect(snapshot.context.queuedCodecRepublishRequest).toEqual({
|
||||
codec: 'vp9',
|
||||
reason: 'data',
|
||||
force: true,
|
||||
});
|
||||
snapshot = transitionVoiceScreenShareSnapshot(snapshot, {
|
||||
type: 'share.codecRepublish.queue',
|
||||
request: {codec: 'h264', reason: 'manual', force: false},
|
||||
});
|
||||
expect(snapshot.context.queuedCodecRepublishRequest).toEqual({
|
||||
codec: 'h264',
|
||||
reason: 'manual',
|
||||
force: false,
|
||||
});
|
||||
snapshot = resolveActive(snapshot, 'display');
|
||||
expect(snapshot.context.queuedCodecRepublishRequest).toEqual({
|
||||
codec: 'h264',
|
||||
reason: 'manual',
|
||||
force: false,
|
||||
});
|
||||
snapshot = transitionVoiceScreenShareSnapshot(snapshot, {type: 'share.queuedCodecRepublish.clear'});
|
||||
expect(snapshot.context.queuedCodecRepublishRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('records deferred codec republish requests without entering a pending state', () => {
|
||||
let snapshot = resolveActive(
|
||||
transitionVoiceScreenShareSnapshot(createVoiceScreenShareSnapshot(), {
|
||||
type: 'share.start',
|
||||
sourceType: 'display',
|
||||
}),
|
||||
);
|
||||
snapshot = transitionVoiceScreenShareSnapshot(snapshot, {
|
||||
type: 'share.codecRepublish.defer',
|
||||
request: {codec: 'h264', reason: 'participant-connected', force: false},
|
||||
});
|
||||
expect(getVoiceScreenShareStateValue(snapshot)).toBe('active');
|
||||
expect(snapshot.context.pendingOperation).toBeNull();
|
||||
expect(snapshot.context.codecRepublishInFlight).toBe(false);
|
||||
expect(snapshot.context.deferredCodecRepublishRequest).toEqual({
|
||||
codec: 'h264',
|
||||
reason: 'participant-connected',
|
||||
force: false,
|
||||
});
|
||||
snapshot = transitionVoiceScreenShareSnapshot(snapshot, {
|
||||
type: 'share.codecRepublish.defer',
|
||||
request: {codec: 'h264', reason: 'data', force: true},
|
||||
});
|
||||
expect(snapshot.context.deferredCodecRepublishRequest).toEqual({
|
||||
codec: 'h264',
|
||||
reason: 'data',
|
||||
force: true,
|
||||
});
|
||||
snapshot = transitionVoiceScreenShareSnapshot(snapshot, {type: 'share.deferredCodecRepublish.clear'});
|
||||
expect(snapshot.context.deferredCodecRepublishRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('tracks active track ended cleanup independently from stop resolution', () => {
|
||||
let snapshot = resolveActive(
|
||||
transitionVoiceScreenShareSnapshot(createVoiceScreenShareSnapshot(), {
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {NegotiationReason} from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import type {PendingScreenShareStopRequest} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||
import type {VideoCodec} from 'livekit-client';
|
||||
import {assign, getInitialSnapshot, type SnapshotFrom, setup, transition} from 'xstate';
|
||||
|
||||
export type VoiceScreenShareSourceType = 'display' | 'native-display' | 'native-app' | 'device';
|
||||
|
||||
export type VoiceScreenShareOperation = 'starting' | 'stopping' | 'replacing' | 'codecRepublishing' | 'restoring';
|
||||
export type VoiceScreenShareOperation = 'starting' | 'stopping' | 'replacing' | 'restoring';
|
||||
|
||||
export type VoiceScreenShareCodecReadiness = 'idle' | 'loading' | 'ready' | 'timeout';
|
||||
|
||||
export type VoiceScreenShareWatchCommand = {type: 'watch.add'; key: string} | {type: 'watch.remove'; key: string};
|
||||
|
||||
export interface PendingScreenShareCodecRepublishRequest {
|
||||
codec: VideoCodec;
|
||||
reason: NegotiationReason;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
interface VoiceScreenShareContext {
|
||||
active: boolean;
|
||||
sourceType: VoiceScreenShareSourceType | null;
|
||||
pendingOperation: VoiceScreenShareOperation | null;
|
||||
queuedStopRequest: PendingScreenShareStopRequest | null;
|
||||
queuedCodecRepublishRequest: PendingScreenShareCodecRepublishRequest | null;
|
||||
deferredCodecRepublishRequest: PendingScreenShareCodecRepublishRequest | null;
|
||||
codecRepublishInFlight: boolean;
|
||||
publicationReplaceInFlight: boolean;
|
||||
codecReadiness: VoiceScreenShareCodecReadiness;
|
||||
encoderVerificationScheduled: boolean;
|
||||
endedTrackStopInFlight: boolean;
|
||||
@@ -41,11 +31,8 @@ export type VoiceScreenShareEvent =
|
||||
| {
|
||||
type: 'share.replace';
|
||||
sourceType: VoiceScreenShareSourceType;
|
||||
codecRepublishInFlight?: boolean;
|
||||
publicationReplaceInFlight?: boolean;
|
||||
}
|
||||
| {type: 'share.codecRepublish'}
|
||||
| {type: 'share.codecRepublish.queue'; request: PendingScreenShareCodecRepublishRequest}
|
||||
| {type: 'share.codecRepublish.defer'; request: PendingScreenShareCodecRepublishRequest}
|
||||
| {
|
||||
type: 'share.resolve';
|
||||
active: boolean;
|
||||
@@ -73,8 +60,6 @@ export type VoiceScreenShareEvent =
|
||||
| {type: 'share.codecReadiness.reset'}
|
||||
| {type: 'share.streamingPriority.set'; active: boolean}
|
||||
| {type: 'share.queuedStop.clear'}
|
||||
| {type: 'share.queuedCodecRepublish.clear'}
|
||||
| {type: 'share.deferredCodecRepublish.clear'}
|
||||
| {
|
||||
type: 'share.localWatcher.sync';
|
||||
enabled: boolean;
|
||||
@@ -92,9 +77,7 @@ function initialContext(): VoiceScreenShareContext {
|
||||
sourceType: null,
|
||||
pendingOperation: null,
|
||||
queuedStopRequest: null,
|
||||
queuedCodecRepublishRequest: null,
|
||||
deferredCodecRepublishRequest: null,
|
||||
codecRepublishInFlight: false,
|
||||
publicationReplaceInFlight: false,
|
||||
codecReadiness: 'idle',
|
||||
encoderVerificationScheduled: false,
|
||||
endedTrackStopInFlight: false,
|
||||
@@ -107,15 +90,14 @@ function beginOperation(
|
||||
context: VoiceScreenShareContext,
|
||||
pendingOperation: VoiceScreenShareOperation,
|
||||
sourceType: VoiceScreenShareSourceType | null = context.sourceType,
|
||||
codecRepublishInFlight = false,
|
||||
publicationReplaceInFlight = false,
|
||||
): VoiceScreenShareContext {
|
||||
if (context.pendingOperation) return context;
|
||||
return {
|
||||
...context,
|
||||
pendingOperation,
|
||||
sourceType,
|
||||
deferredCodecRepublishRequest: null,
|
||||
codecRepublishInFlight,
|
||||
publicationReplaceInFlight,
|
||||
codecReadiness: 'idle',
|
||||
};
|
||||
}
|
||||
@@ -140,7 +122,7 @@ function beginStop(
|
||||
...context,
|
||||
pendingOperation: 'stopping',
|
||||
queuedStopRequest: null,
|
||||
codecRepublishInFlight: false,
|
||||
publicationReplaceInFlight: false,
|
||||
codecReadiness: 'idle',
|
||||
encoderVerificationScheduled: false,
|
||||
streamingPriorityHeld: false,
|
||||
@@ -159,9 +141,8 @@ function resolveOperation(
|
||||
active,
|
||||
sourceType: active ? (sourceType ?? context.sourceType) : null,
|
||||
pendingOperation: null,
|
||||
codecRepublishInFlight: false,
|
||||
publicationReplaceInFlight: false,
|
||||
codecReadiness: active ? context.codecReadiness : 'idle',
|
||||
deferredCodecRepublishRequest: active ? context.deferredCodecRepublishRequest : null,
|
||||
encoderVerificationScheduled: encoderVerificationScheduled ?? (active && context.encoderVerificationScheduled),
|
||||
streamingPriorityHeld: streamingPriorityHeld ?? (active && context.streamingPriorityHeld),
|
||||
};
|
||||
@@ -177,51 +158,13 @@ function rejectOperation(
|
||||
active,
|
||||
sourceType: active ? (sourceType ?? context.sourceType) : null,
|
||||
pendingOperation: null,
|
||||
codecRepublishInFlight: false,
|
||||
publicationReplaceInFlight: false,
|
||||
codecReadiness: active ? context.codecReadiness : 'idle',
|
||||
deferredCodecRepublishRequest: active ? context.deferredCodecRepublishRequest : null,
|
||||
encoderVerificationScheduled: active && context.encoderVerificationScheduled,
|
||||
streamingPriorityHeld: active && context.streamingPriorityHeld,
|
||||
};
|
||||
}
|
||||
|
||||
function queueCodecRepublish(
|
||||
context: VoiceScreenShareContext,
|
||||
request: PendingScreenShareCodecRepublishRequest,
|
||||
): VoiceScreenShareContext {
|
||||
const force =
|
||||
context.queuedCodecRepublishRequest?.codec === request.codec
|
||||
? context.queuedCodecRepublishRequest.force || request.force
|
||||
: request.force;
|
||||
return {
|
||||
...context,
|
||||
queuedCodecRepublishRequest: {
|
||||
codec: request.codec,
|
||||
reason: request.reason,
|
||||
force,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deferCodecRepublish(
|
||||
context: VoiceScreenShareContext,
|
||||
request: PendingScreenShareCodecRepublishRequest,
|
||||
): VoiceScreenShareContext {
|
||||
const force =
|
||||
context.deferredCodecRepublishRequest?.codec === request.codec
|
||||
? context.deferredCodecRepublishRequest.force || request.force
|
||||
: request.force;
|
||||
return {
|
||||
...context,
|
||||
deferredCodecRepublishRequest: {
|
||||
codec: request.codec,
|
||||
reason: request.reason,
|
||||
force,
|
||||
},
|
||||
codecRepublishInFlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
function setCodecReadiness(
|
||||
context: VoiceScreenShareContext,
|
||||
codecReadiness: VoiceScreenShareCodecReadiness,
|
||||
@@ -266,16 +209,9 @@ export const voiceScreenShareStateMachine = setup({
|
||||
stop: assign(({context, event}) => (event.type === 'share.stop' ? beginStop(context, event.request) : context)),
|
||||
replace: assign(({context, event}) =>
|
||||
event.type === 'share.replace'
|
||||
? beginOperation(context, 'replacing', event.sourceType, event.codecRepublishInFlight === true)
|
||||
? beginOperation(context, 'replacing', event.sourceType, event.publicationReplaceInFlight === true)
|
||||
: context,
|
||||
),
|
||||
codecRepublish: assign(({context}) => beginOperation(context, 'codecRepublishing', context.sourceType, true)),
|
||||
queueCodecRepublish: assign(({context, event}) =>
|
||||
event.type === 'share.codecRepublish.queue' ? queueCodecRepublish(context, event.request) : context,
|
||||
),
|
||||
deferCodecRepublish: assign(({context, event}) =>
|
||||
event.type === 'share.codecRepublish.defer' ? deferCodecRepublish(context, event.request) : context,
|
||||
),
|
||||
resolve: assign(({context, event}) =>
|
||||
event.type === 'share.resolve'
|
||||
? resolveOperation(
|
||||
@@ -328,12 +264,6 @@ export const voiceScreenShareStateMachine = setup({
|
||||
clearQueuedStop: assign(({context}) =>
|
||||
context.queuedStopRequest ? {...context, queuedStopRequest: null} : context,
|
||||
),
|
||||
clearQueuedCodecRepublish: assign(({context}) =>
|
||||
context.queuedCodecRepublishRequest ? {...context, queuedCodecRepublishRequest: null} : context,
|
||||
),
|
||||
clearDeferredCodecRepublish: assign(({context}) =>
|
||||
context.deferredCodecRepublishRequest ? {...context, deferredCodecRepublishRequest: null} : context,
|
||||
),
|
||||
syncLocalWatcher: assign(({context, event}) =>
|
||||
event.type === 'share.localWatcher.sync'
|
||||
? syncLocalWatcher(context, event.enabled, event.streamKey, event.currentViewerStreamKeys)
|
||||
@@ -368,9 +298,6 @@ export const voiceScreenShareStateMachine = setup({
|
||||
'share.restore': {target: 'routing', actions: 'restore'},
|
||||
'share.stop': {target: 'routing', actions: 'stop'},
|
||||
'share.replace': {target: 'routing', actions: 'replace'},
|
||||
'share.codecRepublish': {target: 'routing', actions: 'codecRepublish'},
|
||||
'share.codecRepublish.queue': {target: 'routing', actions: 'queueCodecRepublish'},
|
||||
'share.codecRepublish.defer': {target: 'routing', actions: 'deferCodecRepublish'},
|
||||
'share.resolve': {target: 'routing', actions: 'resolve'},
|
||||
'share.reject': {target: 'routing', actions: 'reject'},
|
||||
'share.cancel': {target: 'routing', actions: 'reject'},
|
||||
@@ -380,8 +307,6 @@ export const voiceScreenShareStateMachine = setup({
|
||||
'share.encoderVerification.cleared': {target: 'routing', actions: 'clearEncoderVerification'},
|
||||
'share.streamingPriority.set': {target: 'routing', actions: 'setStreamingPriority'},
|
||||
'share.queuedStop.clear': {target: 'routing', actions: 'clearQueuedStop'},
|
||||
'share.queuedCodecRepublish.clear': {target: 'routing', actions: 'clearQueuedCodecRepublish'},
|
||||
'share.deferredCodecRepublish.clear': {target: 'routing', actions: 'clearDeferredCodecRepublish'},
|
||||
'share.localWatcher.sync': {target: 'routing', actions: 'syncLocalWatcher'},
|
||||
'share.clearWatchCommands': {actions: 'clearWatchCommands'},
|
||||
'share.reset': {target: 'routing', actions: 'reset'},
|
||||
@@ -393,9 +318,6 @@ export const voiceScreenShareStateMachine = setup({
|
||||
'share.restore': {target: 'routing', actions: 'restore'},
|
||||
'share.stop': {target: 'routing', actions: 'stop'},
|
||||
'share.replace': {target: 'routing', actions: 'replace'},
|
||||
'share.codecRepublish': {target: 'routing', actions: 'codecRepublish'},
|
||||
'share.codecRepublish.queue': {target: 'routing', actions: 'queueCodecRepublish'},
|
||||
'share.codecRepublish.defer': {target: 'routing', actions: 'deferCodecRepublish'},
|
||||
'share.resolve': {target: 'routing', actions: 'resolve'},
|
||||
'share.reject': {target: 'routing', actions: 'reject'},
|
||||
'share.cancel': {target: 'routing', actions: 'reject'},
|
||||
@@ -405,8 +327,6 @@ export const voiceScreenShareStateMachine = setup({
|
||||
'share.encoderVerification.cleared': {target: 'routing', actions: 'clearEncoderVerification'},
|
||||
'share.streamingPriority.set': {target: 'routing', actions: 'setStreamingPriority'},
|
||||
'share.queuedStop.clear': {target: 'routing', actions: 'clearQueuedStop'},
|
||||
'share.queuedCodecRepublish.clear': {target: 'routing', actions: 'clearQueuedCodecRepublish'},
|
||||
'share.deferredCodecRepublish.clear': {target: 'routing', actions: 'clearDeferredCodecRepublish'},
|
||||
'share.localWatcher.sync': {target: 'routing', actions: 'syncLocalWatcher'},
|
||||
'share.clearWatchCommands': {actions: 'clearWatchCommands'},
|
||||
'share.reset': {target: 'routing', actions: 'reset'},
|
||||
@@ -418,9 +338,6 @@ export const voiceScreenShareStateMachine = setup({
|
||||
'share.restore': {actions: 'restore'},
|
||||
'share.stop': {actions: 'stop'},
|
||||
'share.replace': {actions: 'replace'},
|
||||
'share.codecRepublish': {actions: 'codecRepublish'},
|
||||
'share.codecRepublish.queue': {actions: 'queueCodecRepublish'},
|
||||
'share.codecRepublish.defer': {actions: 'deferCodecRepublish'},
|
||||
'share.resolve': {target: 'routing', actions: 'resolve'},
|
||||
'share.reject': {target: 'routing', actions: 'reject'},
|
||||
'share.cancel': {target: 'routing', actions: 'reject'},
|
||||
@@ -430,8 +347,6 @@ export const voiceScreenShareStateMachine = setup({
|
||||
'share.encoderVerification.cleared': {actions: 'clearEncoderVerification'},
|
||||
'share.streamingPriority.set': {actions: 'setStreamingPriority'},
|
||||
'share.queuedStop.clear': {actions: 'clearQueuedStop'},
|
||||
'share.queuedCodecRepublish.clear': {actions: 'clearQueuedCodecRepublish'},
|
||||
'share.deferredCodecRepublish.clear': {actions: 'clearDeferredCodecRepublish'},
|
||||
'share.localWatcher.sync': {actions: 'syncLocalWatcher'},
|
||||
'share.clearWatchCommands': {actions: 'clearWatchCommands'},
|
||||
'share.reset': {target: 'routing', actions: 'reset'},
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {VoiceEngineV2VideoCodec} from '@fluxer/voice_engine_v2';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {capVoiceEngineV2DecodableVideoCodecs} from './VoiceEngineV2AppCodecCapability';
|
||||
|
||||
const FULL: Array<VoiceEngineV2VideoCodec> = ['av1', 'h265', 'vp9', 'h264', 'vp8'];
|
||||
|
||||
describe('capVoiceEngineV2DecodableVideoCodecs', () => {
|
||||
it('is a no-op for auto', () => {
|
||||
expect(capVoiceEngineV2DecodableVideoCodecs(FULL, 'auto')).toEqual(FULL);
|
||||
});
|
||||
|
||||
it('drops codecs more efficient than the cap so the advertised best is the cap', () => {
|
||||
expect(capVoiceEngineV2DecodableVideoCodecs(FULL, 'h264')).toEqual(['h264', 'vp8']);
|
||||
expect(capVoiceEngineV2DecodableVideoCodecs(FULL, 'vp9')).toEqual(['vp9', 'h264', 'vp8']);
|
||||
expect(capVoiceEngineV2DecodableVideoCodecs(FULL, 'vp8')).toEqual(['vp8']);
|
||||
});
|
||||
|
||||
it('keeps the cap and everything more compatible, regardless of true support gaps', () => {
|
||||
const partial: Array<VoiceEngineV2VideoCodec> = ['av1', 'h264', 'vp8'];
|
||||
expect(capVoiceEngineV2DecodableVideoCodecs(partial, 'vp9')).toEqual(['h264', 'vp8']);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CodecPreference} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {getVideoDecoderExclusionsSync} from '@app/features/voice/utils/VideoDecoderCapabilities';
|
||||
import {type VoiceEngineV2VideoCodec, voiceEngineV2VideoCodecRank} from '@fluxer/voice_engine_v2';
|
||||
|
||||
const ALL_VIDEO_CODECS: ReadonlyArray<VoiceEngineV2VideoCodec> = ['av1', 'h265', 'vp9', 'h264', 'vp8'];
|
||||
const BASELINE_VIDEO_CODECS: ReadonlyArray<VoiceEngineV2VideoCodec> = ['h264', 'vp8'];
|
||||
|
||||
function trueDecodableVideoCodecs(): Array<VoiceEngineV2VideoCodec> {
|
||||
const exclusions = getVideoDecoderExclusionsSync();
|
||||
if (exclusions === null) return [...BASELINE_VIDEO_CODECS];
|
||||
const excluded = new Set<string>(exclusions);
|
||||
return ALL_VIDEO_CODECS.filter((codec) => !excluded.has(codec));
|
||||
}
|
||||
|
||||
export function capVoiceEngineV2DecodableVideoCodecs(
|
||||
base: ReadonlyArray<VoiceEngineV2VideoCodec>,
|
||||
cap: CodecPreference,
|
||||
): Array<VoiceEngineV2VideoCodec> {
|
||||
if (cap === 'auto') return [...base];
|
||||
const capRank = voiceEngineV2VideoCodecRank(cap);
|
||||
return base.filter((codec) => voiceEngineV2VideoCodecRank(codec) >= capRank);
|
||||
}
|
||||
|
||||
export function getLocalDecodableVideoCodecs(cap: CodecPreference): Array<VoiceEngineV2VideoCodec> {
|
||||
return capVoiceEngineV2DecodableVideoCodecs(trueDecodableVideoCodecs(), cap);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {encodeVoiceEngineV2CodecGossip, type VoiceEngineV2WatchedStream} from '@fluxer/voice_engine_v2';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
computeVoiceEngineV2WatchedStreamGossip,
|
||||
ingestVoiceEngineV2CodecGossip,
|
||||
type VoiceEngineV2CodecGossipController,
|
||||
} from './VoiceEngineV2AppCodecGossipAdapter';
|
||||
|
||||
function recordingController(): VoiceEngineV2CodecGossipController & {
|
||||
viewerCalls: Array<unknown>;
|
||||
capabilityCalls: Array<unknown>;
|
||||
} {
|
||||
const viewerCalls: Array<unknown> = [];
|
||||
const capabilityCalls: Array<unknown> = [];
|
||||
return {
|
||||
viewerCalls,
|
||||
capabilityCalls,
|
||||
reportStreamViewer: (source, viewerIdentity, watching, supportedVideoCodecs) => {
|
||||
viewerCalls.push({source, viewerIdentity, watching, supportedVideoCodecs});
|
||||
},
|
||||
reportRemoteVideoCodecCapability: (identity, supportedVideoCodecs) => {
|
||||
capabilityCalls.push({identity, supportedVideoCodecs});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function watchedStream(participantIdentity: string, source: string): VoiceEngineV2WatchedStream {
|
||||
return {participantIdentity, source, trackSid: null, quality: null, enabled: true};
|
||||
}
|
||||
|
||||
describe('ingestVoiceEngineV2CodecGossip', () => {
|
||||
it('dispatches a viewing message as a stream-viewer report', () => {
|
||||
const controller = recordingController();
|
||||
const payload = encodeVoiceEngineV2CodecGossip({
|
||||
kind: 'codec.viewing',
|
||||
source: 'screen',
|
||||
watching: true,
|
||||
supportedVideoCodecs: ['h264', 'vp8'],
|
||||
});
|
||||
expect(ingestVoiceEngineV2CodecGossip(controller, 'bob', payload)).toBe(true);
|
||||
expect(controller.viewerCalls).toEqual([
|
||||
{source: 'screen', viewerIdentity: 'bob', watching: true, supportedVideoCodecs: ['h264', 'vp8']},
|
||||
]);
|
||||
});
|
||||
|
||||
it('dispatches a capability message as a remote capability report', () => {
|
||||
const controller = recordingController();
|
||||
const payload = encodeVoiceEngineV2CodecGossip({kind: 'codec.capability', supportedVideoCodecs: ['av1', 'vp9']});
|
||||
expect(ingestVoiceEngineV2CodecGossip(controller, 'alice', payload)).toBe(true);
|
||||
expect(controller.capabilityCalls).toEqual([{identity: 'alice', supportedVideoCodecs: ['av1', 'vp9']}]);
|
||||
});
|
||||
|
||||
it('ignores malformed payloads and unknown senders without touching the controller', () => {
|
||||
const controller = recordingController();
|
||||
expect(ingestVoiceEngineV2CodecGossip(controller, 'bob', 'garbage')).toBe(false);
|
||||
expect(
|
||||
ingestVoiceEngineV2CodecGossip(
|
||||
controller,
|
||||
'',
|
||||
encodeVoiceEngineV2CodecGossip({kind: 'codec.capability', supportedVideoCodecs: []}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(controller.viewerCalls).toEqual([]);
|
||||
expect(controller.capabilityCalls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeVoiceEngineV2WatchedStreamGossip', () => {
|
||||
it('emits watching=true for newly watched video streams only', () => {
|
||||
const previous = new Map<string, {identity: string; source: 'camera' | 'screen'}>();
|
||||
const result = computeVoiceEngineV2WatchedStreamGossip(
|
||||
previous,
|
||||
{
|
||||
a: watchedStream('alice', 'screen'),
|
||||
b: watchedStream('bob', 'camera'),
|
||||
c: watchedStream('carol', 'microphone'),
|
||||
},
|
||||
['av1', 'h264', 'vp8'],
|
||||
);
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
destinationIdentity: 'alice',
|
||||
message: {
|
||||
kind: 'codec.viewing',
|
||||
source: 'screen',
|
||||
watching: true,
|
||||
supportedVideoCodecs: ['av1', 'h264', 'vp8'],
|
||||
},
|
||||
},
|
||||
{
|
||||
destinationIdentity: 'bob',
|
||||
message: {
|
||||
kind: 'codec.viewing',
|
||||
source: 'camera',
|
||||
watching: true,
|
||||
supportedVideoCodecs: ['av1', 'h264', 'vp8'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.next.size).toBe(2);
|
||||
});
|
||||
|
||||
it('emits watching=false when a stream is no longer watched and nothing for steady state', () => {
|
||||
const first = computeVoiceEngineV2WatchedStreamGossip(new Map(), {a: watchedStream('alice', 'screen')}, ['h264']);
|
||||
const steady = computeVoiceEngineV2WatchedStreamGossip(first.next, {a: watchedStream('alice', 'screen')}, ['h264']);
|
||||
expect(steady.messages).toEqual([]);
|
||||
const stopped = computeVoiceEngineV2WatchedStreamGossip(steady.next, {}, ['h264']);
|
||||
expect(stopped.messages).toEqual([
|
||||
{
|
||||
destinationIdentity: 'alice',
|
||||
message: {kind: 'codec.viewing', source: 'screen', watching: false, supportedVideoCodecs: ['h264']},
|
||||
},
|
||||
]);
|
||||
expect(stopped.next.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
decodeVoiceEngineV2CodecGossip,
|
||||
type VoiceEngineV2CodecGossipMessage,
|
||||
type VoiceEngineV2LocalStreamSource,
|
||||
type VoiceEngineV2VideoCodec,
|
||||
type VoiceEngineV2WatchedStream,
|
||||
} from '@fluxer/voice_engine_v2';
|
||||
|
||||
export interface VoiceEngineV2CodecGossipController {
|
||||
reportStreamViewer(
|
||||
source: VoiceEngineV2LocalStreamSource,
|
||||
viewerIdentity: string,
|
||||
watching: boolean,
|
||||
supportedVideoCodecs: Array<VoiceEngineV2VideoCodec>,
|
||||
): void;
|
||||
reportRemoteVideoCodecCapability(identity: string, supportedVideoCodecs: Array<VoiceEngineV2VideoCodec>): void;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2WatchedStreamGossip {
|
||||
destinationIdentity: string;
|
||||
message: VoiceEngineV2CodecGossipMessage;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2WatchedStreamGossipResult {
|
||||
messages: Array<VoiceEngineV2WatchedStreamGossip>;
|
||||
next: Map<string, {identity: string; source: VoiceEngineV2LocalStreamSource}>;
|
||||
}
|
||||
|
||||
function watchedStreamGossipKey(identity: string, source: VoiceEngineV2LocalStreamSource): string {
|
||||
return `${source}\u0000${identity}`;
|
||||
}
|
||||
|
||||
export function ingestVoiceEngineV2CodecGossip(
|
||||
controller: VoiceEngineV2CodecGossipController,
|
||||
senderIdentity: string,
|
||||
payload: ArrayBuffer | ArrayBufferView | string,
|
||||
): boolean {
|
||||
if (senderIdentity.length === 0) return false;
|
||||
const message = decodeVoiceEngineV2CodecGossip(payload);
|
||||
if (message === null) return false;
|
||||
if (message.kind === 'codec.capability') {
|
||||
controller.reportRemoteVideoCodecCapability(senderIdentity, message.supportedVideoCodecs);
|
||||
return true;
|
||||
}
|
||||
controller.reportStreamViewer(message.source, senderIdentity, message.watching, message.supportedVideoCodecs);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function computeVoiceEngineV2WatchedStreamGossip(
|
||||
previous: Map<string, {identity: string; source: VoiceEngineV2LocalStreamSource}>,
|
||||
watchedStreams: Record<string, VoiceEngineV2WatchedStream>,
|
||||
localSupportedVideoCodecs: Array<VoiceEngineV2VideoCodec>,
|
||||
): VoiceEngineV2WatchedStreamGossipResult {
|
||||
const next = new Map<string, {identity: string; source: VoiceEngineV2LocalStreamSource}>();
|
||||
for (const stream of Object.values(watchedStreams)) {
|
||||
if (stream.source !== 'camera' && stream.source !== 'screen') continue;
|
||||
next.set(watchedStreamGossipKey(stream.participantIdentity, stream.source), {
|
||||
identity: stream.participantIdentity,
|
||||
source: stream.source,
|
||||
});
|
||||
}
|
||||
const messages: Array<VoiceEngineV2WatchedStreamGossip> = [];
|
||||
for (const [key, entry] of next) {
|
||||
if (previous.has(key)) continue;
|
||||
messages.push({
|
||||
destinationIdentity: entry.identity,
|
||||
message: {
|
||||
kind: 'codec.viewing',
|
||||
source: entry.source,
|
||||
watching: true,
|
||||
supportedVideoCodecs: localSupportedVideoCodecs,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const [key, entry] of previous) {
|
||||
if (next.has(key)) continue;
|
||||
messages.push({
|
||||
destinationIdentity: entry.identity,
|
||||
message: {
|
||||
kind: 'codec.viewing',
|
||||
source: entry.source,
|
||||
watching: false,
|
||||
supportedVideoCodecs: localSupportedVideoCodecs,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {messages, next};
|
||||
}
|
||||
@@ -1,595 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import type {NegotiationReason} from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import ScreenSharePublicationMigration from '@app/features/voice/engine/ScreenSharePublicationMigration';
|
||||
import {updateLocalParticipantFromRoom} from '@app/features/voice/engine/VoiceMediaEngineBridge';
|
||||
import {selectScreenShareCodecRepublishDecision} from '@app/features/voice/engine/VoiceScreenShareCodecRepublishPolicy';
|
||||
import {enforceLocalMediaPublicationCap} from '@app/features/voice/engine/VoiceTrackPublicationUtils';
|
||||
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
|
||||
import type {VoiceEngineV2AppScreenShareExecutionAdapter} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter';
|
||||
import {logger, releaseScreenShareCaptureCleanup} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||
import {SCREEN_SHARE_DEGRADATION_PREFERENCE} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {
|
||||
type LocalParticipant,
|
||||
type LocalTrackPublication,
|
||||
type LocalVideoTrack,
|
||||
type Room,
|
||||
type ScreenShareCaptureOptions,
|
||||
Track,
|
||||
type TrackPublishOptions,
|
||||
type VideoCodec,
|
||||
} from 'livekit-client';
|
||||
|
||||
interface MigrationContext {
|
||||
readonly room: Room;
|
||||
readonly participant: LocalParticipant;
|
||||
readonly publication: LocalTrackPublication;
|
||||
readonly screenShareTrack: LocalVideoTrack;
|
||||
readonly mediaStreamTrack: MediaStreamTrack;
|
||||
readonly previousOptions: TrackPublishOptions;
|
||||
readonly nextPublishOptions: TrackPublishOptions;
|
||||
readonly currentCodec?: VideoCodec;
|
||||
readonly codec: VideoCodec;
|
||||
readonly reason: NegotiationReason;
|
||||
readonly forced: boolean;
|
||||
readonly migrationId: string;
|
||||
readonly generation: number;
|
||||
readonly previousTrackSid: string | null;
|
||||
readonly previousTrackName: string | undefined;
|
||||
}
|
||||
|
||||
type ScreenShareVideoConstraints = MediaTrackConstraints & {
|
||||
colorSpace?: string;
|
||||
cursor?: 'always' | 'motion' | 'never';
|
||||
};
|
||||
|
||||
interface MigrationState {
|
||||
candidateMediaStreamTrack: MediaStreamTrack | null;
|
||||
candidatePublication: LocalTrackPublication | null;
|
||||
candidateTrack: LocalVideoTrack | undefined;
|
||||
oldVideoUnpublished: boolean;
|
||||
committedTrackSid: string | null;
|
||||
}
|
||||
|
||||
export class VoiceEngineV2AppScreenShareCodecMigration {
|
||||
private readonly adapter: VoiceEngineV2AppScreenShareExecutionAdapter;
|
||||
|
||||
constructor(adapter: VoiceEngineV2AppScreenShareExecutionAdapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
private async applyActiveAudioSetting(participant: LocalParticipant, audio: boolean): Promise<boolean> {
|
||||
assert.ok(participant);
|
||||
assert.equal(typeof audio, 'boolean');
|
||||
const screenShareAudioPublication = participant.getTrackPublication(Track.Source.ScreenShareAudio);
|
||||
if (!screenShareAudioPublication) {
|
||||
if (audio) {
|
||||
logger.info('Cannot enable screen share audio without restarting screen share');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (audio) {
|
||||
await screenShareAudioPublication.unmute();
|
||||
} else {
|
||||
await screenShareAudioPublication.mute();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to update active screen share audio state', {error, includeAudio: audio});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async applyActiveResolutionSetting(
|
||||
screenShareTrack: LocalVideoTrack,
|
||||
resolution: NonNullable<ScreenShareCaptureOptions['resolution']>,
|
||||
): Promise<boolean> {
|
||||
assert.ok(screenShareTrack);
|
||||
assert.ok(resolution);
|
||||
const mediaStreamTrack = screenShareTrack.mediaStreamTrack;
|
||||
const currentConstraints = mediaStreamTrack.getConstraints() as ScreenShareVideoConstraints;
|
||||
const nextConstraints: ScreenShareVideoConstraints = {...currentConstraints};
|
||||
if (resolution.width > 0) nextConstraints.width = {ideal: resolution.width};
|
||||
if (resolution.height > 0) nextConstraints.height = {ideal: resolution.height};
|
||||
if (resolution.frameRate !== undefined) {
|
||||
nextConstraints.frameRate = {ideal: resolution.frameRate, max: resolution.frameRate};
|
||||
}
|
||||
if (JSON.stringify(currentConstraints) === JSON.stringify(nextConstraints)) return true;
|
||||
try {
|
||||
await mediaStreamTrack.applyConstraints(nextConstraints);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to update active screen share constraints', {error, resolution});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateActiveSettings(
|
||||
room: Room | null,
|
||||
options?: ScreenShareCaptureOptions,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
): Promise<boolean> {
|
||||
assert.ok(options === undefined || typeof options === 'object');
|
||||
if (Platform.OS !== 'web') {
|
||||
logger.warn('Screen share updates are not supported on native');
|
||||
return false;
|
||||
}
|
||||
const participant = room?.localParticipant;
|
||||
if (!participant || !participant.isScreenShareEnabled) return false;
|
||||
const screenSharePublication = participant.getTrackPublication(Track.Source.ScreenShare);
|
||||
const screenShareTrack = screenSharePublication?.videoTrack;
|
||||
if (!screenShareTrack) {
|
||||
logger.warn('No active screen share track to update');
|
||||
return false;
|
||||
}
|
||||
let appliedAnySetting = false;
|
||||
if (typeof options?.audio === 'boolean') {
|
||||
appliedAnySetting = (await this.applyActiveAudioSetting(participant, options.audio)) || appliedAnySetting;
|
||||
}
|
||||
if (options?.resolution) {
|
||||
appliedAnySetting =
|
||||
(await this.applyActiveResolutionSetting(screenShareTrack, options.resolution)) || appliedAnySetting;
|
||||
}
|
||||
if (options && Object.hasOwn(options, 'contentHint')) {
|
||||
screenShareTrack.mediaStreamTrack.contentHint = options.contentHint ?? '';
|
||||
appliedAnySetting = true;
|
||||
}
|
||||
await screenShareTrack.setDegradationPreference(SCREEN_SHARE_DEGRADATION_PREFERENCE);
|
||||
await this.adapter.enforceScreenShareSenderParametersInternal(participant, publishOptions);
|
||||
this.adapter.ensureScreenShareKeepAliveSinkInternal(participant);
|
||||
appliedAnySetting = true;
|
||||
updateLocalParticipantFromRoom(room);
|
||||
this.adapter.syncLocalScreenShareAudioStateInternal(participant, participant.isScreenShareEnabled);
|
||||
return appliedAnySetting;
|
||||
}
|
||||
|
||||
private getMigrationPublishOptions(
|
||||
publishOptions: TrackPublishOptions | undefined,
|
||||
trackName: string | undefined,
|
||||
): TrackPublishOptions {
|
||||
return {
|
||||
...publishOptions,
|
||||
source: Track.Source.ScreenShare,
|
||||
stream: VoiceTrackSource.ScreenShare,
|
||||
...(trackName ? {name: trackName} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private getMigrationPublicationTrack(
|
||||
publication: LocalTrackPublication,
|
||||
role: 'candidate' | 'fallback',
|
||||
): {track: LocalVideoTrack; trackSid: string} {
|
||||
const track = publication.videoTrack as LocalVideoTrack | undefined;
|
||||
if (!track) {
|
||||
throw new Error(`screen share codec migration ${role} published without a local video track`);
|
||||
}
|
||||
const trackSid = publication.trackSid;
|
||||
if (!trackSid) {
|
||||
throw new Error(`screen share codec migration ${role} published without a track SID`);
|
||||
}
|
||||
return {track, trackSid};
|
||||
}
|
||||
|
||||
private async activateMigrationPublication(args: {
|
||||
room: Room;
|
||||
participant: LocalParticipant;
|
||||
publication: LocalTrackPublication;
|
||||
track: LocalVideoTrack;
|
||||
publishOptions?: TrackPublishOptions;
|
||||
reapplySenderParameters?: boolean;
|
||||
}): Promise<void> {
|
||||
const {room, participant, publication, track, publishOptions, reapplySenderParameters = false} = args;
|
||||
await enforceLocalMediaPublicationCap(participant, VoiceTrackSource.ScreenShare, {
|
||||
preferredPublication: publication,
|
||||
});
|
||||
this.adapter.applyScreenShareContentHintInternal(participant, undefined, track);
|
||||
if (reapplySenderParameters) {
|
||||
await this.adapter.enforceScreenShareTrackSenderParametersInternal(track, publishOptions);
|
||||
}
|
||||
this.adapter.bindScreenShareSenderParameterReapplyInternal(participant, publishOptions, track);
|
||||
this.adapter.ensureScreenShareKeepAliveSinkInternal(participant, track);
|
||||
this.adapter.monitorActiveScreenShareEndInternal(room, participant, track);
|
||||
this.adapter.startEncoderVerificationInternal(room, participant, publishOptions?.videoCodec, track);
|
||||
this.adapter.applyScreenShareStateInternal(true, {reason: 'server', sendUpdate: false});
|
||||
updateLocalParticipantFromRoom(room);
|
||||
this.adapter.syncLocalStreamWatchStateInternal(true);
|
||||
this.adapter.syncLocalScreenShareAudioStateInternal(participant, true);
|
||||
}
|
||||
|
||||
private async commitMigration(ctx: MigrationContext, state: MigrationState, trackSid: string): Promise<void> {
|
||||
assert.equal(typeof trackSid, 'string');
|
||||
assert.ok(state);
|
||||
const commitMessage = {
|
||||
migration_id: ctx.migrationId,
|
||||
generation: ctx.generation,
|
||||
previous_track_sid: ctx.previousTrackSid,
|
||||
candidate_track_sid: trackSid,
|
||||
};
|
||||
await ScreenSharePublicationMigration.publishCommit(ctx.room, commitMessage).catch((error) => {
|
||||
logger.warn('Failed to publish screen share codec migration commit', {
|
||||
error,
|
||||
migrationId: ctx.migrationId,
|
||||
trackSid,
|
||||
});
|
||||
});
|
||||
ScreenSharePublicationMigration.markLocalMigrationCommitted(ctx.participant.identity, commitMessage);
|
||||
state.committedTrackSid = trackSid;
|
||||
}
|
||||
|
||||
private async abortMigration(ctx: MigrationContext, state: MigrationState, abortReason: string): Promise<void> {
|
||||
assert.ok(ctx);
|
||||
assert.equal(typeof abortReason, 'string');
|
||||
if (state.committedTrackSid) return;
|
||||
const abortMessage = {
|
||||
migration_id: ctx.migrationId,
|
||||
generation: ctx.generation,
|
||||
candidate_track_sid: state.candidatePublication?.trackSid ?? null,
|
||||
reason: abortReason,
|
||||
};
|
||||
await ScreenSharePublicationMigration.publishAbort(ctx.room, abortMessage).catch((error) => {
|
||||
logger.warn('Failed to publish screen share codec migration abort', {
|
||||
error,
|
||||
migrationId: ctx.migrationId,
|
||||
reason: abortReason,
|
||||
});
|
||||
});
|
||||
ScreenSharePublicationMigration.markLocalMigrationAborted(ctx.participant.identity, abortMessage);
|
||||
}
|
||||
|
||||
private isPreviousCodecRestorable(ctx: MigrationContext, state: MigrationState): boolean {
|
||||
if (!state.oldVideoUnpublished) return false;
|
||||
if (ctx.mediaStreamTrack.readyState === 'ended') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async restorePreviousCodec(
|
||||
ctx: MigrationContext,
|
||||
state: MigrationState,
|
||||
restoreError: unknown,
|
||||
): Promise<boolean> {
|
||||
assert.ok(ctx);
|
||||
assert.ok(state);
|
||||
if (!this.isPreviousCodecRestorable(ctx, state)) return false;
|
||||
try {
|
||||
const effectivePreviousOptions = await this.adapter.getEffectivePublishOptionsInternal(true, ctx.previousOptions);
|
||||
const fallbackPublication = await ctx.participant.publishTrack(
|
||||
ctx.mediaStreamTrack,
|
||||
this.getMigrationPublishOptions(effectivePreviousOptions, ctx.previousTrackName),
|
||||
);
|
||||
const fallbackTrackInfo = this.getMigrationPublicationTrack(fallbackPublication, 'fallback');
|
||||
await this.adapter.enforceScreenShareTrackSenderParametersInternal(
|
||||
fallbackTrackInfo.track,
|
||||
effectivePreviousOptions,
|
||||
);
|
||||
await this.commitMigration(ctx, state, fallbackTrackInfo.trackSid);
|
||||
await this.activateMigrationPublication({
|
||||
room: ctx.room,
|
||||
participant: ctx.participant,
|
||||
publication: fallbackPublication,
|
||||
track: fallbackTrackInfo.track,
|
||||
publishOptions: effectivePreviousOptions,
|
||||
});
|
||||
logger.warn('Restored previous screen share codec after break-before-make migration failed', {
|
||||
error: restoreError,
|
||||
migrationId: ctx.migrationId,
|
||||
previousCodec: ctx.currentCodec,
|
||||
failedCodec: ctx.codec,
|
||||
reason: ctx.reason,
|
||||
});
|
||||
return true;
|
||||
} catch (fallbackError) {
|
||||
logger.warn('Failed to restore previous screen share codec after break-before-make migration failed', {
|
||||
error: fallbackError,
|
||||
originalError: restoreError,
|
||||
migrationId: ctx.migrationId,
|
||||
previousCodec: ctx.currentCodec,
|
||||
failedCodec: ctx.codec,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async announceBreakAndUnpublish(ctx: MigrationContext, state: MigrationState): Promise<void> {
|
||||
assert.ok(ctx);
|
||||
assert.ok(state);
|
||||
const publishBreakMessage = {
|
||||
migration_id: ctx.migrationId,
|
||||
generation: ctx.generation,
|
||||
previous_track_sid: ctx.previousTrackSid,
|
||||
codec: ctx.codec,
|
||||
reason: ctx.reason,
|
||||
};
|
||||
ScreenSharePublicationMigration.markLocalMigrationBreaking(ctx.participant.identity, publishBreakMessage);
|
||||
await ScreenSharePublicationMigration.publishBreak(ctx.room, {
|
||||
migrationId: ctx.migrationId,
|
||||
generation: ctx.generation,
|
||||
previousTrackSid: ctx.previousTrackSid,
|
||||
codec: ctx.codec,
|
||||
reason: ctx.reason,
|
||||
}).catch((error) => {
|
||||
logger.warn('Failed to publish screen share codec break-before-make announcement', {
|
||||
error,
|
||||
migrationId: ctx.migrationId,
|
||||
codec: ctx.codec,
|
||||
reason: ctx.reason,
|
||||
});
|
||||
});
|
||||
this.adapter.cleanupActiveScreenShareEndListenerInternal();
|
||||
this.adapter.applyScreenShareStateInternal(true, {reason: 'server', sendUpdate: false});
|
||||
const codecRepublishCleanupSnapshot = this.adapter.getScreenShareSimulcastCleanupSnapshotInternal(
|
||||
ctx.screenShareTrack,
|
||||
);
|
||||
await ctx.participant.unpublishTrack(ctx.screenShareTrack, false);
|
||||
state.oldVideoUnpublished = true;
|
||||
this.adapter.applyScreenShareStateInternal(true, {reason: 'server', sendUpdate: false});
|
||||
await releaseScreenShareCaptureCleanup(codecRepublishCleanupSnapshot);
|
||||
}
|
||||
|
||||
private async runMigrationHappyPath(ctx: MigrationContext, state: MigrationState): Promise<void> {
|
||||
assert.ok(ctx);
|
||||
assert.ok(state);
|
||||
const effectivePublishOptions = await this.adapter.getEffectivePublishOptionsInternal(true, ctx.nextPublishOptions);
|
||||
state.candidateMediaStreamTrack =
|
||||
typeof ctx.mediaStreamTrack.clone === 'function' ? ctx.mediaStreamTrack.clone() : ctx.mediaStreamTrack;
|
||||
this.adapter.applyScreenShareContentHintToMediaTrackInternal(state.candidateMediaStreamTrack);
|
||||
this.adapter.cancelEncoderVerificationInternal();
|
||||
await this.announceBreakAndUnpublish(ctx, state);
|
||||
state.candidatePublication = await ctx.participant.publishTrack(
|
||||
state.candidateMediaStreamTrack,
|
||||
this.getMigrationPublishOptions(effectivePublishOptions, ctx.previousTrackName),
|
||||
);
|
||||
const candidateTrackInfo = this.getMigrationPublicationTrack(state.candidatePublication, 'candidate');
|
||||
state.candidateTrack = candidateTrackInfo.track;
|
||||
await this.adapter.enforceScreenShareTrackSenderParametersInternal(state.candidateTrack, effectivePublishOptions);
|
||||
await this.commitMigration(ctx, state, candidateTrackInfo.trackSid);
|
||||
if (state.candidateMediaStreamTrack !== ctx.mediaStreamTrack) {
|
||||
this.adapter.stopMediaTrackInternal(ctx.mediaStreamTrack);
|
||||
}
|
||||
await this.activateMigrationPublication({
|
||||
room: ctx.room,
|
||||
participant: ctx.participant,
|
||||
publication: state.candidatePublication,
|
||||
track: state.candidateTrack,
|
||||
publishOptions: effectivePublishOptions,
|
||||
reapplySenderParameters: true,
|
||||
});
|
||||
this.adapter.transitionScreenShareLifecycleInternal({
|
||||
type: 'share.resolve',
|
||||
active: true,
|
||||
sourceType: this.adapter.getActiveScreenShareSourceTypeInternal(),
|
||||
encoderVerificationScheduled: this.adapter.encoderVerificationTimer != null,
|
||||
streamingPriorityHeld: this.adapter.streamingPriorityHeld,
|
||||
});
|
||||
logger.info('Migrated active screen share codec with break-before-make publication switch', {
|
||||
migrationId: ctx.migrationId,
|
||||
previousCodec: ctx.currentCodec,
|
||||
codec: ctx.codec,
|
||||
reason: ctx.reason,
|
||||
forced: ctx.forced,
|
||||
strategy: 'break-before-make',
|
||||
});
|
||||
}
|
||||
|
||||
private hasCandidateMediaTrackToStop(ctx: MigrationContext, state: MigrationState): boolean {
|
||||
if (state.committedTrackSid) return false;
|
||||
if (!state.candidateMediaStreamTrack) return false;
|
||||
return state.candidateMediaStreamTrack !== ctx.mediaStreamTrack;
|
||||
}
|
||||
|
||||
private async cleanupCandidateAfterFailure(ctx: MigrationContext, state: MigrationState): Promise<void> {
|
||||
assert.ok(ctx);
|
||||
assert.ok(state);
|
||||
if (!state.committedTrackSid && state.candidateTrack) {
|
||||
await ctx.participant.unpublishTrack(state.candidateTrack, false).catch((unpublishError) => {
|
||||
logger.warn('Failed to unpublish screen share codec migration candidate after abort', {
|
||||
error: unpublishError,
|
||||
migrationId: ctx.migrationId,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (this.hasCandidateMediaTrackToStop(ctx, state)) {
|
||||
assert.ok(state.candidateMediaStreamTrack, 'candidateMediaStreamTrack expected after guard');
|
||||
this.adapter.stopMediaTrackInternal(state.candidateMediaStreamTrack);
|
||||
}
|
||||
}
|
||||
|
||||
private async finalizeMigrationFailure(ctx: MigrationContext, state: MigrationState): Promise<void> {
|
||||
assert.ok(ctx);
|
||||
assert.ok(state);
|
||||
await this.abortMigration(ctx, state, state.oldVideoUnpublished ? 'publisher-error' : 'unpublish-error');
|
||||
const actual = ctx.participant.isScreenShareEnabled;
|
||||
this.adapter.applyScreenShareStateInternal(actual, {forceSync: !actual, reason: 'user', sendUpdate: !actual});
|
||||
updateLocalParticipantFromRoom(ctx.room);
|
||||
this.adapter.syncLocalStreamWatchStateInternal(actual);
|
||||
this.adapter.syncLocalScreenShareAudioStateInternal(ctx.participant, actual);
|
||||
if (actual) {
|
||||
this.adapter.monitorActiveScreenShareEndInternal(ctx.room, ctx.participant);
|
||||
await this.adapter.enforceScreenShareSenderParametersInternal(ctx.participant, ctx.previousOptions);
|
||||
this.adapter.ensureScreenShareKeepAliveSinkInternal(ctx.participant);
|
||||
} else {
|
||||
await this.adapter.cleanupLingeringScreenShareTracks(ctx.participant);
|
||||
}
|
||||
this.adapter.transitionScreenShareLifecycleInternal({
|
||||
type: 'share.reject',
|
||||
active: actual,
|
||||
sourceType: actual ? this.adapter.getActiveScreenShareSourceTypeInternal() : null,
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMigrationFailure(ctx: MigrationContext, state: MigrationState, error: unknown): Promise<boolean> {
|
||||
assert.ok(ctx);
|
||||
assert.ok(state);
|
||||
logger.warn('Failed to migrate active screen share codec with break-before-make', {
|
||||
error,
|
||||
migrationId: ctx.migrationId,
|
||||
codec: ctx.codec,
|
||||
reason: ctx.reason,
|
||||
});
|
||||
await this.cleanupCandidateAfterFailure(ctx, state);
|
||||
const shouldAttemptRestore = !state.committedTrackSid;
|
||||
if (shouldAttemptRestore && (await this.restorePreviousCodec(ctx, state, error))) {
|
||||
this.adapter.transitionScreenShareLifecycleInternal({
|
||||
type: 'share.resolve',
|
||||
active: true,
|
||||
sourceType: this.adapter.getActiveScreenShareSourceTypeInternal(),
|
||||
encoderVerificationScheduled: this.adapter.encoderVerificationTimer != null,
|
||||
streamingPriorityHeld: this.adapter.streamingPriorityHeld,
|
||||
});
|
||||
logger.info('Completed screen share codec migration by falling back to previous codec', {
|
||||
migrationId: ctx.migrationId,
|
||||
previousCodec: ctx.currentCodec,
|
||||
failedCodec: ctx.codec,
|
||||
reason: ctx.reason,
|
||||
forced: ctx.forced,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
await this.finalizeMigrationFailure(ctx, state);
|
||||
return false;
|
||||
}
|
||||
|
||||
private async migrateBreakBeforeMake(args: {
|
||||
room: Room;
|
||||
participant: LocalParticipant;
|
||||
publication: LocalTrackPublication;
|
||||
screenShareTrack: LocalVideoTrack;
|
||||
mediaStreamTrack: MediaStreamTrack;
|
||||
previousOptions: TrackPublishOptions;
|
||||
nextPublishOptions: TrackPublishOptions;
|
||||
currentCodec?: VideoCodec;
|
||||
codec: VideoCodec;
|
||||
reason: NegotiationReason;
|
||||
forced: boolean;
|
||||
}): Promise<boolean> {
|
||||
assert.ok(args.room);
|
||||
assert.ok(args.participant);
|
||||
const ctx: MigrationContext = {
|
||||
...args,
|
||||
migrationId: ScreenSharePublicationMigration.createMigrationId(),
|
||||
generation: ++this.adapter.screenShareMigrationGeneration,
|
||||
previousTrackSid: args.publication.trackSid ?? null,
|
||||
previousTrackName: args.publication.trackName,
|
||||
};
|
||||
const state: MigrationState = {
|
||||
candidateMediaStreamTrack: null,
|
||||
candidatePublication: null,
|
||||
candidateTrack: undefined,
|
||||
oldVideoUnpublished: false,
|
||||
committedTrackSid: null,
|
||||
};
|
||||
this.adapter.transitionScreenShareLifecycleInternal({type: 'share.codecRepublish'});
|
||||
try {
|
||||
await this.runMigrationHappyPath(ctx, state);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return this.handleMigrationFailure(ctx, state, error);
|
||||
} finally {
|
||||
await this.adapter.applyPendingScreenShareRequestsInternal(ctx.room, ctx.participant);
|
||||
}
|
||||
}
|
||||
|
||||
private async applyCodecRenegotiationNoop(
|
||||
participant: LocalParticipant,
|
||||
previousOptions: TrackPublishOptions,
|
||||
currentCodec: VideoCodec | undefined,
|
||||
): Promise<void> {
|
||||
assert.ok(participant);
|
||||
const effectivePublishOptions = await this.adapter.getEffectivePublishOptionsInternal(true, {
|
||||
...previousOptions,
|
||||
...(currentCodec ? {videoCodec: currentCodec} : {}),
|
||||
});
|
||||
await this.adapter.enforceScreenShareSenderParametersInternal(participant, effectivePublishOptions);
|
||||
}
|
||||
|
||||
private buildCodecRenegotiationPublishOptions(
|
||||
previousOptions: TrackPublishOptions,
|
||||
codec: VideoCodec,
|
||||
currentCodec: VideoCodec | undefined,
|
||||
force: boolean,
|
||||
): TrackPublishOptions {
|
||||
assert.equal(typeof force, 'boolean');
|
||||
const nextPublishOptions: TrackPublishOptions = {...previousOptions, videoCodec: codec};
|
||||
const codecChanged = currentCodec !== codec;
|
||||
if (force || codecChanged) {
|
||||
delete nextPublishOptions.backupCodec;
|
||||
delete nextPublishOptions.backupCodecPolicy;
|
||||
delete nextPublishOptions.scalabilityMode;
|
||||
delete nextPublishOptions.simulcast;
|
||||
}
|
||||
return nextPublishOptions;
|
||||
}
|
||||
|
||||
async renegotiateActiveCodec(
|
||||
room: Room | null,
|
||||
codec: VideoCodec,
|
||||
reason: NegotiationReason,
|
||||
options: {force?: boolean} = {},
|
||||
): Promise<boolean> {
|
||||
assert.equal(typeof codec, 'string');
|
||||
assert.ok(reason !== undefined, 'reason required');
|
||||
if (Platform.OS !== 'web') return false;
|
||||
const participant = room?.localParticipant;
|
||||
if (!room) return false;
|
||||
if (!participant) return false;
|
||||
if (!participant.isScreenShareEnabled) return false;
|
||||
if (this.adapter.isScreenSharePending) {
|
||||
this.adapter.queuePendingCodecRepublishRequestInternal(codec, reason, options);
|
||||
return false;
|
||||
}
|
||||
await enforceLocalMediaPublicationCap(participant, VoiceTrackSource.ScreenShare);
|
||||
const publication = participant.getTrackPublication(Track.Source.ScreenShare);
|
||||
const screenShareTrack = publication?.videoTrack as LocalVideoTrack | undefined;
|
||||
if (!publication) return false;
|
||||
if (!screenShareTrack) return false;
|
||||
if (screenShareTrack.mediaStreamTrack.readyState === 'ended') return false;
|
||||
const mediaStreamTrack = screenShareTrack.mediaStreamTrack;
|
||||
const previousOptions = ((publication as {options?: TrackPublishOptions}).options ?? {}) as TrackPublishOptions;
|
||||
const currentCodec = screenShareTrack.codec ?? previousOptions.videoCodec;
|
||||
const decision = selectScreenShareCodecRepublishDecision({
|
||||
currentCodec,
|
||||
nextCodec: codec,
|
||||
reason,
|
||||
force: options.force,
|
||||
});
|
||||
if (decision.action === 'noop') {
|
||||
await this.applyCodecRenegotiationNoop(participant, previousOptions, currentCodec);
|
||||
return false;
|
||||
}
|
||||
if (decision.action === 'defer') {
|
||||
this.adapter.deferActiveCodecRepublishRequestInternal(codec, reason, options);
|
||||
logger.info('Deferring negotiated screen share codec change until the next share start', {
|
||||
currentCodec,
|
||||
codec,
|
||||
reason,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const nextPublishOptions = this.buildCodecRenegotiationPublishOptions(
|
||||
previousOptions,
|
||||
codec,
|
||||
currentCodec,
|
||||
options.force === true,
|
||||
);
|
||||
if (currentCodec === codec && !options.force) {
|
||||
const effectivePublishOptions = await this.adapter.getEffectivePublishOptionsInternal(true, nextPublishOptions);
|
||||
await this.adapter.enforceScreenShareSenderParametersInternal(participant, effectivePublishOptions);
|
||||
return false;
|
||||
}
|
||||
return this.migrateBreakBeforeMake({
|
||||
room,
|
||||
participant,
|
||||
publication,
|
||||
screenShareTrack,
|
||||
mediaStreamTrack,
|
||||
previousOptions,
|
||||
nextPublishOptions,
|
||||
currentCodec,
|
||||
codec,
|
||||
reason,
|
||||
forced: options.force === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
+156
-110
@@ -4,15 +4,18 @@ import assert from 'node:assert/strict';
|
||||
import i18n from '@app/app/I18n';
|
||||
import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModal';
|
||||
import {SoundType} from '@app/features/notification/utils/SoundUtils';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import * as SoundCommands from '@app/features/ui/commands/SoundCommands';
|
||||
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
|
||||
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
|
||||
import {getStreamKey} from '@app/features/voice/components/StreamKeys';
|
||||
import type {NegotiationReason} from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import ScreenShareCodecNegotiation from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import {Store} from '@app/features/voice/engine/Store';
|
||||
import {getVoiceConnectionContextFromMediaEngine} from '@app/features/voice/engine/VoiceMediaEngineBridge';
|
||||
import {
|
||||
getVoiceConnectionContextFromMediaEngine,
|
||||
updateLocalParticipantFromRoom,
|
||||
} from '@app/features/voice/engine/VoiceMediaEngineBridge';
|
||||
import {
|
||||
normalizeVoiceMediaGraphViewerStreamKeys,
|
||||
selectVoiceMediaGraphViewerStreamKeys,
|
||||
@@ -29,7 +32,6 @@ import {
|
||||
getLocalScreenShareVideoPublications,
|
||||
unpublishLocalMediaPublications,
|
||||
} from '@app/features/voice/engine/VoiceTrackPublicationUtils';
|
||||
import {VoiceEngineV2AppScreenShareCodecMigration} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareCodecMigration';
|
||||
import {
|
||||
selectVoiceEngineV2AppScreenShareSetEnabledOptions,
|
||||
type VoiceEngineV2AppScreenShareControllerGateway,
|
||||
@@ -52,7 +54,6 @@ import {
|
||||
getEffectivePublishOptions as resolveEffectivePublishOptions,
|
||||
type ScreenShareCaptureCleanupSnapshot,
|
||||
type ScreenShareCodecReadinessStatus,
|
||||
type SimulcastTrackInfoLike,
|
||||
scheduleScreenShareEncoderVerification,
|
||||
stopMediaTrack,
|
||||
} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||
@@ -73,6 +74,7 @@ import {
|
||||
commitNativeAudioBridgeReplacement,
|
||||
disarmNativeAudio,
|
||||
} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
||||
import {SCREEN_SHARE_DEGRADATION_PREFERENCE} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {ScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError';
|
||||
import {handleScreenShareError} from '@app/features/voice/utils/ScreenShareUtils';
|
||||
import type {NativeAudioStartOptions} from '@app/types/electron.d';
|
||||
@@ -105,6 +107,17 @@ const SCREEN_SHARE_SOURCE_STOPPED_DESCRIPTOR = msg({
|
||||
'Body of a modal shown when a browser screen share track ends outside the app, for example from the browser sharing controls.',
|
||||
context: 'screen-share',
|
||||
});
|
||||
const SCREEN_SHARE_ENCODER_FAILED_DESCRIPTOR = msg({
|
||||
message: 'Your video encoder stopped encoding frames, so your screen share was stopped. Try sharing again.',
|
||||
comment:
|
||||
'Body of a modal shown when the local video encoder produces no frames for an active screen share, which forces the share to stop.',
|
||||
context: 'screen-share',
|
||||
});
|
||||
|
||||
type ScreenShareVideoConstraints = MediaTrackConstraints & {
|
||||
colorSpace?: string;
|
||||
cursor?: 'always' | 'motion' | 'never';
|
||||
};
|
||||
|
||||
export interface ScreenShareReconnectSnapshot {
|
||||
videoTrack: MediaStreamTrack;
|
||||
@@ -121,10 +134,8 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
private activeScreenShareEndListener: (() => void) | null = null;
|
||||
private endedScreenShareStopInFlight: Promise<void> | null = null;
|
||||
encoderVerificationTimer: NodeJS.Timeout | null = null;
|
||||
screenShareMigrationGeneration = 0;
|
||||
sourceLifecycleBridge: VoiceEngineV2AppSourceLifecycleBridge | null = null;
|
||||
|
||||
readonly codecMigration: VoiceEngineV2AppScreenShareCodecMigration;
|
||||
readonly liveKitFlows: VoiceEngineV2AppScreenShareLiveKitFlows;
|
||||
readonly controllerRouting: VoiceEngineV2AppScreenShareControllerRouting;
|
||||
|
||||
@@ -134,7 +145,6 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
this.trackPlumbing = new VoiceEngineV2AppScreenShareTrackPlumbing({
|
||||
getActiveContentSource: () => this.getActiveScreenShareContentSourceInternal(),
|
||||
});
|
||||
this.codecMigration = new VoiceEngineV2AppScreenShareCodecMigration(this);
|
||||
this.liveKitFlows = new VoiceEngineV2AppScreenShareLiveKitFlows(this);
|
||||
this.controllerRouting = new VoiceEngineV2AppScreenShareControllerRouting(this);
|
||||
}
|
||||
@@ -193,8 +203,8 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
this.trackPlumbing.clearKeepAliveSink();
|
||||
}
|
||||
|
||||
isScreenShareCodecRepublishInFlight(): boolean {
|
||||
return this.lifecycle.codecRepublishInFlight;
|
||||
isScreenSharePublicationReplaceInFlight(): boolean {
|
||||
return this.lifecycle.publicationReplaceInFlight;
|
||||
}
|
||||
|
||||
transitionScreenShareLifecycleInternal(event: VoiceScreenShareEvent): void {
|
||||
@@ -225,29 +235,10 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
this.lifecycle.queueStopRequest(options);
|
||||
}
|
||||
|
||||
queuePendingCodecRepublishRequestInternal(
|
||||
codec: VideoCodec,
|
||||
reason: NegotiationReason,
|
||||
options: {force?: boolean} = {},
|
||||
): void {
|
||||
this.lifecycle.queueCodecRepublishRequest(codec, reason, options);
|
||||
}
|
||||
|
||||
deferActiveCodecRepublishRequestInternal(
|
||||
codec: VideoCodec,
|
||||
reason: NegotiationReason,
|
||||
options: {force?: boolean} = {},
|
||||
): void {
|
||||
this.lifecycle.deferCodecRepublishRequest(codec, reason, options);
|
||||
}
|
||||
|
||||
async applyPendingScreenShareRequestsInternal(room: Room | null, participant: LocalParticipant): Promise<void> {
|
||||
await this.lifecycle.drainQueuedRequests({
|
||||
isScreenShareEnabled: () => participant.isScreenShareEnabled,
|
||||
applyStop: (request) => this.setScreenShareEnabled(room, false, request),
|
||||
applyCodecRepublish: async (request) => {
|
||||
await this.renegotiateActiveScreenShareCodec(room, request.codec, request.reason, {force: request.force});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -278,13 +269,6 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
this.trackPlumbing.applyContentHint(participant, contentSource, preferredTrack);
|
||||
}
|
||||
|
||||
applyScreenShareContentHintToMediaTrackInternal(
|
||||
mediaStreamTrack: MediaStreamTrack | undefined,
|
||||
contentSource: ScreenShareContentSource = this.getActiveScreenShareContentSourceInternal(),
|
||||
): void {
|
||||
this.trackPlumbing.applyContentHintToMediaTrack(mediaStreamTrack, contentSource);
|
||||
}
|
||||
|
||||
async enforceScreenShareSenderParametersInternal(
|
||||
participant: LocalParticipant,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
@@ -292,21 +276,6 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
await this.trackPlumbing.enforceSenderParameters(participant, publishOptions);
|
||||
}
|
||||
|
||||
async enforceScreenShareTrackSenderParametersInternal(
|
||||
track: LocalVideoTrack | undefined,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
): Promise<void> {
|
||||
await this.trackPlumbing.enforceTrackSenderParameters(track, publishOptions);
|
||||
}
|
||||
|
||||
bindScreenShareSenderParameterReapplyInternal(
|
||||
participant: LocalParticipant,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
preferredTrack?: LocalVideoTrack,
|
||||
): void {
|
||||
this.trackPlumbing.bindSenderParameterReapply(participant, publishOptions, preferredTrack);
|
||||
}
|
||||
|
||||
applyScreenShareAudioContentHintInternal(participant: LocalParticipant): void {
|
||||
this.trackPlumbing.applyAudioContentHint(participant);
|
||||
}
|
||||
@@ -403,36 +372,6 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
);
|
||||
}
|
||||
|
||||
getScreenShareSimulcastCleanupSnapshotInternal(screenShareTrack: LocalVideoTrack): ScreenShareCaptureCleanupSnapshot {
|
||||
const snapshot: ScreenShareCaptureCleanupSnapshot = {mediaTracks: [], senders: []};
|
||||
const primarySender = screenShareTrack.sender;
|
||||
if (primarySender) {
|
||||
snapshot.senders.push({sender: primarySender, expectedTrack: primarySender.track});
|
||||
}
|
||||
const simulcastCodecs = (
|
||||
screenShareTrack as LocalVideoTrack & {
|
||||
simulcastCodecs?: Map<unknown, SimulcastTrackInfoLike>;
|
||||
}
|
||||
).simulcastCodecs;
|
||||
for (const simulcastTrackInfo of simulcastCodecs?.values() ?? []) {
|
||||
snapshot.mediaTracks.push(simulcastTrackInfo.mediaStreamTrack);
|
||||
if (simulcastTrackInfo.sender) {
|
||||
const senderTarget = {
|
||||
sender: simulcastTrackInfo.sender,
|
||||
expectedTrack: simulcastTrackInfo.sender.track,
|
||||
};
|
||||
if (
|
||||
!snapshot.senders.some(
|
||||
(target) => target.sender === senderTarget.sender && target.expectedTrack === senderTarget.expectedTrack,
|
||||
)
|
||||
) {
|
||||
snapshot.senders.push(senderTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
cleanupActiveScreenShareEndListenerInternal(): void {
|
||||
this.activeScreenShareEndListener?.();
|
||||
this.activeScreenShareEndListener = null;
|
||||
@@ -460,6 +399,56 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
);
|
||||
}
|
||||
|
||||
private isScreenShareTrackPublishedInternal(participant: LocalParticipant, track: LocalVideoTrack): boolean {
|
||||
return getLocalScreenShareVideoPublications(participant).some((publication) => {
|
||||
const publishedTrack =
|
||||
(publication.videoTrack as LocalVideoTrack | undefined) ?? (publication.track as LocalVideoTrack | undefined);
|
||||
return publishedTrack === track;
|
||||
});
|
||||
}
|
||||
|
||||
private async recoverActiveScreenShareAfterEncoderFailure(
|
||||
room: Room | null,
|
||||
participant: LocalParticipant,
|
||||
track: LocalVideoTrack,
|
||||
failedCodec: VideoCodec,
|
||||
): Promise<void> {
|
||||
if (!this.isScreenShareTrackPublishedInternal(participant, track)) return;
|
||||
await ScreenShareCodecNegotiation.publishLocalCapabilities(room, 'manual');
|
||||
const codec = ScreenShareCodecNegotiation.selectScreenShareCodec(VoiceSettings.getPreferredScreenShareCodec());
|
||||
if (codec !== failedCodec) {
|
||||
const recovered = await this.liveKitFlows.republishActiveShareWithCodec(room, track, codec);
|
||||
if (recovered) return;
|
||||
}
|
||||
this.stopScreenShareAfterEncoderFailure(room, participant, track, failedCodec);
|
||||
}
|
||||
|
||||
private stopScreenShareAfterEncoderFailure(
|
||||
room: Room | null,
|
||||
participant: LocalParticipant,
|
||||
track: LocalVideoTrack,
|
||||
codec: VideoCodec,
|
||||
): void {
|
||||
logger.error('Screen share encoder produced no frames and no other codec took over; disabling screen share', {
|
||||
codec,
|
||||
});
|
||||
this.showScreenShareEndedModalInternal(i18n._(SCREEN_SHARE_ENCODER_FAILED_DESCRIPTOR));
|
||||
if (this.endedScreenShareStopInFlight) return;
|
||||
if (!this.isScreenShareTrackPublishedInternal(participant, track)) return;
|
||||
this.transitionScreenShareLifecycleInternal({type: 'share.endedStop.start'});
|
||||
const stopPromise = this.setScreenShareEnabled(room, false, {sendUpdate: true, playSound: true})
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to disable screen share after encoder failure', {error, codec});
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.endedScreenShareStopInFlight === stopPromise) {
|
||||
this.endedScreenShareStopInFlight = null;
|
||||
}
|
||||
this.transitionScreenShareLifecycleInternal({type: 'share.endedStop.finish'});
|
||||
});
|
||||
this.endedScreenShareStopInFlight = stopPromise;
|
||||
}
|
||||
|
||||
startEncoderVerificationInternal(
|
||||
room: Room | null,
|
||||
participant: LocalParticipant,
|
||||
@@ -471,7 +460,7 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
const publication = preferredTrack ? undefined : participant.getTrackPublication(Track.Source.ScreenShare);
|
||||
const track = preferredTrack ?? (publication?.videoTrack as LocalVideoTrack | undefined);
|
||||
const sender = track?.sender;
|
||||
if (!sender) {
|
||||
if (!track || !sender) {
|
||||
logger.warn('No sender found for screen share encoder verification');
|
||||
return;
|
||||
}
|
||||
@@ -481,27 +470,23 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
(failure) => {
|
||||
const failureReason =
|
||||
failure.reason === 'codec-mismatch' ? 'screen-share-codec-mismatch' : 'screen-share-encode-stalled';
|
||||
if (!markScreenShareCodecEncodeRuntimeFailure(failure.codec, failureReason)) return;
|
||||
void this.recoverActiveScreenShareAfterEncoderFailure(room, failure.codec).catch((error) => {
|
||||
logger.warn('Failed to recover screen share after encoder verification failure', {
|
||||
error,
|
||||
codec: failure.codec,
|
||||
failureReason,
|
||||
});
|
||||
});
|
||||
markScreenShareCodecEncodeRuntimeFailure(failure.codec, failureReason);
|
||||
logger.warn('Screen share encoder verification failed', {codec: failure.codec, failureReason});
|
||||
if (failure.reason !== 'stalled') return;
|
||||
void this.recoverActiveScreenShareAfterEncoderFailure(room, participant, track, failure.codec).catch(
|
||||
(error) => {
|
||||
logger.warn('Failed to recover screen share after encoder verification failure', {
|
||||
error,
|
||||
codec: failure.codec,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
this.trackPlumbing.bindKeyFrameRequests(room, participant, track);
|
||||
this.transitionScreenShareLifecycleInternal({type: 'share.encoderVerification.scheduled'});
|
||||
}
|
||||
|
||||
private async recoverActiveScreenShareAfterEncoderFailure(room: Room | null, failedCodec: VideoCodec): Promise<void> {
|
||||
await ScreenShareCodecNegotiation.publishLocalCapabilities(room, 'manual', {});
|
||||
const codec = ScreenShareCodecNegotiation.selectScreenShareCodec(VoiceSettings.getPreferredScreenShareCodec());
|
||||
if (codec === failedCodec) return;
|
||||
await this.renegotiateActiveScreenShareCodec(room, codec, 'manual', {force: true});
|
||||
}
|
||||
|
||||
monitorActiveScreenShareEndInternal(
|
||||
room: Room | null,
|
||||
participant: LocalParticipant,
|
||||
@@ -790,10 +775,6 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
await this.applyPendingScreenShareRequestsInternal(room, participant);
|
||||
}
|
||||
|
||||
stopMediaTrackInternal(track: MediaStreamTrack | undefined): void {
|
||||
stopMediaTrack(track);
|
||||
}
|
||||
|
||||
async publishControllerScreenViaLiveKitFlows(room: Room | null, options: VoiceEngineV2ScreenOptions): Promise<void> {
|
||||
if (typeof options.captureId !== 'string' || options.captureId.length === 0) {
|
||||
throw new Error('Controller screen-share publish requires a captureId');
|
||||
@@ -805,21 +786,86 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
await this.controllerRouting.unpublishViaLiveKitFlows(room);
|
||||
}
|
||||
|
||||
private async applyActiveScreenShareAudioSetting(participant: LocalParticipant, audio: boolean): Promise<boolean> {
|
||||
assert.ok(participant);
|
||||
assert.equal(typeof audio, 'boolean');
|
||||
const screenShareAudioPublication = participant.getTrackPublication(Track.Source.ScreenShareAudio);
|
||||
if (!screenShareAudioPublication) {
|
||||
if (audio) {
|
||||
logger.info('Cannot enable screen share audio without restarting screen share');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (audio) {
|
||||
await screenShareAudioPublication.unmute();
|
||||
} else {
|
||||
await screenShareAudioPublication.mute();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to update active screen share audio state', {error, includeAudio: audio});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async applyActiveScreenShareResolutionSetting(
|
||||
screenShareTrack: LocalVideoTrack,
|
||||
resolution: NonNullable<ScreenShareCaptureOptions['resolution']>,
|
||||
): Promise<boolean> {
|
||||
assert.ok(screenShareTrack);
|
||||
assert.ok(resolution);
|
||||
const mediaStreamTrack = screenShareTrack.mediaStreamTrack;
|
||||
const currentConstraints = mediaStreamTrack.getConstraints() as ScreenShareVideoConstraints;
|
||||
const nextConstraints: ScreenShareVideoConstraints = {...currentConstraints};
|
||||
if (resolution.width > 0) nextConstraints.width = {ideal: resolution.width};
|
||||
if (resolution.height > 0) nextConstraints.height = {ideal: resolution.height};
|
||||
if (resolution.frameRate !== undefined) {
|
||||
nextConstraints.frameRate = {ideal: resolution.frameRate, max: resolution.frameRate};
|
||||
}
|
||||
if (JSON.stringify(currentConstraints) === JSON.stringify(nextConstraints)) return true;
|
||||
try {
|
||||
await mediaStreamTrack.applyConstraints(nextConstraints);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to update active screen share constraints', {error, resolution});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateActiveScreenShareSettings(
|
||||
room: Room | null,
|
||||
options?: ScreenShareCaptureOptions,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
): Promise<boolean> {
|
||||
return this.codecMigration.updateActiveSettings(room, options, publishOptions);
|
||||
}
|
||||
|
||||
async renegotiateActiveScreenShareCodec(
|
||||
room: Room | null,
|
||||
codec: VideoCodec,
|
||||
reason: NegotiationReason,
|
||||
options: {force?: boolean} = {},
|
||||
): Promise<boolean> {
|
||||
return this.codecMigration.renegotiateActiveCodec(room, codec, reason, options);
|
||||
assert.ok(options === undefined || typeof options === 'object');
|
||||
if (Platform.OS !== 'web') {
|
||||
logger.warn('Screen share updates are not supported on native');
|
||||
return false;
|
||||
}
|
||||
const participant = room?.localParticipant;
|
||||
if (!participant || !participant.isScreenShareEnabled) return false;
|
||||
const screenSharePublication = participant.getTrackPublication(Track.Source.ScreenShare);
|
||||
const screenShareTrack = screenSharePublication?.videoTrack;
|
||||
if (!screenShareTrack) {
|
||||
logger.warn('No active screen share track to update');
|
||||
return false;
|
||||
}
|
||||
if (typeof options?.audio === 'boolean') {
|
||||
await this.applyActiveScreenShareAudioSetting(participant, options.audio);
|
||||
}
|
||||
if (options?.resolution) {
|
||||
await this.applyActiveScreenShareResolutionSetting(screenShareTrack, options.resolution);
|
||||
}
|
||||
if (options && Object.hasOwn(options, 'contentHint')) {
|
||||
screenShareTrack.mediaStreamTrack.contentHint = options.contentHint ?? '';
|
||||
}
|
||||
await screenShareTrack.setDegradationPreference(SCREEN_SHARE_DEGRADATION_PREFERENCE);
|
||||
await this.enforceScreenShareSenderParametersInternal(participant, publishOptions);
|
||||
this.ensureScreenShareKeepAliveSinkInternal(participant);
|
||||
updateLocalParticipantFromRoom(room);
|
||||
this.syncLocalScreenShareAudioStateInternal(participant, participant.isScreenShareEnabled);
|
||||
return true;
|
||||
}
|
||||
|
||||
setScreenShareAudioMuted(room: Room | null, muted: boolean): void {
|
||||
|
||||
+121
-1
@@ -69,6 +69,7 @@ import {
|
||||
type ScreenShareCaptureOptions,
|
||||
Track,
|
||||
type TrackPublishOptions,
|
||||
type VideoCodec,
|
||||
} from 'livekit-client';
|
||||
|
||||
function isUserCancelledScreenShareError(error: unknown): boolean {
|
||||
@@ -624,7 +625,7 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
this.adapter.transitionScreenShareLifecycleInternal({
|
||||
type: 'share.replace',
|
||||
sourceType: 'display',
|
||||
codecRepublishInFlight: true,
|
||||
publicationReplaceInFlight: true,
|
||||
});
|
||||
try {
|
||||
const tracks = await createDisplayScreenShareTracks(options, captureContext);
|
||||
@@ -681,6 +682,125 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
return didReplace;
|
||||
}
|
||||
|
||||
async republishActiveShareWithCodec(
|
||||
room: Room | null,
|
||||
screenShareTrack: LocalVideoTrack,
|
||||
codec: VideoCodec,
|
||||
): Promise<boolean> {
|
||||
if (guardScreenShareEntry({platformUnsupportedWarning: SCREEN_SHARE_UNSUPPORTED_PLATFORM_WARNING}) !== 'proceed') {
|
||||
return false;
|
||||
}
|
||||
const participant = room?.localParticipant;
|
||||
if (!room || !participant || !participant.isScreenShareEnabled) {
|
||||
logger.warn('No active screen share to republish');
|
||||
return false;
|
||||
}
|
||||
const pendingVerdict = guardScreenShareEntry({
|
||||
pending: {
|
||||
active: this.adapter.isScreenSharePending,
|
||||
debugMessage: 'Already pending, ignoring screen share codec republish',
|
||||
},
|
||||
});
|
||||
if (pendingVerdict === 'share-pending') {
|
||||
return false;
|
||||
}
|
||||
const publication = getLocalScreenShareVideoPublications(participant).find(
|
||||
(candidate) => (candidate.videoTrack ?? candidate.track) === screenShareTrack,
|
||||
);
|
||||
if (!publication) {
|
||||
logger.warn('Screen share track is no longer published; skipping codec republish', {codec});
|
||||
return false;
|
||||
}
|
||||
const mediaStreamTrack = screenShareTrack.mediaStreamTrack;
|
||||
if (mediaStreamTrack.readyState !== 'live') {
|
||||
logger.warn('Screen share source track ended before codec republish', {codec});
|
||||
return false;
|
||||
}
|
||||
const previousOptions = ((publication as {options?: TrackPublishOptions}).options ?? {}) as TrackPublishOptions;
|
||||
const nextPublishOptions: TrackPublishOptions = {...previousOptions, videoCodec: codec};
|
||||
delete nextPublishOptions.backupCodec;
|
||||
delete nextPublishOptions.backupCodecPolicy;
|
||||
delete nextPublishOptions.scalabilityMode;
|
||||
delete nextPublishOptions.simulcast;
|
||||
const contentSource: ScreenShareContentSource =
|
||||
ActiveScreenShareSource.getShareContext() ?? this.adapter.getActiveScreenShareContentSourceInternal();
|
||||
const sourceType = this.adapter.getScreenShareSourceTypeForContentSourceInternal(contentSource);
|
||||
this.adapter.transitionScreenShareLifecycleInternal({
|
||||
type: 'share.replace',
|
||||
sourceType,
|
||||
publicationReplaceInFlight: true,
|
||||
});
|
||||
this.adapter.cancelEncoderVerificationInternal();
|
||||
this.adapter.cleanupActiveScreenShareEndListenerInternal();
|
||||
let videoPublished = false;
|
||||
try {
|
||||
await participant.unpublishTrack(screenShareTrack, false);
|
||||
const effectivePublishOptions = await this.adapter.getEffectivePublishOptionsInternal(true, nextPublishOptions);
|
||||
await participant.publishTrack(mediaStreamTrack, {
|
||||
...effectivePublishOptions,
|
||||
source: Track.Source.ScreenShare,
|
||||
stream: VoiceTrackSource.ScreenShare,
|
||||
...(publication.trackName ? {name: publication.trackName} : {}),
|
||||
});
|
||||
videoPublished = true;
|
||||
await runScreenShareActivationRitual({
|
||||
adapter: this.adapter,
|
||||
room,
|
||||
participant,
|
||||
active: true,
|
||||
steps: {
|
||||
acquireStreamingPriority: false,
|
||||
enforcePublicationCap: true,
|
||||
applyState: () => applyScreenShareState(this.adapter, true, true, true),
|
||||
applyStatePosition: 'after-pipeline',
|
||||
publishPipeline: {contentSource, effectivePublishOptions},
|
||||
deactivateCleanup: null,
|
||||
updateLocalParticipant: true,
|
||||
audioSync: {kind: 'participant-after-watch'},
|
||||
syncPersistedAudioPreferenceWhenActive: true,
|
||||
playSound: false,
|
||||
buildResolveTransition: () => ({
|
||||
type: 'share.resolve',
|
||||
active: true,
|
||||
sourceType,
|
||||
encoderVerificationScheduled: this.adapter.encoderVerificationTimer != null,
|
||||
streamingPriorityHeld: this.adapter.streamingPriorityHeld,
|
||||
}),
|
||||
},
|
||||
});
|
||||
logger.info('Republished active screen share with a different codec', {
|
||||
previousCodec: previousOptions.videoCodec,
|
||||
codec,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to republish active screen share with a different codec', {error, codec, videoPublished});
|
||||
const actual = participant.isScreenShareEnabled;
|
||||
if (!actual) {
|
||||
await this.adapter.cleanupLingeringScreenShareTracks(participant).catch((cleanupError) => {
|
||||
logger.warn('Failed to clean up screen share after codec republish failure', {error: cleanupError});
|
||||
});
|
||||
}
|
||||
return settleScreenShareFailure({
|
||||
adapter: this.adapter,
|
||||
room,
|
||||
participant,
|
||||
actual,
|
||||
applyState: (actualNow) => applyScreenShareState(this.adapter, actualNow, true, true),
|
||||
onInactiveAfterSync: () => stopMediaTrack(mediaStreamTrack),
|
||||
monitorEndOnActive: true,
|
||||
playSound: false,
|
||||
buildTransition: (actualNow) => ({
|
||||
type: 'share.reject',
|
||||
active: actualNow,
|
||||
sourceType: actualNow ? sourceType : null,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await this.adapter.applyPendingScreenShareRequestsInternal(room, participant);
|
||||
}
|
||||
}
|
||||
|
||||
private captureScreenShareReplacementSnapshot(
|
||||
participant: LocalParticipant,
|
||||
screenShareTrack: LocalVideoTrack,
|
||||
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import ScreenSharePublicationMigration from '@app/features/voice/engine/ScreenSharePublicationMigration';
|
||||
import {getPreferredScreenShareCodec, logger} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||
import {type LocalParticipant, type Room, Track, type TrackPublishOptions, type VideoCodec} from 'livekit-client';
|
||||
|
||||
export interface ScreenShareRestartMigrationSession {
|
||||
readonly migrationId: string;
|
||||
readonly generation: number;
|
||||
readonly participantIdentity: string;
|
||||
readonly previousTrackSid: string | null;
|
||||
readonly candidateTrackSid: string;
|
||||
}
|
||||
|
||||
function isVideoCodecValue(value: unknown): value is VideoCodec {
|
||||
return value === 'av1' || value === 'h265' || value === 'h264' || value === 'vp9' || value === 'vp8';
|
||||
}
|
||||
|
||||
export function resolveScreenShareRestartMigrationCodec(
|
||||
nextPublishOptions: TrackPublishOptions | undefined,
|
||||
previousPublishOptions: TrackPublishOptions | undefined,
|
||||
): VideoCodec {
|
||||
const nextCodec = nextPublishOptions?.videoCodec;
|
||||
if (isVideoCodecValue(nextCodec)) return nextCodec;
|
||||
const previousCodec = previousPublishOptions?.videoCodec;
|
||||
if (isVideoCodecValue(previousCodec)) return previousCodec;
|
||||
return getPreferredScreenShareCodec();
|
||||
}
|
||||
|
||||
function getPreviousScreenShareTrackSid(participant: LocalParticipant): string | null {
|
||||
assert.ok(participant, 'participant is required');
|
||||
const publication = participant.getTrackPublication(Track.Source.ScreenShare);
|
||||
if (typeof publication?.trackSid === 'string' && publication.trackSid.length > 0) return publication.trackSid;
|
||||
for (const candidate of participant.trackPublications.values()) {
|
||||
if (candidate.source !== Track.Source.ScreenShare) continue;
|
||||
if (typeof candidate.trackSid === 'string' && candidate.trackSid.length > 0) return candidate.trackSid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function announceScreenShareRestartMigration(args: {
|
||||
readonly room: Room | null;
|
||||
readonly generation: number;
|
||||
readonly previousPublishOptions: TrackPublishOptions | undefined;
|
||||
readonly nextPublishOptions: TrackPublishOptions | undefined;
|
||||
readonly candidateTrackSid: string;
|
||||
readonly reason: string;
|
||||
}): Promise<ScreenShareRestartMigrationSession | null> {
|
||||
assert.ok(args.room === null || typeof args.room === 'object');
|
||||
assert.ok(Number.isInteger(args.generation) && args.generation > 0);
|
||||
assert.ok(args.candidateTrackSid.length > 0, 'candidateTrackSid is required');
|
||||
assert.ok(args.reason.length > 0, 'reason is required');
|
||||
const participant = args.room?.localParticipant;
|
||||
if (!participant) return null;
|
||||
const previousTrackSid = getPreviousScreenShareTrackSid(participant);
|
||||
const migrationId = ScreenSharePublicationMigration.createMigrationId();
|
||||
const codec = resolveScreenShareRestartMigrationCodec(args.nextPublishOptions, args.previousPublishOptions);
|
||||
const breakMessage = {
|
||||
migration_id: migrationId,
|
||||
generation: args.generation,
|
||||
previous_track_sid: previousTrackSid,
|
||||
codec,
|
||||
reason: args.reason,
|
||||
};
|
||||
ScreenSharePublicationMigration.markLocalMigrationBreaking(participant.identity, breakMessage);
|
||||
await ScreenSharePublicationMigration.publishBreak(args.room, {
|
||||
migrationId,
|
||||
generation: args.generation,
|
||||
previousTrackSid,
|
||||
codec,
|
||||
reason: args.reason,
|
||||
}).catch((error) => {
|
||||
logger.warn('Failed to publish native screen-share restart migration break', {error, migrationId});
|
||||
});
|
||||
return {
|
||||
migrationId,
|
||||
generation: args.generation,
|
||||
participantIdentity: participant.identity,
|
||||
previousTrackSid,
|
||||
candidateTrackSid: args.candidateTrackSid,
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitScreenShareRestartMigration(
|
||||
room: Room | null,
|
||||
session: ScreenShareRestartMigrationSession | null,
|
||||
candidateTrackSid: string,
|
||||
): Promise<void> {
|
||||
if (!session || !room?.localParticipant) return;
|
||||
assert.ok(candidateTrackSid.length > 0, 'candidateTrackSid is required');
|
||||
const commitMessage = {
|
||||
migration_id: session.migrationId,
|
||||
generation: session.generation,
|
||||
previous_track_sid: session.previousTrackSid,
|
||||
candidate_track_sid: candidateTrackSid,
|
||||
};
|
||||
await ScreenSharePublicationMigration.publishCommit(room, commitMessage).catch((error) => {
|
||||
logger.warn('Failed to publish native screen-share restart migration commit', {
|
||||
error,
|
||||
migrationId: session.migrationId,
|
||||
candidateTrackSid,
|
||||
});
|
||||
});
|
||||
ScreenSharePublicationMigration.markLocalMigrationCommitted(session.participantIdentity, commitMessage);
|
||||
}
|
||||
|
||||
export async function abortScreenShareRestartMigration(
|
||||
room: Room | null,
|
||||
session: ScreenShareRestartMigrationSession | null,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
if (!session || !room?.localParticipant) return;
|
||||
assert.ok(reason.length > 0, 'reason is required');
|
||||
const abortMessage = {
|
||||
migration_id: session.migrationId,
|
||||
generation: session.generation,
|
||||
candidate_track_sid: null,
|
||||
reason,
|
||||
};
|
||||
await ScreenSharePublicationMigration.publishAbort(room, abortMessage).catch((error) => {
|
||||
logger.warn('Failed to publish native screen-share restart migration abort', {
|
||||
error,
|
||||
migrationId: session.migrationId,
|
||||
});
|
||||
});
|
||||
ScreenSharePublicationMigration.markLocalMigrationAborted(session.participantIdentity, abortMessage);
|
||||
}
|
||||
Reference in new issue
Block a user