refactor(voice): remove screen share codec renegotiation (#2417)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-03 04:46:11 +02:00
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;
@@ -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,
});
}
}
@@ -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 {
@@ -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,
@@ -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);
}