mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 12:42:27 +09:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9027cbdf3e | ||
|
|
2119e10ed5 | ||
|
|
87f3eb3c81 | ||
|
|
f9bb8bd585 | ||
|
|
bc47a724af | ||
|
|
f32356801d | ||
|
|
efd677f32b |
@@ -104,6 +104,12 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
#FLUXER_NCMEC_ENABLED=false
|
||||
#FLUXER_CLAMAV_ENABLED=false
|
||||
|
||||
# Outside lookups, off unless turned on. The Tor exit list comes from
|
||||
# onionoo.torproject.org and the breached password check asks
|
||||
# api.pwnedpasswords.com.
|
||||
#FLUXER_TOR_EXIT_LIST_ENABLED=true
|
||||
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=true
|
||||
|
||||
# The client address. Name the header your proxy actually writes, and turn the
|
||||
# trust off when nothing sits in front.
|
||||
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
|
||||
|
||||
@@ -24,6 +24,8 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
|
||||
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
|
||||
FLUXER_TOR_EXIT_LIST_ENABLED: "${FLUXER_TOR_EXIT_LIST_ENABLED:-false}"
|
||||
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: "${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-false}"
|
||||
|
||||
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
|
||||
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
|
||||
|
||||
@@ -447,10 +447,10 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
|
||||
}
|
||||
}
|
||||
|
||||
const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
|
||||
const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
|
||||
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
|
||||
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
|
||||
@@ -476,35 +476,36 @@ where
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_rollout_salt(
|
||||
fn parse_experiment_rollout_salt(
|
||||
form: &MultiValueForm,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(raw) = form.first("voice_ns_rollout_salt") else {
|
||||
let Some(raw) = form.first(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let salt = raw.trim();
|
||||
if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS {
|
||||
if salt.is_empty() || salt.encode_utf16().count() > EXPERIMENT_MAX_ROLLOUT_SALT_CHARS {
|
||||
return Err(format!(
|
||||
"Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
));
|
||||
}
|
||||
Ok(Some(salt.to_owned()))
|
||||
}
|
||||
|
||||
fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
|
||||
fn is_experiment_snowflake(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
|
||||
let candidate = candidate.trim();
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_voice_noise_suppression_snowflake(candidate) {
|
||||
if !is_experiment_snowflake(candidate) {
|
||||
return Err(format!(
|
||||
"{label} entry {} must contain 1 to 20 decimal digits",
|
||||
index + 1
|
||||
@@ -536,7 +537,7 @@ fn parse_voice_noise_suppression_guild_overrides(
|
||||
format!("Guild overrides line {line_number} must use guild_id=backend")
|
||||
})?;
|
||||
let guild_id = guild_id.trim();
|
||||
if !is_voice_noise_suppression_snowflake(guild_id) {
|
||||
if !is_experiment_snowflake(guild_id) {
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
|
||||
));
|
||||
@@ -602,14 +603,14 @@ fn build_voice_noise_suppression_update(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?,
|
||||
included_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_included_user_ids").unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
@@ -1307,9 +1308,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() {
|
||||
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec![
|
||||
"1".to_owned(),
|
||||
@@ -1322,16 +1323,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
|
||||
fn parse_experiment_user_ids_dedupes_preserving_order() {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"),
|
||||
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
for value in [
|
||||
"abc",
|
||||
"12a",
|
||||
@@ -1341,11 +1341,8 @@ mod tests {
|
||||
"<script>",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(
|
||||
&format!("123,{value}"),
|
||||
"Included user IDs"
|
||||
)
|
||||
.expect_err("invalid ID"),
|
||||
parse_experiment_user_ids(&format!("123,{value}"), "Included user IDs")
|
||||
.expect_err("invalid ID"),
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{value}"
|
||||
);
|
||||
@@ -1353,18 +1350,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_exceeding_the_cap() {
|
||||
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
|
||||
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let ids =
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
|
||||
assert_eq!(ids.last(), Some(&"999".to_owned()));
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
.expect_err("too many IDs"),
|
||||
"Included user IDs must contain at most 1000 unique IDs"
|
||||
);
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
maxInflightRequests: config.maxInflightRequests,
|
||||
torExitBlockingEnabled: config.torExitList.enabled,
|
||||
});
|
||||
routes.onError(AbuseAwareAppErrorHandler);
|
||||
routes.notFound(AppNotFoundHandler);
|
||||
|
||||
@@ -166,3 +166,57 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
|
||||
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, undefined)).stripe.legacyPrices).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function withOptionalOutboundLookups(
|
||||
master: MasterConfig,
|
||||
selfHosted: boolean,
|
||||
overrides: {torExitList?: boolean; breachedPasswordCheck?: boolean} = {},
|
||||
): MasterConfig {
|
||||
return {
|
||||
...master,
|
||||
integrations: {
|
||||
...master.integrations,
|
||||
tor_exit_list: {enabled: overrides.torExitList},
|
||||
breached_password_check: {enabled: overrides.breachedPasswordCheck},
|
||||
},
|
||||
instance: {
|
||||
...master.instance,
|
||||
self_hosted: selfHosted,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAPIConfigFromMaster optional outbound lookups', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('keeps both lookups on when the instance is not self-hosted', () => {
|
||||
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, false));
|
||||
expect(config.torExitList.enabled).toBe(true);
|
||||
expect(config.breachedPasswordCheck.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves both lookups off on a self-hosted instance', () => {
|
||||
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, true));
|
||||
expect(config.torExitList.enabled).toBe(false);
|
||||
expect(config.breachedPasswordCheck.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a self-hosted operator switch each lookup on', () => {
|
||||
const config = buildAPIConfigFromMaster(
|
||||
withOptionalOutboundLookups(master, true, {torExitList: true, breachedPasswordCheck: true}),
|
||||
);
|
||||
expect(config.torExitList.enabled).toBe(true);
|
||||
expect(config.breachedPasswordCheck.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an operator switch each lookup off when the instance is not self-hosted', () => {
|
||||
const config = buildAPIConfigFromMaster(
|
||||
withOptionalOutboundLookups(master, false, {torExitList: false, breachedPasswordCheck: false}),
|
||||
);
|
||||
expect(config.torExitList.enabled).toBe(false);
|
||||
expect(config.breachedPasswordCheck.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -348,6 +348,12 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
blocklistFeeds: {
|
||||
enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted,
|
||||
},
|
||||
torExitList: {
|
||||
enabled: master.integrations.tor_exit_list.enabled ?? !master.instance.self_hosted,
|
||||
},
|
||||
breachedPasswordCheck: {
|
||||
enabled: master.integrations.breached_password_check.enabled ?? !master.instance.self_hosted,
|
||||
},
|
||||
captcha: {
|
||||
enabled: master.integrations.captcha.enabled,
|
||||
provider: master.integrations.captcha.provider,
|
||||
|
||||
@@ -137,9 +137,11 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
|
||||
await initializeRefreshCache(ipBanCache, 'IP ban cache', logger);
|
||||
await startAbuseReplicationSubscriber(kvClient);
|
||||
logger.info('Abusive-IP auto-banner replication started');
|
||||
torExitListCache.setKvClient(kvClient);
|
||||
await torExitListCache.initialize();
|
||||
logger.info('Tor exit list cache initialized');
|
||||
if (config.torExitList.enabled) {
|
||||
torExitListCache.setKvClient(kvClient);
|
||||
await torExitListCache.initialize();
|
||||
logger.info('Tor exit list cache initialized');
|
||||
}
|
||||
const {urlBlocklistCache} = await import('@app/api/middleware/UrlBlocklistCache');
|
||||
urlBlocklistCache.setRefreshSubscriber(kvClient);
|
||||
const {getStorageService} = await import('@app/api/middleware/ServiceSingletons');
|
||||
|
||||
@@ -29,10 +29,19 @@ interface MiddlewarePipelineOptions {
|
||||
trustClientIpHeader: boolean;
|
||||
clientIpHeaderName?: string;
|
||||
maxInflightRequests: number;
|
||||
torExitBlockingEnabled: boolean;
|
||||
}
|
||||
|
||||
export function configureMiddleware(routes: HonoApp, options: MiddlewarePipelineOptions): void {
|
||||
const {logger, nodeEnv, corsOrigins, trustClientIpHeader, clientIpHeaderName, maxInflightRequests} = options;
|
||||
const {
|
||||
logger,
|
||||
nodeEnv,
|
||||
corsOrigins,
|
||||
trustClientIpHeader,
|
||||
clientIpHeaderName,
|
||||
maxInflightRequests,
|
||||
torExitBlockingEnabled,
|
||||
} = options;
|
||||
const resolvedHeader = resolveClientIpHeaderName(clientIpHeaderName);
|
||||
routes.use('/webhooks/:webhook_id/:token', cors({origins: '*'}));
|
||||
routes.use('/webhooks/:webhook_id/:token/messages/:message_id', cors({origins: '*'}));
|
||||
@@ -100,7 +109,9 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
|
||||
}),
|
||||
);
|
||||
}
|
||||
routes.use(TorExitMiddleware);
|
||||
if (torExitBlockingEnabled) {
|
||||
routes.use(TorExitMiddleware);
|
||||
}
|
||||
routes.use(AuditLogMiddleware);
|
||||
routes.use(RequireClientIpMiddleware());
|
||||
routes.use(ServiceMiddleware);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
|
||||
import {TorExitMiddleware} from '@app/api/middleware/TorExitMiddleware';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {Hono} from 'hono';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function registeredHandlers(torExitBlockingEnabled: boolean): Array<unknown> {
|
||||
const routes = new Hono<HonoEnv>({strict: true});
|
||||
configureMiddleware(routes, {
|
||||
logger: new NoopLogger(),
|
||||
nodeEnv: 'test',
|
||||
corsOrigins: ['http://localhost:3000'],
|
||||
trustClientIpHeader: true,
|
||||
clientIpHeaderName: 'x-forwarded-for',
|
||||
maxInflightRequests: 100,
|
||||
torExitBlockingEnabled,
|
||||
});
|
||||
return routes.routes.map((route) => route.handler);
|
||||
}
|
||||
|
||||
describe('tor exit blocking in the middleware pipeline', () => {
|
||||
it('registers the tor exit middleware when the switch is on', () => {
|
||||
expect(registeredHandlers(true)).toContain(TorExitMiddleware);
|
||||
});
|
||||
|
||||
it('leaves the tor exit middleware unregistered when the switch is off', () => {
|
||||
expect(registeredHandlers(false)).not.toContain(TorExitMiddleware);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {createMfaTicket, createPasswordResetToken} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
||||
@@ -116,6 +117,9 @@ export async function verifyPassword(
|
||||
}
|
||||
|
||||
export async function isPasswordPwned(_ctx: ApiContext, password: string): Promise<boolean> {
|
||||
if (!Config.breachedPasswordCheck.enabled) {
|
||||
return false;
|
||||
}
|
||||
const hashed = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
|
||||
const hashPrefix = hashed.slice(0, 5);
|
||||
const hashSuffix = hashed.slice(5);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {isPasswordPwned, resetPwnedPasswordCacheForTesting} from '@app/api/auth/AuthPassword';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {server} from '@app/api/test/msw/server';
|
||||
import {delay, HttpResponse, http} from 'msw';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
@@ -68,6 +69,19 @@ describe('isPasswordPwned', () => {
|
||||
await expect(isPasswordPwned(ctx, SAFE_PASSWORD_SAME_PREFIX)).resolves.toBe(false);
|
||||
expect(requestedPrefixes).toHaveLength(1);
|
||||
});
|
||||
test('makes no upstream call when the check is switched off', async () => {
|
||||
const config = getConfig();
|
||||
const originalEnabled = config.breachedPasswordCheck.enabled;
|
||||
const requestedPrefixes: Array<string> = [];
|
||||
server.use(rangeHandler(requestedPrefixes, [suffixOf(PWNED_PASSWORD)]));
|
||||
try {
|
||||
config.breachedPasswordCheck.enabled = false;
|
||||
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(false);
|
||||
expect(requestedPrefixes).toHaveLength(0);
|
||||
} finally {
|
||||
config.breachedPasswordCheck.enabled = originalEnabled;
|
||||
}
|
||||
});
|
||||
test('fails open on a non-OK response', async () => {
|
||||
server.use(http.get('https://api.pwnedpasswords.com/range/:prefix', () => HttpResponse.text('', {status: 503})));
|
||||
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(false);
|
||||
|
||||
@@ -207,6 +207,12 @@ export interface APIConfig {
|
||||
blocklistFeeds: {
|
||||
enabled: boolean;
|
||||
};
|
||||
torExitList: {
|
||||
enabled: boolean;
|
||||
};
|
||||
breachedPasswordCheck: {
|
||||
enabled: boolean;
|
||||
};
|
||||
captcha: {
|
||||
enabled: boolean;
|
||||
provider: 'hcaptcha' | 'turnstile' | 'none';
|
||||
|
||||
@@ -32,11 +32,12 @@ export function ExperimentController(app: HonoApp) {
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
]);
|
||||
const userId = ctx.get('user').id.toString();
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, ctx.get('user').id.toString()),
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -49,7 +49,9 @@ describe('GET /experiments', () => {
|
||||
expect(body).toEqual({
|
||||
poll_interval_seconds: DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
assignments: {voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT},
|
||||
assignments: {
|
||||
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
|
||||
import {getIpInfoService} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
|
||||
import {getCacheService} from '@app/api/middleware/ServiceSingletons';
|
||||
import {isAutoBanExemptAsn} from '@app/api/risk/AutoBanAsnExemptions';
|
||||
import {isIpBanExempt} from '@app/api/risk/IpBanExemptions';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {parseJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
|
||||
@@ -18,7 +19,7 @@ import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
|
||||
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
|
||||
type IpClass = 'datacenter' | 'anonymous' | 'mobile' | 'residential' | 'unknown';
|
||||
type IpClass = 'datacenter' | 'anonymous' | 'mobile' | 'residential' | 'unknown' | 'exempt';
|
||||
type TriggerKind = 'score' | 'token_diversity' | 'score_and_token_diversity';
|
||||
|
||||
interface AbuseRecord {
|
||||
@@ -104,7 +105,7 @@ const IP_CLASS_CLAIM_TTL_SECONDS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_
|
||||
const DEFAULT_IP_CLASS_PENDING_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_PENDING_TTL_MS', 20_000);
|
||||
const DEFAULT_IP_CLASS_NEGATIVE_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_NEGATIVE_TTL_MS', 300_000);
|
||||
const DEFAULT_IP_CLASS_HINT_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_HINT_TTL_MS', 600_000);
|
||||
const IP_CLASSES = ['datacenter', 'anonymous', 'mobile', 'residential', 'unknown'] as const;
|
||||
const IP_CLASSES = ['datacenter', 'anonymous', 'mobile', 'residential', 'unknown', 'exempt'] as const;
|
||||
const POD_ID = process.env.HOSTNAME ?? randomUUID();
|
||||
|
||||
type ReplicatedTick = [banKey: string, scoreDelta: number, tokenHashes: Array<string>, lookupIp: string];
|
||||
@@ -185,6 +186,7 @@ function scoreThresholdFor(ipClass: IpClass): number {
|
||||
return THRESHOLD_MOBILE;
|
||||
case 'residential':
|
||||
case 'unknown':
|
||||
case 'exempt':
|
||||
return THRESHOLD_RESIDENTIAL;
|
||||
}
|
||||
}
|
||||
@@ -199,6 +201,7 @@ function tokenDiversityThresholdFor(ipClass: IpClass): number {
|
||||
return TOKEN_DIVERSITY_MOBILE;
|
||||
case 'residential':
|
||||
case 'unknown':
|
||||
case 'exempt':
|
||||
return TOKEN_DIVERSITY_RESIDENTIAL;
|
||||
}
|
||||
}
|
||||
@@ -390,6 +393,10 @@ async function claimIpClassLookup(key: string): Promise<boolean> {
|
||||
|
||||
async function runIpClassLookup(key: string, lookupIp: string): Promise<void> {
|
||||
try {
|
||||
if (await isAutoBanExemptAsn(lookupIp)) {
|
||||
setOwnIpClass(key, lookupIp, 'exempt', false);
|
||||
return;
|
||||
}
|
||||
if (!(await claimIpClassLookup(key))) return;
|
||||
const result = await getIpInfoService().lookup(lookupIp, {source: 'AbusiveIpAutoBanner', reason: 'classify'});
|
||||
setOwnIpClass(key, lookupIp, classifyIpInfo(result), !result.available);
|
||||
@@ -427,6 +434,14 @@ function maybeFireAutoBan(key: string, rec: AbuseRecord): void {
|
||||
if (resolved.blocked) {
|
||||
return;
|
||||
}
|
||||
if (ipClass === 'exempt') {
|
||||
rec.autoBanFired = true;
|
||||
Logger.warn(
|
||||
{ip: key, ipClass, score: rec.score, distinctTokens: rec.distinctTokenHashes.size},
|
||||
'[abuse-auto-ban] Skipping automatic IP ban because the ASN is exempt',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (shouldSkipAutoBanForIpClass(ipClass)) {
|
||||
rec.autoBanFired = true;
|
||||
Logger.warn(
|
||||
|
||||
@@ -13,13 +13,19 @@ import {
|
||||
} from '@app/api/middleware/AbusiveIpAutoBanner';
|
||||
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
|
||||
import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {resetAutoBanAsnExemptionsForTesting, setInjectedAutoBanAsnLookup} from '@app/api/risk/AutoBanAsnExemptions';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import type {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import type {GeoipAsnResult} from '@pkgs/geoip/src/GeoipLookup';
|
||||
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
function asnResult(asn: number | null): GeoipAsnResult {
|
||||
return {normalizedIp: null, asn, asnOrg: null, available: asn !== null};
|
||||
}
|
||||
|
||||
function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
|
||||
return {
|
||||
ip,
|
||||
@@ -96,6 +102,7 @@ describe('AbusiveIpAutoBanner', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let adminRepository: AdminRepository;
|
||||
let lookupCount = 0;
|
||||
let asnLookupCount = 0;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
adminRepository = new AdminRepository();
|
||||
@@ -104,6 +111,9 @@ describe('AbusiveIpAutoBanner', () => {
|
||||
await harness.reset();
|
||||
resetAbuseTrackingForTests();
|
||||
ipBanCache.resetCaches();
|
||||
delete process.env.FLUXER_ABUSE_EXEMPT_ASNS;
|
||||
resetAutoBanAsnExemptionsForTesting();
|
||||
asnLookupCount = 0;
|
||||
lookupCount = 0;
|
||||
setInjectedIpInfoService({
|
||||
async lookup(ip: string) {
|
||||
@@ -117,6 +127,8 @@ describe('AbusiveIpAutoBanner', () => {
|
||||
afterAll(async () => {
|
||||
await stopAbuseReplicationSubscriber();
|
||||
setInjectedIpInfoService(undefined);
|
||||
delete process.env.FLUXER_ABUSE_EXEMPT_ASNS;
|
||||
resetAutoBanAsnExemptionsForTesting();
|
||||
await harness.shutdown();
|
||||
});
|
||||
it('temporarily bans an IP that tries many distinct invalid tokens', async () => {
|
||||
@@ -175,6 +187,49 @@ describe('AbusiveIpAutoBanner', () => {
|
||||
expect(ipBanCache.isBanned(ip)).toBe(false);
|
||||
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(false);
|
||||
});
|
||||
it('does not auto-ban an IP whose ASN is exempt', async () => {
|
||||
const ip = '9.9.9.9';
|
||||
process.env.FLUXER_ABUSE_EXEMPT_ASNS = '64501, not-an-asn, 64502';
|
||||
resetAutoBanAsnExemptionsForTesting();
|
||||
setInjectedAutoBanAsnLookup(async () => asnResult(64502));
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`exempt-${i}`)});
|
||||
}
|
||||
await drainAbuseIpClassLookupsForTests();
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
expect(ipBanCache.isBanned(ip)).toBe(false);
|
||||
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(false);
|
||||
expect(lookupCount).toBe(0);
|
||||
});
|
||||
it('still auto-bans an IP whose ASN is not on the exempt list', async () => {
|
||||
const ip = '9.9.9.10';
|
||||
process.env.FLUXER_ABUSE_EXEMPT_ASNS = '64501';
|
||||
resetAutoBanAsnExemptionsForTesting();
|
||||
setInjectedAutoBanAsnLookup(async () => asnResult(64502));
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`not-exempt-${i}`)});
|
||||
}
|
||||
await waitForAssertion(() => {
|
||||
expect(ipBanCache.isBanned(ip)).toBe(true);
|
||||
});
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(true);
|
||||
});
|
||||
it('does not resolve an ASN when no exemptions are configured', async () => {
|
||||
const ip = '9.9.9.11';
|
||||
setInjectedAutoBanAsnLookup(async () => {
|
||||
asnLookupCount += 1;
|
||||
return asnResult(64502);
|
||||
});
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`no-exempt-list-${i}`)});
|
||||
}
|
||||
await waitForAssertion(() => {
|
||||
expect(ipBanCache.isBanned(ip)).toBe(true);
|
||||
});
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
expect(asnLookupCount).toBe(0);
|
||||
});
|
||||
it('does not auto-ban loopback or private IP addresses', async () => {
|
||||
for (const ip of ['127.0.0.1', '10.0.0.10', '::ffff:127.0.0.1']) {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {type GeoipAsnResult, lookupAsnByIp} from '@pkgs/geoip/src/GeoipLookup';
|
||||
|
||||
const ASN_ENTRY_REGEX = /^\d+$/u;
|
||||
|
||||
type AsnLookup = (ip: string) => Promise<GeoipAsnResult>;
|
||||
|
||||
let exemptAsns: ReadonlySet<number> | null = null;
|
||||
let injectedAsnLookup: AsnLookup | undefined;
|
||||
|
||||
function getExemptAsns(): ReadonlySet<number> {
|
||||
if (exemptAsns) {
|
||||
return exemptAsns;
|
||||
}
|
||||
const asns = new Set<number>();
|
||||
const rawValue = process.env.FLUXER_ABUSE_EXEMPT_ASNS;
|
||||
if (rawValue) {
|
||||
for (const entry of rawValue.split(',')) {
|
||||
const trimmed = entry.trim();
|
||||
if (!ASN_ENTRY_REGEX.test(trimmed)) continue;
|
||||
const asn = Number.parseInt(trimmed, 10);
|
||||
if (Number.isSafeInteger(asn) && asn > 0) asns.add(asn);
|
||||
}
|
||||
}
|
||||
exemptAsns = asns;
|
||||
return asns;
|
||||
}
|
||||
|
||||
function resolveAsn(ip: string): Promise<GeoipAsnResult> {
|
||||
if (injectedAsnLookup) {
|
||||
return injectedAsnLookup(ip);
|
||||
}
|
||||
return lookupAsnByIp(ip, Config.geoip.maxmindAsnDbPath);
|
||||
}
|
||||
|
||||
export async function isAutoBanExemptAsn(ip: string): Promise<boolean> {
|
||||
const asns = getExemptAsns();
|
||||
if (asns.size === 0) return false;
|
||||
const result = await resolveAsn(ip);
|
||||
return result.asn !== null && asns.has(result.asn);
|
||||
}
|
||||
|
||||
export function setInjectedAutoBanAsnLookup(lookup: AsnLookup | undefined): void {
|
||||
injectedAsnLookup = lookup;
|
||||
}
|
||||
|
||||
export function resetAutoBanAsnExemptionsForTesting(): void {
|
||||
exemptAsns = null;
|
||||
injectedAsnLookup = undefined;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {resetServiceSingletonsForTesting} from '@app/api/middleware/ServiceSingl
|
||||
import {torExitListCache} from '@app/api/middleware/TorExitListCache';
|
||||
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
|
||||
import {resetAdminSecretHashForTesting} from '@app/api/oauth/repositories/ApplicationRepository';
|
||||
import {resetAutoBanAsnExemptionsForTesting} from '@app/api/risk/AutoBanAsnExemptions';
|
||||
import {resetIpBanExemptionsForTesting} from '@app/api/risk/IpBanExemptions';
|
||||
import {setThemeCssMaxBytesForTesting} from '@app/api/theme/ThemeService';
|
||||
import {resetGeoipReadersForTesting} from '@pkgs/geoip/src/GeoipLookup';
|
||||
@@ -23,6 +24,7 @@ export async function resetServiceStateForTesting(): Promise<void> {
|
||||
resetServiceSingletonsForTesting();
|
||||
resetServiceMiddlewareForTesting();
|
||||
resetIpBanExemptionsForTesting();
|
||||
resetAutoBanAsnExemptionsForTesting();
|
||||
resetGlobalLimitConfigServiceForTesting();
|
||||
resetSudoModeServiceForTesting();
|
||||
resetSsoRequestUrlPolicyForTesting();
|
||||
|
||||
@@ -2,9 +2,19 @@
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import type {MediaDescription} from 'sdp-transform';
|
||||
import {parse} from 'sdp-transform';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {TrackBitrateInfo} from './PCTransport.ts';
|
||||
import {applyVideoStartBitrate, collectStereoMids, ensureAudioNackAndStereo, ensureOpusFmtp} from './PCTransport.ts';
|
||||
import {
|
||||
applyVideoStartBitrate,
|
||||
collectStereoMids,
|
||||
ensureAudioNackAndStereo,
|
||||
ensureOpusFmtp,
|
||||
ensureVideoDDExtension,
|
||||
placeholderMidsFromTransceivers,
|
||||
videoSectionCanReceiveAV1,
|
||||
} from './PCTransport.ts';
|
||||
import {ddExtensionURI} from './utils.ts';
|
||||
|
||||
function videoMedia(
|
||||
trackId: string,
|
||||
@@ -166,3 +176,89 @@ describe('collectStereoMids', () => {
|
||||
expect(collectStereoMids([audioBitrateInfo(null, 'mic-track', false)], media)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const singlePcOffer = `v=0
|
||||
o=- 0 0 IN IP4 127.0.0.1
|
||||
s=-
|
||||
t=0 0
|
||||
a=group:BUNDLE 0 1 2
|
||||
m=video 9 UDP/TLS/RTP/SAVPF 96 45
|
||||
c=IN IP4 0.0.0.0
|
||||
a=mid:0
|
||||
a=sendonly
|
||||
a=msid:s cam
|
||||
a=rtpmap:96 VP8/90000
|
||||
a=rtpmap:45 AV1/90000
|
||||
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
|
||||
a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id
|
||||
m=video 9 UDP/TLS/RTP/SAVPF 96 45
|
||||
c=IN IP4 0.0.0.0
|
||||
a=mid:1
|
||||
a=recvonly
|
||||
a=rtpmap:96 VP8/90000
|
||||
a=rtpmap:45 AV1/90000
|
||||
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
|
||||
m=video 9 UDP/TLS/RTP/SAVPF 96
|
||||
c=IN IP4 0.0.0.0
|
||||
a=mid:2
|
||||
a=recvonly
|
||||
a=rtpmap:96 VP8/90000
|
||||
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time`;
|
||||
|
||||
const sectionOf = (sdp: ReturnType<typeof parse>, mid: string) => sdp.media.find((media) => `${media.mid}` === mid)!;
|
||||
const ddOf = (sdp: ReturnType<typeof parse>, mid: string) =>
|
||||
sectionOf(sdp, mid).ext?.find((ext) => ext.uri === ddExtensionURI)?.value;
|
||||
|
||||
describe('videoSectionCanReceiveAV1', () => {
|
||||
it('is true for a section we receive on that kept AV1, false otherwise', () => {
|
||||
const sdp = parse(singlePcOffer);
|
||||
expect(videoSectionCanReceiveAV1(sectionOf(sdp, '1'))).toBe(true);
|
||||
expect(videoSectionCanReceiveAV1(sectionOf(sdp, '0'))).toBe(false);
|
||||
expect(videoSectionCanReceiveAV1(sectionOf(sdp, '2'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureVideoDDExtension', () => {
|
||||
it('assigns an id above every extension in the bundle and reuses it', () => {
|
||||
const sdp = parse(singlePcOffer);
|
||||
expect(ensureVideoDDExtension(sectionOf(sdp, '1'), sdp, 0)).toBe(12);
|
||||
expect(ddOf(sdp, '1')).toBe(12);
|
||||
expect(ensureVideoDDExtension(sectionOf(sdp, '2'), sdp, 12)).toBe(12);
|
||||
expect(ddOf(sdp, '2')).toBe(12);
|
||||
});
|
||||
|
||||
it('adopts an id the bundle already maps the extension to', () => {
|
||||
const sdp = parse(singlePcOffer);
|
||||
sectionOf(sdp, '0').ext!.push({value: 13, uri: ddExtensionURI});
|
||||
expect(ensureVideoDDExtension(sectionOf(sdp, '1'), sdp, 7)).toBe(13);
|
||||
expect(ddOf(sdp, '1')).toBe(13);
|
||||
});
|
||||
|
||||
it('leaves a section that already carries the extension alone', () => {
|
||||
const sdp = parse(`${singlePcOffer}\na=extmap:3 ${ddExtensionURI}`);
|
||||
expect(ensureVideoDDExtension(sectionOf(sdp, '2'), sdp, 0)).toBe(3);
|
||||
expect(sectionOf(sdp, '2').ext).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeholderMidsFromTransceivers', () => {
|
||||
function transceiver(
|
||||
mid: string | null,
|
||||
track: MediaStreamTrack | null,
|
||||
currentDirection: RTCRtpTransceiverDirection | null,
|
||||
): RTCRtpTransceiver {
|
||||
return {mid, currentDirection, sender: {track}} as unknown as RTCRtpTransceiver;
|
||||
}
|
||||
|
||||
it('keeps the trackless recvonly sections that still hold an m-line', () => {
|
||||
const mids = placeholderMidsFromTransceivers([
|
||||
transceiver('3', null, 'recvonly'),
|
||||
transceiver('7', {} as MediaStreamTrack, 'sendonly'),
|
||||
]);
|
||||
expect(mids).toEqual(new Set(['3']));
|
||||
});
|
||||
|
||||
it('drops a transceiver that unpublish stopped so its recycled m-section is not fmtp-conformed', () => {
|
||||
expect(placeholderMidsFromTransceivers([transceiver('7', null, 'stopped')])).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import log, {getLogger, LoggerNames} from '../logger.ts';
|
||||
import {debounce} from './debounce.ts';
|
||||
import {NegotiationError, UnexpectedConnectionState} from './errors.ts';
|
||||
import type {LoggerOptions} from './types.ts';
|
||||
import {ddExtensionURI, isFireFox, isSafari, isSVCCodec} from './utils.ts';
|
||||
import {ddExtensionURI, isChromiumBased, isFireFox, isSafari, isSVCCodec} from './utils.ts';
|
||||
|
||||
export interface TrackBitrateInfo {
|
||||
cid?: string;
|
||||
@@ -372,6 +372,9 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
||||
if (media.type === 'audio') {
|
||||
ensureAudioNackAndStereo(media, stereoMids, []);
|
||||
} else if (media.type === 'video') {
|
||||
if (isChromiumBased() && videoSectionCanReceiveAV1(media)) {
|
||||
this.ddExtID = ensureVideoDDExtension(media, sdpParsed, this.ddExtID);
|
||||
}
|
||||
this.trackBitrates.some((trackbr): boolean => {
|
||||
if (!trackbr.cid) {
|
||||
return false;
|
||||
@@ -677,6 +680,11 @@ export function ensureVideoDDExtension(
|
||||
return id;
|
||||
}
|
||||
|
||||
export function videoSectionCanReceiveAV1(media: MediaDescription): boolean {
|
||||
if (media.direction !== 'recvonly' && media.direction !== 'sendrecv') return false;
|
||||
return media.rtp.some((rtp) => rtp.codec.toLowerCase() === 'av1');
|
||||
}
|
||||
|
||||
function ddExtensionIDFor(sdp: SessionDescription, cachedID: number): number | undefined {
|
||||
const mapped = mappedExtensionID(sdp, ddExtensionURI);
|
||||
if (mapped !== undefined) {
|
||||
@@ -841,6 +849,9 @@ export function collectStereoMids(
|
||||
export function placeholderMidsFromTransceivers(transceivers: ReadonlyArray<RTCRtpTransceiver>): Set<string> {
|
||||
const mids = new Set<string>();
|
||||
for (const transceiver of transceivers) {
|
||||
if (transceiver.currentDirection === 'stopped') {
|
||||
continue;
|
||||
}
|
||||
if (transceiver.mid && !transceiver.sender.track) {
|
||||
mids.add(transceiver.mid);
|
||||
}
|
||||
|
||||
@@ -106,11 +106,6 @@ export class PCTransportManager {
|
||||
this.isPublisherConnectionRequired = mode !== 'subscriber-primary';
|
||||
this.isSubscriberConnectionRequired = mode === 'subscriber-primary';
|
||||
this.publisher = new PCTransport(rtcConfig, loggerOptions);
|
||||
if (subscriberVideoCodecExclusions?.length) {
|
||||
for (const codec of subscriberVideoCodecExclusions) {
|
||||
this.publisher.excludedVideoDecoderMimeTypes.add(`video/${codec}`);
|
||||
}
|
||||
}
|
||||
this._mode = mode;
|
||||
if (mode !== 'publisher-only') {
|
||||
this.subscriber = new PCTransport(rtcConfig, loggerOptions);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {selectPublisherCodecPreferences} from './RTCEngine.ts';
|
||||
import type {InternalRoomOptions} from '../options.ts';
|
||||
import RTCEngine, {selectPublisherCodecPreferences} from './RTCEngine.ts';
|
||||
|
||||
function codec(
|
||||
mimeType: string,
|
||||
@@ -82,10 +83,72 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
expect(preferences).toEqual([constrainedBaselineMode1, constrainedBaselineMode0, highProfile]);
|
||||
});
|
||||
|
||||
it('keeps non-H.264 codecs in browser capability order and appends RTX', () => {
|
||||
it('puts the chosen codec first and keeps every other codec in browser capability order', () => {
|
||||
const vp9 = codec('video/VP9');
|
||||
const vp8 = codec('video/VP8');
|
||||
const rtx = codec('video/rtx');
|
||||
expect(selectPublisherCodecPreferences('vp9', [vp8, rtx, vp9])).toEqual([vp9, rtx]);
|
||||
expect(selectPublisherCodecPreferences('vp9', [vp8, rtx, vp9])).toEqual([vp9, vp8, rtx]);
|
||||
});
|
||||
|
||||
it('keeps the other codecs so a later publication on the same connection can negotiate them', () => {
|
||||
const vp9 = codec('video/VP9');
|
||||
const av1 = codec('video/AV1');
|
||||
const h264 = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f');
|
||||
const preferences = selectPublisherCodecPreferences('vp9', [av1, h264, vp9]);
|
||||
expect(preferences.map((entry) => entry.mimeType)).toEqual(['video/VP9', 'video/AV1', 'video/H264']);
|
||||
});
|
||||
|
||||
it('ranks the H.264 profiles it leaves behind the chosen codec', () => {
|
||||
const vp8 = codec('video/VP8');
|
||||
const highProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033');
|
||||
const constrainedBaseline = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const preferences = selectPublisherCodecPreferences('vp8', [vp8, highProfile, constrainedBaseline]);
|
||||
expect(preferences).toEqual([vp8, constrainedBaseline, highProfile]);
|
||||
});
|
||||
|
||||
it('returns nothing when the sender cannot encode the chosen codec', () => {
|
||||
expect(selectPublisherCodecPreferences('av1', [codec('video/VP8'), codec('video/rtx')])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publisher data channels before negotiation', () => {
|
||||
function engineWithPublisherChannels(hasPublisherChannels: boolean) {
|
||||
const engine = new RTCEngine({} as InternalRoomOptions);
|
||||
const created: Array<string> = [];
|
||||
const internals = engine as unknown as {
|
||||
_isClosed: boolean;
|
||||
pcManager: unknown;
|
||||
dataChannels: {hasPublisherChannels: boolean; createPublisherChannels: () => void};
|
||||
};
|
||||
internals._isClosed = false;
|
||||
internals.dataChannels = {
|
||||
hasPublisherChannels,
|
||||
createPublisherChannels: () => created.push('publisher'),
|
||||
};
|
||||
internals.pcManager = {
|
||||
requirePublisher: () => {},
|
||||
negotiate: async () => {},
|
||||
publisher: {
|
||||
off: () => {},
|
||||
once: () => {},
|
||||
getTransceivers: () => [{} as RTCRtpTransceiver],
|
||||
},
|
||||
};
|
||||
return {engine, created};
|
||||
}
|
||||
|
||||
it('creates them on a renegotiation that already carries transceivers', async () => {
|
||||
const {engine, created} = engineWithPublisherChannels(false);
|
||||
await engine.negotiate();
|
||||
expect(created).toEqual(['publisher']);
|
||||
});
|
||||
|
||||
it('leaves the existing channels alone', async () => {
|
||||
const {engine, created} = engineWithPublisherChannels(true);
|
||||
await engine.negotiate();
|
||||
expect(created).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1034,7 +1034,13 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
const capabilities = RTCRtpSender.getCapabilities('video');
|
||||
if (!capabilities) return;
|
||||
const preferences = selectPublisherCodecPreferences(codec, capabilities.codecs);
|
||||
if (preferences.length === 0) return;
|
||||
if (preferences.length === 0) {
|
||||
this.log.warn('sender cannot encode the requested codec, leaving the browser order in place', {
|
||||
...this.logContext,
|
||||
codec,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
transceiver.setCodecPreferences(preferences);
|
||||
} catch (error) {
|
||||
@@ -1553,7 +1559,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
}
|
||||
|
||||
this.pcManager.requirePublisher();
|
||||
if (this.pcManager.publisher.getTransceivers().length === 0 && !this.dataChannels.hasPublisherChannels) {
|
||||
if (!this.dataChannels.hasPublisherChannels) {
|
||||
this.createDataChannels();
|
||||
}
|
||||
|
||||
@@ -1828,8 +1834,12 @@ export function selectPublisherCodecPreferences(
|
||||
const selected = codecs.filter((entry) => mimeTypes.has(entry.mimeType.toLowerCase()));
|
||||
if (selected.length === 0) return [];
|
||||
const preferred = codec === 'h264' ? preferHardwareH264Codecs(selected) : selected;
|
||||
const rtx = codecs.filter((entry) => entry.mimeType.toLowerCase() === 'video/rtx');
|
||||
return [...preferred, ...rtx];
|
||||
const isH264 = (entry: RtpCodecCapability): boolean => entry.mimeType.toLowerCase() === 'video/h264';
|
||||
const remaining = codecs.filter((entry) => !mimeTypes.has(entry.mimeType.toLowerCase()));
|
||||
const rankedH264 = preferHardwareH264Codecs(remaining.filter(isH264));
|
||||
let nextH264 = 0;
|
||||
const rest = remaining.map((entry) => (isH264(entry) ? rankedH264[nextH264++] : entry));
|
||||
return [...preferred, ...rest];
|
||||
}
|
||||
|
||||
export type EngineEventCallbacks = {
|
||||
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
isWeb,
|
||||
selectPreferredVideoCodec,
|
||||
sleep,
|
||||
stopTransceiversForSender,
|
||||
supportsVideoCodec,
|
||||
usesLegacySVCEncodings,
|
||||
} from '../utils.ts';
|
||||
@@ -436,8 +437,9 @@ export default class LocalParticipant extends Participant {
|
||||
enabled: boolean,
|
||||
options?: ScreenShareCaptureOptions,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
audioPublishOptions?: TrackPublishOptions,
|
||||
): Promise<LocalTrackPublication | undefined> {
|
||||
return this.setTrackEnabled(Track.Source.ScreenShare, enabled, options, publishOptions);
|
||||
return this.setTrackEnabled(Track.Source.ScreenShare, enabled, options, publishOptions, audioPublishOptions);
|
||||
}
|
||||
|
||||
async setE2EEEnabled(enabled: boolean) {
|
||||
@@ -474,12 +476,14 @@ export default class LocalParticipant extends Participant {
|
||||
enabled: boolean,
|
||||
options?: ScreenShareCaptureOptions,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
audioPublishOptions?: TrackPublishOptions,
|
||||
): Promise<LocalTrackPublication | undefined>;
|
||||
private async setTrackEnabled(
|
||||
source: Track.Source,
|
||||
enabled: true,
|
||||
options?: VideoCaptureOptions | AudioCaptureOptions | ScreenShareCaptureOptions,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
audioPublishOptions?: TrackPublishOptions,
|
||||
) {
|
||||
this.log.debug('setTrackEnabled', {source, enabled});
|
||||
if (this.republishPromise) {
|
||||
@@ -548,7 +552,12 @@ export default class LocalParticipant extends Participant {
|
||||
for (const localTrack of localTracks) {
|
||||
this.log.info('publishing track', getLogContextFromTrack(localTrack));
|
||||
|
||||
publishPromises.push(this.publishTrack(localTrack, publishOptions));
|
||||
publishPromises.push(
|
||||
this.publishTrack(
|
||||
localTrack,
|
||||
audioPublishOptions && isAudioTrack(localTrack) ? audioPublishOptions : publishOptions,
|
||||
),
|
||||
);
|
||||
}
|
||||
const publishedTracks = await Promise.all(publishPromises);
|
||||
|
||||
@@ -1421,29 +1430,30 @@ export default class LocalParticipant extends Participant {
|
||||
const trackSender = track.sender;
|
||||
track.sender = undefined;
|
||||
if (this.engine.pcManager && this.engine.pcManager.currentState < PCTransportState.FAILED && trackSender) {
|
||||
const publisher = this.engine.pcManager.publisher;
|
||||
try {
|
||||
for (const transceiver of this.engine.pcManager.publisher.getTransceivers()) {
|
||||
if (transceiver.sender === trackSender) {
|
||||
transceiver.direction = 'inactive';
|
||||
negotiationNeeded = true;
|
||||
}
|
||||
}
|
||||
try {
|
||||
negotiationNeeded = this.engine.removeTrack(trackSender);
|
||||
} catch (e) {
|
||||
this.log.warn(e);
|
||||
negotiationNeeded = true;
|
||||
}
|
||||
if (stopTransceiversForSender(publisher.getTransceivers(), trackSender)) {
|
||||
negotiationNeeded = true;
|
||||
}
|
||||
|
||||
if (isLocalVideoTrack(track)) {
|
||||
for (const [, trackInfo] of track.simulcastCodecs) {
|
||||
if (trackInfo.sender) {
|
||||
try {
|
||||
negotiationNeeded = this.engine.removeTrack(trackInfo.sender);
|
||||
negotiationNeeded = this.engine.removeTrack(trackInfo.sender) || negotiationNeeded;
|
||||
} catch (e) {
|
||||
this.log.warn(e);
|
||||
negotiationNeeded = true;
|
||||
}
|
||||
if (stopTransceiversForSender(publisher.getTransceivers(), trackInfo.sender)) {
|
||||
negotiationNeeded = true;
|
||||
}
|
||||
trackInfo.sender = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {screenCaptureToDisplayMediaStreamOptions} from './track/utils.ts';
|
||||
import {selectPreferredVideoCodec, supportsAV1, supportsH265, supportsVideoCodec, supportsVP9} from './utils.ts';
|
||||
import {
|
||||
selectPreferredVideoCodec,
|
||||
stopTransceiversForSender,
|
||||
supportsAV1,
|
||||
supportsH265,
|
||||
supportsVideoCodec,
|
||||
supportsVP9,
|
||||
} from './utils.ts';
|
||||
|
||||
const originalNavigator = globalThis.navigator;
|
||||
const originalSender = globalThis.RTCRtpSender;
|
||||
@@ -91,3 +98,46 @@ describe('screenCaptureToDisplayMediaStreamOptions', () => {
|
||||
expect(options.systemAudio).toBe('exclude');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopTransceiversForSender', () => {
|
||||
type FakeTransceiver = {sender: RTCRtpSender; direction: string; stopCalls: number; stop?: () => void};
|
||||
|
||||
function fakeTransceiver(sender: RTCRtpSender, canStop = true): FakeTransceiver {
|
||||
const transceiver: FakeTransceiver = {sender, direction: 'sendonly', stopCalls: 0};
|
||||
if (canStop) {
|
||||
transceiver.stop = () => {
|
||||
transceiver.stopCalls += 1;
|
||||
transceiver.direction = 'stopped';
|
||||
};
|
||||
}
|
||||
return transceiver;
|
||||
}
|
||||
|
||||
function asTransceivers(list: Array<FakeTransceiver>): ReadonlyArray<RTCRtpTransceiver> {
|
||||
return list as unknown as ReadonlyArray<RTCRtpTransceiver>;
|
||||
}
|
||||
|
||||
it('stops the transceiver holding the sender so the m-section can be recycled', () => {
|
||||
const sender = {} as RTCRtpSender;
|
||||
const mine = fakeTransceiver(sender);
|
||||
const theirs = fakeTransceiver({} as RTCRtpSender);
|
||||
expect(stopTransceiversForSender(asTransceivers([theirs, mine]), sender)).toBe(true);
|
||||
expect(mine.stopCalls).toBe(1);
|
||||
expect(mine.direction).toBe('stopped');
|
||||
expect(theirs.stopCalls).toBe(0);
|
||||
expect(theirs.direction).toBe('sendonly');
|
||||
});
|
||||
|
||||
it('reports no match when the sender is not on the connection', () => {
|
||||
const mine = fakeTransceiver({} as RTCRtpSender);
|
||||
expect(stopTransceiversForSender(asTransceivers([mine]), {} as RTCRtpSender)).toBe(false);
|
||||
expect(mine.stopCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to inactive where stop is unavailable', () => {
|
||||
const sender = {} as RTCRtpSender;
|
||||
const mine = fakeTransceiver(sender, false);
|
||||
expect(stopTransceiversForSender(asTransceivers([mine]), sender)).toBe(true);
|
||||
expect(mine.direction).toBe('inactive');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +158,25 @@ export function negotiateDependencyDescriptor(transceiver: RTCRtpTransceiver): b
|
||||
}
|
||||
}
|
||||
|
||||
export function stopTransceiversForSender(
|
||||
transceivers: ReadonlyArray<RTCRtpTransceiver>,
|
||||
sender: RTCRtpSender,
|
||||
): boolean {
|
||||
let matched = false;
|
||||
for (const transceiver of transceivers) {
|
||||
if (transceiver.sender !== sender) {
|
||||
continue;
|
||||
}
|
||||
matched = true;
|
||||
if (typeof transceiver.stop === 'function') {
|
||||
transceiver.stop();
|
||||
} else {
|
||||
transceiver.direction = 'inactive';
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
export function isSVCSimulcast(codec?: string, options?: {simulcast?: boolean; scalabilityMode?: string}): boolean {
|
||||
return isSVCCodec(codec) && !!options?.simulcast && !!options.scalabilityMode?.startsWith('L1T');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -139,6 +139,9 @@
|
||||
{
|
||||
"msgid": "[email protected]"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -1164,6 +1167,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -1401,6 +1407,9 @@
|
||||
{
|
||||
"msgid": "A lower price is available. Switching takes effect on <0>{listPriceEffectiveDate}</0> and changes nothing else."
|
||||
},
|
||||
{
|
||||
"msgid": "A viewer cannot play {pinnedCodec}, so your stream uses {codec}."
|
||||
},
|
||||
{
|
||||
"msgid": "AV1"
|
||||
},
|
||||
@@ -1569,6 +1578,9 @@
|
||||
{
|
||||
"msgid": "Capture card"
|
||||
},
|
||||
{
|
||||
"msgid": "Capture devices use the Gaming preset."
|
||||
},
|
||||
{
|
||||
"msgid": "Capture entire system audio"
|
||||
},
|
||||
@@ -1821,6 +1833,12 @@
|
||||
{
|
||||
"msgid": "Desktop audio isn't available right now on macOS."
|
||||
},
|
||||
{
|
||||
"msgid": "Device audio ({deviceLabel})"
|
||||
},
|
||||
{
|
||||
"msgid": "Device audio only"
|
||||
},
|
||||
{
|
||||
"msgid": "Device volume"
|
||||
},
|
||||
@@ -2136,9 +2154,15 @@
|
||||
{
|
||||
"msgid": "No application windows found"
|
||||
},
|
||||
{
|
||||
"msgid": "No audio from this device"
|
||||
},
|
||||
{
|
||||
"msgid": "No displays found"
|
||||
},
|
||||
{
|
||||
"msgid": "No hardware encoder was found on this device, so Prefer hardware works like Automatic."
|
||||
},
|
||||
{
|
||||
"msgid": "No optional services are available on this instance. You can skip this step."
|
||||
},
|
||||
@@ -2526,6 +2550,9 @@
|
||||
{
|
||||
"msgid": "Send messages in this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Sending {resolution} at {frameRate} FPS with {codec}"
|
||||
},
|
||||
{
|
||||
"msgid": "Sends both channels of a stereo microphone. Works only with no suppression or standard suppression."
|
||||
},
|
||||
@@ -2559,6 +2586,9 @@
|
||||
{
|
||||
"msgid": "Set a member list position for the role."
|
||||
},
|
||||
{
|
||||
"msgid": "Set by the {preset} preset. Picking a value here switches to Custom."
|
||||
},
|
||||
{
|
||||
"msgid": "Set mature content to follow the community setting."
|
||||
},
|
||||
@@ -2631,6 +2661,9 @@
|
||||
{
|
||||
"msgid": "Set this check aside?"
|
||||
},
|
||||
{
|
||||
"msgid": "Set to send {resolution} at {frameRate} FPS with {codec}"
|
||||
},
|
||||
{
|
||||
"msgid": "Set up {productName}"
|
||||
},
|
||||
@@ -2652,6 +2685,12 @@
|
||||
{
|
||||
"msgid": "Shared window audio"
|
||||
},
|
||||
{
|
||||
"msgid": "Sharp text at 720p, up to 30 FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "Sharp text at up to 4K, up to 15 FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "Sharper text at 720p, 30 FPS"
|
||||
},
|
||||
@@ -2688,6 +2727,9 @@
|
||||
{
|
||||
"msgid": "Sign-in details"
|
||||
},
|
||||
{
|
||||
"msgid": "Silent when the capture device has no audio input of its own"
|
||||
},
|
||||
{
|
||||
"msgid": "Single community"
|
||||
},
|
||||
@@ -2709,9 +2751,18 @@
|
||||
{
|
||||
"msgid": "Slowmode is set to {durationLabel}. Wait before sending another message."
|
||||
},
|
||||
{
|
||||
"msgid": "Smooth 30 FPS at up to 720p"
|
||||
},
|
||||
{
|
||||
"msgid": "Smooth 60 FPS at up to 1440p"
|
||||
},
|
||||
{
|
||||
"msgid": "Smoother video"
|
||||
},
|
||||
{
|
||||
"msgid": "Some stream settings could not be applied to your live stream."
|
||||
},
|
||||
{
|
||||
"msgid": "Something is limiting this stream, so viewers are getting fewer frames than you asked for."
|
||||
},
|
||||
@@ -2775,6 +2826,9 @@
|
||||
{
|
||||
"msgid": "Stream quality"
|
||||
},
|
||||
{
|
||||
"msgid": "Stream settings were adjusted"
|
||||
},
|
||||
{
|
||||
"msgid": "Strongest neural filter for speech, limits audio to the speech range."
|
||||
},
|
||||
@@ -2814,6 +2868,9 @@
|
||||
{
|
||||
"msgid": "The ban was set to expire on {date}."
|
||||
},
|
||||
{
|
||||
"msgid": "The browser is sending {deliveredResolution} instead of {resolution}."
|
||||
},
|
||||
{
|
||||
"msgid": "The camera preview is still stopping. Try streaming again in a moment."
|
||||
},
|
||||
@@ -2886,6 +2943,9 @@
|
||||
{
|
||||
"msgid": "These options change how people use the instance. Some can only be chosen during setup."
|
||||
},
|
||||
{
|
||||
"msgid": "This device could not keep up with {targetResolution} at {targetFrameRate} FPS last time, so your stream started at {resolution} at {frameRate} FPS."
|
||||
},
|
||||
{
|
||||
"msgid": "This emoji is from"
|
||||
},
|
||||
@@ -2940,6 +3000,9 @@
|
||||
{
|
||||
"msgid": "Transferred community ownership to {user}."
|
||||
},
|
||||
{
|
||||
"msgid": "Try full quality again"
|
||||
},
|
||||
{
|
||||
"msgid": "Turn off audio sharing for this source or try again in a moment."
|
||||
},
|
||||
@@ -3003,6 +3066,9 @@
|
||||
{
|
||||
"msgid": "Use your operating system's spellchecker when available. Otherwise, use {productName}'s in-app dictionaries."
|
||||
},
|
||||
{
|
||||
"msgid": "Used in Custom mode. The Gaming and Screen share presets set their own hint."
|
||||
},
|
||||
{
|
||||
"msgid": "Virtual microphone"
|
||||
},
|
||||
@@ -3039,6 +3105,9 @@
|
||||
{
|
||||
"msgid": "Wordmark"
|
||||
},
|
||||
{
|
||||
"msgid": "You asked for {resolution} at {frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "You can still email {supportEmail} for a human review at any time."
|
||||
},
|
||||
@@ -3054,12 +3123,39 @@
|
||||
{
|
||||
"msgid": "Your account works normally again straight away and nothing is removed. We can ask for this check again later."
|
||||
},
|
||||
{
|
||||
"msgid": "Your capture device is sending {deliveredResolution} instead of {resolution}."
|
||||
},
|
||||
{
|
||||
"msgid": "Your carrier says this number isn't in service. Check it and try again, or contact support if it's correct."
|
||||
},
|
||||
{
|
||||
"msgid": "Your connection cannot keep up. Viewers get about {deliveredFrameRate} of {frameRate} FPS at {resolution}."
|
||||
},
|
||||
{
|
||||
"msgid": "Your connection cannot keep up. Your stream is set to {frameRate} FPS at {resolution} and viewers are getting about {deliveredFrameRate} FPS."
|
||||
},
|
||||
{
|
||||
"msgid": "Your device cannot keep up. Viewers get about {deliveredFrameRate} of {frameRate} FPS at {resolution}."
|
||||
},
|
||||
{
|
||||
"msgid": "Your device cannot keep up. Your stream is set to {frameRate} FPS at {resolution} and viewers are getting about {deliveredFrameRate} FPS."
|
||||
},
|
||||
{
|
||||
"msgid": "Your device could not keep up, so your stream now sends {frameRate} FPS to keep {resolution} sharp."
|
||||
},
|
||||
{
|
||||
"msgid": "Your device could not keep up, so your stream now sends {resolution} to keep {frameRate} FPS smooth."
|
||||
},
|
||||
{
|
||||
"msgid": "Your new nickname, or leave blank to reset it."
|
||||
},
|
||||
{
|
||||
"msgid": "Your saved {savedResolution} at {savedFrameRate} FPS needs {premiumProductName}, so shares use {resolution} at {frameRate} FPS."
|
||||
},
|
||||
{
|
||||
"msgid": "Your source is producing about {sourceFrameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "Your subscription can't move to the new price right now."
|
||||
},
|
||||
@@ -3069,6 +3165,9 @@
|
||||
{
|
||||
"msgid": "Your subscription still ends on <0>{cancelDate}</0>. The current price is now {listPriceNewLabel}. If you reactivate, you can switch to it from here."
|
||||
},
|
||||
{
|
||||
"msgid": "Your video encoder stopped, so your stream switched to {codec}."
|
||||
},
|
||||
{
|
||||
"msgid": "deleted-category"
|
||||
},
|
||||
@@ -3564,6 +3663,9 @@
|
||||
{
|
||||
"msgid": "{channelHeading}, {mentionCount, plural, one {# mention} other {# mentions}}"
|
||||
},
|
||||
{
|
||||
"msgid": "{codec} cannot keep up on this device. Set the codec to Automatic in Advanced settings to allow a faster one."
|
||||
},
|
||||
{
|
||||
"msgid": "{count, plural, =0 {∞ No limit} one {# participant} other {# participants}}"
|
||||
},
|
||||
@@ -3594,12 +3696,18 @@
|
||||
{
|
||||
"msgid": "{expressionName} is a custom sticker from this community. Members can use it here."
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{imageCount, plural, one {# image} other {# images}}, {videoCount, plural, one {# video} other {# videos}}"
|
||||
},
|
||||
{
|
||||
"msgid": "{name} copy"
|
||||
},
|
||||
{
|
||||
"msgid": "{pinnedCodec} is not available on this device, so your stream uses {codec}."
|
||||
},
|
||||
{
|
||||
"msgid": "{premiumProductName} costs less now. Switch from {currentPrice} to {newPrice} a month starting {effectiveDate}, and nothing is charged today."
|
||||
},
|
||||
@@ -3936,6 +4044,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -4144,6 +4255,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -4314,6 +4428,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -4915,6 +5032,9 @@
|
||||
{
|
||||
"msgid": "[email protected]"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{messageGroupSpacing}px"
|
||||
},
|
||||
@@ -5165,6 +5285,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -5652,6 +5775,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -6000,6 +6126,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -6158,6 +6287,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -6250,6 +6382,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{messageGroupSpacing}px"
|
||||
},
|
||||
@@ -6324,6 +6459,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -7604,6 +7742,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -7967,6 +8108,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -8659,6 +8803,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -8953,6 +9100,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -9162,6 +9312,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -21,17 +21,28 @@ import {getAdvancedSettingsCategorySectionId} from '@app/features/user/component
|
||||
import {CompactComboboxRow} from '@app/features/user/components/modals/tabs/components/CompactComboboxRow';
|
||||
import {useMediaPermission} from '@app/features/user/components/modals/tabs/hooks/useMediaPermission';
|
||||
import styles from '@app/features/user/components/modals/tabs/UserVideoTab.module.css';
|
||||
import {
|
||||
resolveUserVideoTabScreenShareState,
|
||||
SCREEN_SHARE_PRESET_DESCRIPTORS,
|
||||
} from '@app/features/user/components/modals/tabs/UserVideoTabState';
|
||||
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
|
||||
import {CameraPreviewModalStandalone} from '@app/features/voice/components/modals/CameraPreviewModal';
|
||||
import type VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import type {CameraResolution, ScreenshareResolution} from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
resolveScreenShareFrameRate,
|
||||
CAPTURE_DEVICES_USE_THE_GAMING_PRESET_DESCRIPTOR,
|
||||
type OfferedScreenShareResolution,
|
||||
} from '@app/features/voice/components/StreamSettingsMenuContentStateMachine';
|
||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import type VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import type {CameraResolution} from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
resolveScreenShareQualityPick,
|
||||
type ScreenShareQualityInput,
|
||||
type SupportedScreenShareFrameRate,
|
||||
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {buildSettingsDeviceOptions} from '@app/features/voice/utils/SettingsDeviceOptions';
|
||||
import {hasHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
||||
import {resolveEffectiveDeviceId} from '@app/features/voice/utils/VoiceDeviceManager';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {CrownIcon, GearIcon} from '@phosphor-icons/react';
|
||||
@@ -39,9 +50,10 @@ import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useMemo} from 'react';
|
||||
|
||||
const FPS_15_DESCRIPTOR = msg({message: '15 FPS'});
|
||||
const FPS_30_DESCRIPTOR = msg({message: '30 FPS'});
|
||||
const FPS_60_DESCRIPTOR = msg({message: '60 FPS'});
|
||||
const FPS_DESCRIPTOR = msg({
|
||||
message: '{frameRate} FPS',
|
||||
comment: 'Frame rate option label in the video tab. FPS is a technical token and frameRate is a whole number.',
|
||||
});
|
||||
|
||||
const LOW_480P_LABEL = '480p';
|
||||
const MEDIUM_720P_LABEL = '720p';
|
||||
@@ -62,13 +74,27 @@ const SELF_HOSTED_VIDEO_QUALITY_LIMIT_DESCRIPTOR = msg({
|
||||
message: 'This instance currently allows screen sharing up to 720p at 30 FPS.',
|
||||
comment: 'Neutral video settings note shown when higher screen share quality is disabled by instance limits.',
|
||||
});
|
||||
const STANDARD_720P_LABEL = '720p';
|
||||
const HIGH_DEFINITION_1080P_LABEL = '1080p';
|
||||
const QUAD_HD_1440P_LABEL = '1440p';
|
||||
const SCREEN_SHARE_RESOLUTION_LABELS: Record<Exclude<OfferedScreenShareResolution, 'source'>, string> = {
|
||||
low_480p: '480p',
|
||||
medium: '720p',
|
||||
high: '1080p',
|
||||
ultra: '1440p',
|
||||
};
|
||||
const SOURCE_DESCRIPTOR = msg({
|
||||
message: 'Source',
|
||||
comment: 'Short label in the video tab for the native source resolution option. Keep it concise.',
|
||||
});
|
||||
const SCREEN_SHARE_PRESET_OWNED_DESCRIPTOR = msg({
|
||||
message: 'Set by the {preset} preset. Picking a value here switches to Custom.',
|
||||
comment:
|
||||
'Video settings note shown when a streaming preset decides the screen share resolution and frame rate. preset is the Gaming or Screen share preset label, and Custom is the third preset label.',
|
||||
});
|
||||
const SCREEN_SHARE_TIER_LIMITED_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Your saved {savedResolution} at {savedFrameRate} FPS needs {premiumProductName}, so shares use {resolution} at {frameRate} FPS.',
|
||||
comment:
|
||||
'Video settings note shown when the saved screen share quality is above what this account can send. Resolutions are short labels such as 1440p or Source and rates are whole numbers. FPS is a technical token.',
|
||||
});
|
||||
const CAMERA_2_DESCRIPTOR = msg({
|
||||
message: 'Camera',
|
||||
comment: 'Short label in the video tab. Keep it concise.',
|
||||
@@ -94,6 +120,10 @@ const OPEN_ADVANCED_MEDIA_SETTINGS_DESCRIPTOR = msg({
|
||||
comment: 'Button label in the video tab that jumps to the advanced settings media section.',
|
||||
});
|
||||
|
||||
function getScreenShareResolutionLabel(i18n: I18n, resolution: OfferedScreenShareResolution): string {
|
||||
return resolution === 'source' ? i18n._(SOURCE_DESCRIPTOR) : SCREEN_SHARE_RESOLUTION_LABELS[resolution];
|
||||
}
|
||||
|
||||
interface VideoTabProps {
|
||||
voiceSettings: typeof VoiceSettings;
|
||||
hasPremium: boolean;
|
||||
@@ -103,7 +133,15 @@ interface VideoTabProps {
|
||||
export const VideoTab: React.FC<VideoTabProps> = observer(
|
||||
({voiceSettings, hasPremium: _hasPremium, autoRequestPermission = false}) => {
|
||||
const {i18n} = useLingui();
|
||||
const {videoDeviceId, cameraResolution, mirrorCamera, screenshareResolution, videoFrameRate} = voiceSettings;
|
||||
const {
|
||||
videoDeviceId,
|
||||
cameraResolution,
|
||||
mirrorCamera,
|
||||
screenshareResolution,
|
||||
videoFrameRate,
|
||||
streamingMode,
|
||||
screenShareContentHint,
|
||||
} = voiceSettings;
|
||||
const hasHigherQuality = hasHigherVideoQuality();
|
||||
const isSelfHosted = RuntimeConfig.isSelfHosted();
|
||||
const {
|
||||
@@ -132,45 +170,30 @@ export const VideoTab: React.FC<VideoTabProps> = observer(
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const screenshareResolutionOptions: ReadonlyArray<ComboboxOption<ScreenshareResolution>> = [
|
||||
{value: 'low_480p', label: LOW_480P_LABEL},
|
||||
{
|
||||
value: 'medium',
|
||||
label: STANDARD_720P_LABEL,
|
||||
},
|
||||
...(hasHigherQuality || !isSelfHosted
|
||||
? [
|
||||
{
|
||||
value: 'high' as const,
|
||||
label: HIGH_DEFINITION_1080P_LABEL,
|
||||
isDisabled: !hasHigherQuality,
|
||||
},
|
||||
{
|
||||
value: 'ultra' as const,
|
||||
label: QUAD_HD_1440P_LABEL,
|
||||
isDisabled: !hasHigherQuality,
|
||||
},
|
||||
{
|
||||
value: 'source' as const,
|
||||
label: i18n._(SOURCE_DESCRIPTOR),
|
||||
isDisabled: !hasHigherQuality,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const frameRateOptions: ReadonlyArray<ComboboxOption<SupportedScreenShareFrameRate>> = hasHigherQuality
|
||||
? [
|
||||
{value: 15, label: i18n._(FPS_15_DESCRIPTOR)},
|
||||
{value: 30, label: i18n._(FPS_30_DESCRIPTOR)},
|
||||
{value: 60, label: i18n._(FPS_60_DESCRIPTOR)},
|
||||
]
|
||||
: [
|
||||
{value: 15, label: i18n._(FPS_15_DESCRIPTOR)},
|
||||
{value: 30, label: i18n._(FPS_30_DESCRIPTOR)},
|
||||
];
|
||||
const resolvedVideoFrameRate = resolveScreenShareFrameRate(videoFrameRate);
|
||||
const effectiveVideoFrameRate: SupportedScreenShareFrameRate =
|
||||
!hasHigherQuality && resolvedVideoFrameRate === 60 ? 30 : resolvedVideoFrameRate;
|
||||
const liveShareContext = ActiveScreenShareSource.getShareContext();
|
||||
const screenShareQuality: ScreenShareQualityInput = {
|
||||
mode: streamingMode,
|
||||
storedResolution: screenshareResolution,
|
||||
storedFrameRate: videoFrameRate,
|
||||
entitled: hasHigherQuality,
|
||||
context: liveShareContext ?? 'display',
|
||||
};
|
||||
const screenShareState = resolveUserVideoTabScreenShareState({
|
||||
quality: screenShareQuality,
|
||||
hintSetting: screenShareContentHint,
|
||||
selfHosted: isSelfHosted,
|
||||
});
|
||||
const screenshareResolutionOptions: ReadonlyArray<ComboboxOption<OfferedScreenShareResolution>> =
|
||||
screenShareState.resolutionOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: getScreenShareResolutionLabel(i18n, option.value),
|
||||
isDisabled: option.isDisabled,
|
||||
}));
|
||||
const frameRateOptions: ReadonlyArray<ComboboxOption<SupportedScreenShareFrameRate>> =
|
||||
screenShareState.frameRateOptions.map((value) => ({
|
||||
value,
|
||||
label: i18n._(FPS_DESCRIPTOR, {frameRate: value}),
|
||||
}));
|
||||
const handleCameraPreview = async () => {
|
||||
const granted = await requestPermission();
|
||||
if (granted) {
|
||||
@@ -184,12 +207,16 @@ export const VideoTab: React.FC<VideoTabProps> = observer(
|
||||
);
|
||||
}
|
||||
};
|
||||
const handleScreenshareResolutionChange = useCallback((value: ScreenshareResolution) => {
|
||||
VoiceSettingsCommands.update({screenshareResolution: value, streamingMode: 'custom'});
|
||||
}, []);
|
||||
const handleVideoFrameRateChange = useCallback((value: SupportedScreenShareFrameRate) => {
|
||||
VoiceSettingsCommands.update({videoFrameRate: value, streamingMode: 'custom'});
|
||||
}, []);
|
||||
const handleScreenshareResolutionChange = (value: OfferedScreenShareResolution) => {
|
||||
const patch = resolveScreenShareQualityPick(screenShareQuality, {axis: 'resolution', resolution: value});
|
||||
if (patch === null) return;
|
||||
VoiceSettingsCommands.update(patch);
|
||||
};
|
||||
const handleVideoFrameRateChange = (value: SupportedScreenShareFrameRate) => {
|
||||
const patch = resolveScreenShareQualityPick(screenShareQuality, {axis: 'frameRate', frameRate: value});
|
||||
if (patch === null) return;
|
||||
VoiceSettingsCommands.update(patch);
|
||||
};
|
||||
const handleOpenAdvancedVideoSettings = useCallback(() => {
|
||||
ComponentBus.dispatch('USER_SETTINGS_TAB_SELECT', {
|
||||
tab: 'advanced_settings',
|
||||
@@ -271,10 +298,10 @@ export const VideoTab: React.FC<VideoTabProps> = observer(
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.controlGroup} data-flx="user.video-tab.screen-sharing-group">
|
||||
<CompactComboboxRow<ScreenshareResolution>
|
||||
<CompactComboboxRow<OfferedScreenShareResolution>
|
||||
label={i18n._(SCREEN_SHARE_QUALITY_DESCRIPTOR)}
|
||||
options={screenshareResolutionOptions}
|
||||
value={screenshareResolution}
|
||||
value={screenShareState.resolution}
|
||||
onChange={handleScreenshareResolutionChange}
|
||||
isSearchable={false}
|
||||
controlWidth="small"
|
||||
@@ -313,7 +340,7 @@ export const VideoTab: React.FC<VideoTabProps> = observer(
|
||||
<CompactComboboxRow<SupportedScreenShareFrameRate>
|
||||
label={<Trans>Frame rate</Trans>}
|
||||
options={frameRateOptions}
|
||||
value={effectiveVideoFrameRate}
|
||||
value={screenShareState.frameRate}
|
||||
onChange={handleVideoFrameRateChange}
|
||||
isSearchable={false}
|
||||
controlWidth="small"
|
||||
@@ -336,6 +363,35 @@ export const VideoTab: React.FC<VideoTabProps> = observer(
|
||||
{i18n._(SELF_HOSTED_VIDEO_QUALITY_LIMIT_DESCRIPTOR)}
|
||||
</div>
|
||||
)}
|
||||
{screenShareState.preset !== null && (
|
||||
<div className={styles.frameRateNote} data-flx="user.video-tab.preset-note">
|
||||
{i18n._(SCREEN_SHARE_PRESET_OWNED_DESCRIPTOR, {
|
||||
preset: i18n._(SCREEN_SHARE_PRESET_DESCRIPTORS[screenShareState.preset]),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{screenShareState.presetOverriddenByContext && (
|
||||
<div className={styles.frameRateNote} data-flx="user.video-tab.preset-context-note">
|
||||
{i18n._(CAPTURE_DEVICES_USE_THE_GAMING_PRESET_DESCRIPTOR)}
|
||||
</div>
|
||||
)}
|
||||
{screenShareState.saved !== null && (
|
||||
<div className={styles.frameRateNote} data-flx="user.video-tab.tier-limit-note">
|
||||
<CrownIcon
|
||||
weight="fill"
|
||||
size={remFromPx(14)}
|
||||
className={styles.frameRateIcon}
|
||||
data-flx="user.video-tab.tier-limit-icon"
|
||||
/>
|
||||
{i18n._(SCREEN_SHARE_TIER_LIMITED_DESCRIPTOR, {
|
||||
frameRate: screenShareState.frameRate,
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
resolution: getScreenShareResolutionLabel(i18n, screenShareState.resolution),
|
||||
savedFrameRate: screenShareState.saved.frameRate,
|
||||
savedResolution: getScreenShareResolutionLabel(i18n, screenShareState.saved.resolution),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actionRow} data-flx="user.video-tab.advanced-video-settings-section">
|
||||
<span className={styles.actionLabel} data-flx="user.video-tab.advanced-video-settings-label">
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
contextAllowsScreenShareResolution,
|
||||
OFFERED_SCREEN_SHARE_RESOLUTIONS,
|
||||
type OfferedScreenShareResolution,
|
||||
offeredScreenShareResolution,
|
||||
selectStreamSettingsPresetOverriddenByContext,
|
||||
} from '@app/features/voice/components/StreamSettingsMenuContentStateMachine';
|
||||
import type {StreamingMode} from '@app/features/voice/state/VoiceSettings';
|
||||
import type {ScreenShareContentHint} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {
|
||||
resolveScreenShareFrameRate,
|
||||
resolveScreenShareTarget,
|
||||
type ScreenShareContext,
|
||||
type ScreenShareQualityInput,
|
||||
SUPPORTED_SCREEN_SHARE_FRAME_RATES,
|
||||
type SupportedScreenShareFrameRate,
|
||||
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
export const SCREEN_SHARE_PRESET_DESCRIPTORS: Record<Exclude<StreamingMode, 'custom'>, MessageDescriptor> = {
|
||||
gaming: msg({
|
||||
message: 'Gaming',
|
||||
comment: 'Video settings label for the motion-focused screen share quality preset.',
|
||||
}),
|
||||
screenshare: msg({
|
||||
message: 'Screen share',
|
||||
comment: 'Video settings label for the text-focused screen share quality preset.',
|
||||
}),
|
||||
};
|
||||
|
||||
const FREE_SCREEN_SHARE_RESOLUTIONS: ReadonlyArray<OfferedScreenShareResolution> = ['low_480p', 'medium'];
|
||||
const PREMIUM_SCREEN_SHARE_RESOLUTIONS: ReadonlyArray<OfferedScreenShareResolution> = ['high', 'ultra', 'source'];
|
||||
const FREE_SCREEN_SHARE_FRAME_RATES: ReadonlyArray<SupportedScreenShareFrameRate> = [15, 30];
|
||||
const PREMIUM_SCREEN_SHARE_FRAME_RATES: ReadonlyArray<SupportedScreenShareFrameRate> = [15, 30, 60];
|
||||
|
||||
export interface UserVideoTabScreenShareInput {
|
||||
quality: ScreenShareQualityInput;
|
||||
hintSetting: ScreenShareContentHint;
|
||||
selfHosted: boolean;
|
||||
}
|
||||
|
||||
export interface UserVideoTabResolutionOption {
|
||||
value: OfferedScreenShareResolution;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export interface UserVideoTabScreenShareState {
|
||||
resolution: OfferedScreenShareResolution;
|
||||
frameRate: SupportedScreenShareFrameRate;
|
||||
resolutionOptions: ReadonlyArray<UserVideoTabResolutionOption>;
|
||||
frameRateOptions: ReadonlyArray<SupportedScreenShareFrameRate>;
|
||||
preset: Exclude<StreamingMode, 'custom'> | null;
|
||||
presetOverriddenByContext: boolean;
|
||||
saved: {resolution: OfferedScreenShareResolution; frameRate: SupportedScreenShareFrameRate} | null;
|
||||
}
|
||||
|
||||
function buildResolutionOptions(
|
||||
entitled: boolean,
|
||||
selfHosted: boolean,
|
||||
context: ScreenShareContext,
|
||||
effective: OfferedScreenShareResolution,
|
||||
): ReadonlyArray<UserVideoTabResolutionOption> {
|
||||
const offersPremium = entitled || !selfHosted;
|
||||
return OFFERED_SCREEN_SHARE_RESOLUTIONS.filter(
|
||||
(value) =>
|
||||
value === effective ||
|
||||
FREE_SCREEN_SHARE_RESOLUTIONS.includes(value) ||
|
||||
(offersPremium &&
|
||||
PREMIUM_SCREEN_SHARE_RESOLUTIONS.includes(value) &&
|
||||
contextAllowsScreenShareResolution(value, context)),
|
||||
).map((value) => ({value, isDisabled: !entitled && PREMIUM_SCREEN_SHARE_RESOLUTIONS.includes(value)}));
|
||||
}
|
||||
|
||||
function buildFrameRateOptions(
|
||||
entitled: boolean,
|
||||
effective: SupportedScreenShareFrameRate,
|
||||
): ReadonlyArray<SupportedScreenShareFrameRate> {
|
||||
const offered = entitled ? PREMIUM_SCREEN_SHARE_FRAME_RATES : FREE_SCREEN_SHARE_FRAME_RATES;
|
||||
return SUPPORTED_SCREEN_SHARE_FRAME_RATES.filter((value) => value === effective || offered.includes(value));
|
||||
}
|
||||
|
||||
export function resolveUserVideoTabScreenShareState(input: UserVideoTabScreenShareInput): UserVideoTabScreenShareState {
|
||||
const target = resolveScreenShareTarget({...input.quality, sourceDimensions: null, hintSetting: input.hintSetting});
|
||||
const resolution = offeredScreenShareResolution(target.resolution);
|
||||
return {
|
||||
resolution,
|
||||
frameRate: target.frameRate,
|
||||
resolutionOptions: buildResolutionOptions(
|
||||
input.quality.entitled,
|
||||
input.selfHosted,
|
||||
input.quality.context,
|
||||
resolution,
|
||||
),
|
||||
frameRateOptions: buildFrameRateOptions(input.quality.entitled, target.frameRate),
|
||||
preset: input.quality.mode === 'custom' ? null : input.quality.mode,
|
||||
presetOverriddenByContext: selectStreamSettingsPresetOverriddenByContext(input.quality.mode, input.quality.context),
|
||||
saved: target.tierLimited
|
||||
? {
|
||||
resolution: offeredScreenShareResolution(input.quality.storedResolution),
|
||||
frameRate: resolveScreenShareFrameRate(input.quality.storedFrameRate),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
+22
-87
@@ -11,23 +11,26 @@ import {SwitchGroup, SwitchGroupItem} from '@app/features/ui/components/SwitchGr
|
||||
import PiP from '@app/features/ui/state/PiP';
|
||||
import {getElectronAPI, isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import styles from '@app/features/user/components/modals/tabs/AdvancedSettingsTab.module.css';
|
||||
import {selectScreenShareEncoderPathDescription} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControlsState';
|
||||
import {CompactComboboxRow} from '@app/features/user/components/modals/tabs/components/CompactComboboxRow';
|
||||
import PrivacyPreferences from '@app/features/user/state/PrivacyPreferences';
|
||||
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
|
||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import type {
|
||||
CodecPreference,
|
||||
ScreenShareBackupCodecMode,
|
||||
ScreenShareContentHint,
|
||||
ScreenShareEncoderMode,
|
||||
ScreenShareScalabilityModePreference,
|
||||
ScreenShareSoftwareQuality,
|
||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {
|
||||
describeScreenShareEncoderPath,
|
||||
getCodecCapabilityReport,
|
||||
selectAutomaticScreenShareCodec,
|
||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {getGpuEncoderReportSync, loadGpuEncoderReport} from '@app/features/voice/utils/GpuEncoderCapabilities';
|
||||
import {
|
||||
getGpuEncoderReportSync,
|
||||
type HardwareEncodeReport,
|
||||
loadGpuEncoderReport,
|
||||
} from '@app/features/voice/utils/GpuEncoderCapabilities';
|
||||
import {setOpenH264Enabled} from '@app/features/voice/utils/OpenH264Status';
|
||||
import {CODEC_DISPLAY_LABEL} from '@app/features/voice/utils/ScreenShareCodecPolicy';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -57,10 +60,6 @@ const ENCODER_PATH_DESCRIPTOR = msg({
|
||||
message: 'Encoder path',
|
||||
comment: 'Label for an advanced screen-share select. Refers to hardware or software video encoding.',
|
||||
});
|
||||
const ENCODER_PATH_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Encoder preference for new screen shares.',
|
||||
comment: 'Description for the encoder path select.',
|
||||
});
|
||||
const ENCODER_PATH_AUTO_DESCRIPTOR = msg({
|
||||
message: 'Automatic',
|
||||
comment: 'Option label for an encoder path select.',
|
||||
@@ -73,26 +72,6 @@ const ENCODER_PATH_SOFTWARE_DESCRIPTOR = msg({
|
||||
message: 'Prefer software',
|
||||
comment: 'Option label for an encoder path select. Refers to software video encoders.',
|
||||
});
|
||||
const SOFTWARE_QUALITY_DESCRIPTOR = msg({
|
||||
message: 'Software encoder quality',
|
||||
comment: 'Label for an advanced screen-share select. Refers to software video encoder quality bias.',
|
||||
});
|
||||
const SOFTWARE_QUALITY_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Higher quality can increase CPU usage and latency.',
|
||||
comment: 'Description for a software encoder quality select. Keep AV1 and CPU literal.',
|
||||
});
|
||||
const SOFTWARE_QUALITY_REALTIME_DESCRIPTOR = msg({
|
||||
message: 'Real-time',
|
||||
comment: 'Option label for a software encoder quality select. Means fastest/lower-latency.',
|
||||
});
|
||||
const SOFTWARE_QUALITY_BALANCED_DESCRIPTOR = msg({
|
||||
message: 'Balanced',
|
||||
comment: 'Option label for a software encoder quality select.',
|
||||
});
|
||||
const SOFTWARE_QUALITY_QUALITY_DESCRIPTOR = msg({
|
||||
message: 'Quality',
|
||||
comment: 'Option label for a software encoder quality select. Means slower/higher-quality.',
|
||||
});
|
||||
const SVC_MODE_DESCRIPTOR = msg({
|
||||
message: 'SVC mode',
|
||||
comment: 'Label for an advanced screen-share select. SVC is a WebRTC acronym and should stay uppercase.',
|
||||
@@ -113,33 +92,14 @@ const SVC_MODE_TEMPORAL_DESCRIPTOR = msg({
|
||||
message: 'Temporal layers',
|
||||
comment: 'Option label for an SVC mode select.',
|
||||
});
|
||||
const SVC_MODE_SPATIAL_DESCRIPTOR = msg({
|
||||
message: 'Spatial and temporal layers',
|
||||
comment: 'Option label for an SVC mode select.',
|
||||
});
|
||||
const BACKUP_CODEC_DESCRIPTOR = msg({
|
||||
message: 'H.264 backup stream',
|
||||
comment: 'Label for an advanced screen-share select. H.264 is a codec name and should stay literal.',
|
||||
});
|
||||
const BACKUP_CODEC_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Adds H.264 for mixed clients. Requires more encoding.',
|
||||
comment: 'Description for an H.264 backup stream select. Keep H.264, CPU, and GPU literal.',
|
||||
});
|
||||
const BACKUP_CODEC_OFF_DESCRIPTOR = msg({
|
||||
message: 'Off',
|
||||
comment: 'Option label for an H.264 backup stream select.',
|
||||
});
|
||||
const BACKUP_CODEC_H264_SIMULCAST_DESCRIPTOR = msg({
|
||||
message: 'H.264 simulcast backup',
|
||||
comment: 'Option label for an H.264 backup stream select. H.264 is a codec name and should stay literal.',
|
||||
});
|
||||
const CONTENT_HINT_DESCRIPTOR = msg({
|
||||
message: 'Content hint',
|
||||
comment: 'Label for an advanced screen-share select. Refers to the WebRTC MediaStreamTrack contentHint value.',
|
||||
});
|
||||
const CONTENT_HINT_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Helps the browser choose motion, detail, or text handling.',
|
||||
comment: 'Description for a WebRTC content hint select. Keep Motion, Detail, and text as plain option concepts.',
|
||||
message: 'Used in Custom mode. The Gaming and Screen share presets set their own hint.',
|
||||
comment:
|
||||
'Description for a WebRTC content hint select. Custom, Gaming and Screen share are the stream quality preset labels.',
|
||||
});
|
||||
const CONTENT_HINT_AUTO_DESCRIPTOR = msg({
|
||||
message: 'Automatic',
|
||||
@@ -191,9 +151,7 @@ const HEVC_SCREEN_SHARE_OPT_IN_DESCRIPTOR = msg({
|
||||
});
|
||||
const SCREEN_SHARE_CODEC_OPTION_ORDER = ['av1', 'h265', 'h264', 'vp9', 'vp8'] as const;
|
||||
|
||||
export const ScreenShareCodecControl = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const encoderMode = VoiceSettings.getScreenShareEncoderMode();
|
||||
function useLoadedGpuEncoderReport(): HardwareEncodeReport | null {
|
||||
const [gpuReport, setGpuReport] = useState(() => getGpuEncoderReportSync());
|
||||
useEffect(() => {
|
||||
if (gpuReport) return;
|
||||
@@ -205,7 +163,14 @@ export const ScreenShareCodecControl = observer(() => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [gpuReport]);
|
||||
const automaticCodec = selectAutomaticScreenShareCodec(encoderMode).codec;
|
||||
return gpuReport;
|
||||
}
|
||||
|
||||
export const ScreenShareCodecControl = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const encoderMode = VoiceSettings.getScreenShareEncoderMode();
|
||||
useLoadedGpuEncoderReport();
|
||||
const automaticCodec = describeScreenShareEncoderPath(encoderMode).codec;
|
||||
const av1OptIn = VoiceSettings.getScreenShareAv1OptIn();
|
||||
const hevcOptIn = VoiceSettings.getScreenShareHevcOptIn();
|
||||
const codecCapabilities = getCodecCapabilityReport();
|
||||
@@ -313,25 +278,17 @@ export const ScreenSharePreviewBehaviorControl = observer(() => {
|
||||
|
||||
const ScreenShareEncoderControlsContent = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
useLoadedGpuEncoderReport();
|
||||
const {hardwareUnavailable} = describeScreenShareEncoderPath(VoiceSettings.screenShareEncoderMode);
|
||||
const encoderModeOptions: ReadonlyArray<ComboboxOption<ScreenShareEncoderMode>> = [
|
||||
{value: 'auto', label: i18n._(ENCODER_PATH_AUTO_DESCRIPTOR)},
|
||||
{value: 'hardware', label: i18n._(ENCODER_PATH_HARDWARE_DESCRIPTOR)},
|
||||
{value: 'software', label: i18n._(ENCODER_PATH_SOFTWARE_DESCRIPTOR)},
|
||||
];
|
||||
const softwareQualityOptions: ReadonlyArray<ComboboxOption<ScreenShareSoftwareQuality>> = [
|
||||
{value: 'realtime', label: i18n._(SOFTWARE_QUALITY_REALTIME_DESCRIPTOR)},
|
||||
{value: 'balanced', label: i18n._(SOFTWARE_QUALITY_BALANCED_DESCRIPTOR)},
|
||||
{value: 'quality', label: i18n._(SOFTWARE_QUALITY_QUALITY_DESCRIPTOR)},
|
||||
];
|
||||
const scalabilityModeOptions: ReadonlyArray<ComboboxOption<ScreenShareScalabilityModePreference>> = [
|
||||
{value: 'auto', label: i18n._(SVC_MODE_AUTO_DESCRIPTOR)},
|
||||
{value: 'single_layer', label: i18n._(SVC_MODE_SINGLE_LAYER_DESCRIPTOR)},
|
||||
{value: 'temporal', label: i18n._(SVC_MODE_TEMPORAL_DESCRIPTOR)},
|
||||
{value: 'spatial', label: i18n._(SVC_MODE_SPATIAL_DESCRIPTOR)},
|
||||
];
|
||||
const backupCodecOptions: ReadonlyArray<ComboboxOption<ScreenShareBackupCodecMode>> = [
|
||||
{value: 'off', label: i18n._(BACKUP_CODEC_OFF_DESCRIPTOR)},
|
||||
{value: 'h264_simulcast', label: i18n._(BACKUP_CODEC_H264_SIMULCAST_DESCRIPTOR)},
|
||||
];
|
||||
const contentHintOptions: ReadonlyArray<ComboboxOption<ScreenShareContentHint>> = [
|
||||
{value: 'auto', label: i18n._(CONTENT_HINT_AUTO_DESCRIPTOR)},
|
||||
@@ -343,7 +300,7 @@ const ScreenShareEncoderControlsContent = observer(() => {
|
||||
<div className={styles.controlStackCompact} data-flx="user.advanced-settings-tab.screen-share-encoder-controls">
|
||||
<CompactComboboxRow<ScreenShareEncoderMode>
|
||||
label={i18n._(ENCODER_PATH_DESCRIPTOR)}
|
||||
description={i18n._(ENCODER_PATH_DESCRIPTION_DESCRIPTOR)}
|
||||
description={i18n._(selectScreenShareEncoderPathDescription(hardwareUnavailable))}
|
||||
value={VoiceSettings.screenShareEncoderMode}
|
||||
options={encoderModeOptions}
|
||||
onChange={(value) => VoiceSettingsCommands.update({screenShareEncoderMode: value})}
|
||||
@@ -352,17 +309,6 @@ const ScreenShareEncoderControlsContent = observer(() => {
|
||||
dataFlx="user.advanced-settings-tab.select.screen-share-encoder-path"
|
||||
data-flx="user.advanced-settings-tab.advanced-video-controls.screen-share-encoder-controls-content.compact-combobox-row.update"
|
||||
/>
|
||||
<CompactComboboxRow<ScreenShareSoftwareQuality>
|
||||
label={i18n._(SOFTWARE_QUALITY_DESCRIPTOR)}
|
||||
description={i18n._(SOFTWARE_QUALITY_DESCRIPTION_DESCRIPTOR)}
|
||||
value={VoiceSettings.screenShareSoftwareQuality}
|
||||
options={softwareQualityOptions}
|
||||
onChange={(value) => VoiceSettingsCommands.update({screenShareSoftwareQuality: value})}
|
||||
isSearchable={false}
|
||||
controlWidth="small"
|
||||
dataFlx="user.advanced-settings-tab.select.screen-share-software-quality"
|
||||
data-flx="user.advanced-settings-tab.advanced-video-controls.screen-share-encoder-controls-content.compact-combobox-row.update--2"
|
||||
/>
|
||||
<CompactComboboxRow<ScreenShareScalabilityModePreference>
|
||||
label={i18n._(SVC_MODE_DESCRIPTOR)}
|
||||
description={i18n._(SVC_MODE_DESCRIPTION_DESCRIPTOR)}
|
||||
@@ -374,17 +320,6 @@ const ScreenShareEncoderControlsContent = observer(() => {
|
||||
dataFlx="user.advanced-settings-tab.select.screen-share-svc-mode"
|
||||
data-flx="user.advanced-settings-tab.advanced-video-controls.screen-share-encoder-controls-content.compact-combobox-row.update--3"
|
||||
/>
|
||||
<CompactComboboxRow<ScreenShareBackupCodecMode>
|
||||
label={i18n._(BACKUP_CODEC_DESCRIPTOR)}
|
||||
description={i18n._(BACKUP_CODEC_DESCRIPTION_DESCRIPTOR)}
|
||||
value={VoiceSettings.screenShareBackupCodecMode}
|
||||
options={backupCodecOptions}
|
||||
onChange={(value) => VoiceSettingsCommands.update({screenShareBackupCodecMode: value})}
|
||||
isSearchable={false}
|
||||
controlWidth="large"
|
||||
dataFlx="user.advanced-settings-tab.select.screen-share-backup-codec"
|
||||
data-flx="user.advanced-settings-tab.advanced-video-controls.screen-share-encoder-controls-content.compact-combobox-row.update--4"
|
||||
/>
|
||||
<CompactComboboxRow<ScreenShareContentHint>
|
||||
label={i18n._(CONTENT_HINT_DESCRIPTOR)}
|
||||
description={i18n._(CONTENT_HINT_DESCRIPTION_DESCRIPTOR)}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
const ENCODER_PATH_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Encoder preference for new screen shares.',
|
||||
comment: 'Description for the encoder path select.',
|
||||
});
|
||||
const ENCODER_PATH_NO_HARDWARE_DESCRIPTOR = msg({
|
||||
message: 'No hardware encoder was found on this device, so Prefer hardware works like Automatic.',
|
||||
comment:
|
||||
'Description for the encoder path select, shown when this device has no hardware video encoder. Prefer hardware and Automatic are the option labels of the same select.',
|
||||
});
|
||||
|
||||
export function selectScreenShareEncoderPathDescription(hardwareUnavailable: boolean): MessageDescriptor {
|
||||
return hardwareUnavailable ? ENCODER_PATH_NO_HARDWARE_DESCRIPTOR : ENCODER_PATH_DESCRIPTION_DESCRIPTOR;
|
||||
}
|
||||
+1
-1
@@ -171,7 +171,7 @@ const BITRATE_DESCRIPTOR = msg({
|
||||
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
|
||||
});
|
||||
const TUNE_LOW_LEVEL_SCREEN_SHARE_ENCODER_BEHAVIOR_FOR_THE_NEXT_STREAM_DESCRIPTOR = msg({
|
||||
message: 'Encoder, SVC, backup stream, and bitrate presets',
|
||||
message: 'Encoder, SVC, and bitrate presets',
|
||||
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {noteDeliberateScreenShareQualityChange} from '@app/features/voice/engine/ScreenShareUnderperformance';
|
||||
import VoiceSettings, {
|
||||
type CameraResolution,
|
||||
type ScreenshareResolution,
|
||||
@@ -9,11 +8,9 @@ import VoiceSettings, {
|
||||
} from '@app/features/voice/state/VoiceSettings';
|
||||
import type {
|
||||
CodecPreference,
|
||||
ScreenShareBackupCodecMode,
|
||||
ScreenShareContentHint,
|
||||
ScreenShareEncoderMode,
|
||||
ScreenShareScalabilityModePreference,
|
||||
ScreenShareSoftwareQuality,
|
||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {getActiveInputDeviceLabel, type VoiceProcessingMode} from '@app/features/voice/utils/VoiceProcessingProfile';
|
||||
|
||||
@@ -62,9 +59,7 @@ type VoiceSettingsPatch = Partial<{
|
||||
screenShareHevcOptIn: boolean;
|
||||
screenShareContentHint: ScreenShareContentHint;
|
||||
screenShareEncoderMode: ScreenShareEncoderMode;
|
||||
screenShareSoftwareQuality: ScreenShareSoftwareQuality;
|
||||
screenShareScalabilityMode: ScreenShareScalabilityModePreference;
|
||||
screenShareBackupCodecMode: ScreenShareBackupCodecMode;
|
||||
vadThreshold: number;
|
||||
vadAutoSensitivity: boolean;
|
||||
vadEnhanced: boolean;
|
||||
@@ -107,6 +102,7 @@ const SCREEN_SHARE_CODEC_NEGOTIATION_REFRESH_KEYS: Array<keyof VoiceSettingsPatc
|
||||
'preferredScreenShareCodec',
|
||||
'screenShareAv1OptIn',
|
||||
'screenShareHevcOptIn',
|
||||
'screenShareEncoderMode',
|
||||
];
|
||||
function refreshMicrophone(): void {
|
||||
MediaEngine.refreshMicrophoneFromSettings();
|
||||
@@ -151,44 +147,13 @@ function shouldRefreshCameraCapture(
|
||||
return CAMERA_CAPTURE_REFRESH_KEYS.some((key) => settings[key] !== undefined && before[key] !== after[key]);
|
||||
}
|
||||
|
||||
function readScreenShareQualityValues(): VoiceSettingsPatch {
|
||||
return {
|
||||
screenshareResolution: VoiceSettings.getScreenshareResolution(),
|
||||
videoFrameRate: VoiceSettings.getVideoFrameRate(),
|
||||
streamingMode: VoiceSettings.getStreamingMode(),
|
||||
screenShareContentHint: VoiceSettings.getScreenShareContentHint(),
|
||||
preferredScreenShareCodec: VoiceSettings.getPreferredScreenShareCodec(),
|
||||
screenShareAv1OptIn: VoiceSettings.getScreenShareAv1OptIn(),
|
||||
screenShareHevcOptIn: VoiceSettings.getScreenShareHevcOptIn(),
|
||||
screenShareEncoderMode: VoiceSettings.getScreenShareEncoderMode(),
|
||||
screenShareSoftwareQuality: VoiceSettings.getScreenShareSoftwareQuality(),
|
||||
screenShareScalabilityMode: VoiceSettings.getScreenShareScalabilityMode(),
|
||||
screenShareBackupCodecMode: VoiceSettings.getScreenShareBackupCodecMode(),
|
||||
openH264Enabled: VoiceSettings.getOpenH264Enabled(),
|
||||
};
|
||||
}
|
||||
|
||||
function changesScreenShareQualityDeliberately(
|
||||
settings: VoiceSettingsPatch,
|
||||
before: VoiceSettingsPatch,
|
||||
after: VoiceSettingsPatch,
|
||||
): boolean {
|
||||
return (Object.keys(before) as Array<keyof VoiceSettingsPatch>).some(
|
||||
(key) => settings[key] !== undefined && before[key] !== after[key],
|
||||
);
|
||||
}
|
||||
|
||||
function applyUpdatedVoiceSettings(
|
||||
settings: VoiceSettingsPatch,
|
||||
refreshInput: boolean,
|
||||
options: VoiceSettingsUpdateOptions = {},
|
||||
): void {
|
||||
const cameraCaptureBefore = readCameraCaptureRefreshValues();
|
||||
const screenShareQualityBefore = readScreenShareQualityValues();
|
||||
VoiceSettings.updateSettings(settings);
|
||||
if (changesScreenShareQualityDeliberately(settings, screenShareQualityBefore, readScreenShareQualityValues())) {
|
||||
noteDeliberateScreenShareQualityChange();
|
||||
}
|
||||
if (settings.muteStreamAudio !== undefined) {
|
||||
MediaEngine.setScreenShareAudioMuted(settings.muteStreamAudio);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ export interface ActiveScreenShareMenuProps {
|
||||
shareContextResolved: boolean;
|
||||
iconClassName?: string;
|
||||
additionalActions?: React.ReactNode;
|
||||
showLiveSettings?: boolean;
|
||||
tail?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -60,7 +59,6 @@ export const ActiveScreenShareMenu: React.FC<ActiveScreenShareMenuProps> = ({
|
||||
shareContextResolved,
|
||||
iconClassName,
|
||||
additionalActions,
|
||||
showLiveSettings = true,
|
||||
tail,
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
@@ -109,7 +107,7 @@ export const ActiveScreenShareMenu: React.FC<ActiveScreenShareMenuProps> = ({
|
||||
{i18n._(CHANGE_STREAM_DESCRIPTOR)}
|
||||
</MenuItem>
|
||||
{additionalActions}
|
||||
{showLiveSettings && !isWeb && (
|
||||
{!isWeb && (
|
||||
<StreamSettingsMenuContent
|
||||
applyToLiveStream
|
||||
variant="compactLive"
|
||||
|
||||
@@ -8,9 +8,10 @@ import {MenuItemRadio} from '@app/features/ui/action_menu/MenuItemRadio';
|
||||
import {MenuItemSubmenu} from '@app/features/ui/action_menu/MenuItemSubmenu';
|
||||
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
|
||||
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices';
|
||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
filterRoutableLinuxAudioSources,
|
||||
type LinuxAudioSourceFilterOptions,
|
||||
type LinuxAudioSourceItem,
|
||||
linuxAudioSourceItemKey,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
formatScreenShareAudioSummary,
|
||||
MICROPHONE_DESCRIPTOR,
|
||||
MICROPHONE_WITH_DEVICE_DESCRIPTOR,
|
||||
resolveDeviceShareAudioPairing,
|
||||
} from '@app/features/voice/utils/ScreenShareAudioSummary';
|
||||
import type {DisplayShareEnvironment} from '@app/features/voice/utils/ScreenShareEnvironment';
|
||||
import {
|
||||
@@ -142,11 +144,11 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
||||
const includeSources = VoiceSettings.getScreenShareAudioIncludeSources();
|
||||
const excludeSources = VoiceSettings.getScreenShareAudioExcludeSources();
|
||||
const usesDeviceMicrophone = VoiceSettings.getScreenShareDeviceAudioUsesMicrophone();
|
||||
const {inputDevices, videoDevices} = useMediaDevices({autoRefresh: true, requestPermissions: false});
|
||||
const granular = VoiceSettings.getLinuxAudioCaptureGranularSelect();
|
||||
const deviceSelect = VoiceSettings.getLinuxAudioCaptureDeviceSelect();
|
||||
const ignoreVirtual = VoiceSettings.getLinuxAudioCaptureIgnoreVirtual();
|
||||
const [snapshot, setSnapshot] = useState<AudioSourceSnapshot>(EMPTY_SNAPSHOT);
|
||||
const routesSelectedSources = sourceMode === 'specific' && filterRoutableLinuxAudioSources(includeSources).length > 0;
|
||||
const widenedScope = offersWindowScope ? ('system' as const) : undefined;
|
||||
const refresh = useCallback(() => {
|
||||
setSnapshot((prev) => ({...prev, loading: true}));
|
||||
@@ -218,12 +220,17 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
||||
includeSources,
|
||||
shareContext,
|
||||
microphoneLabel,
|
||||
chosenAudioDeviceId: VoiceSettings.getScreenShareAudioDeviceId(),
|
||||
deviceAudioPairing: resolveDeviceShareAudioPairing(
|
||||
[...videoDevices, ...inputDevices],
|
||||
MediaEngine.getActiveScreenShareVideoDeviceId(),
|
||||
),
|
||||
displayShareEnvironment,
|
||||
windowAudioScope,
|
||||
usesDeviceMicrophone,
|
||||
});
|
||||
const showsWideSourceLists = !offersWindowScope || resolvedScope === 'system';
|
||||
const wideSourceIsSelected = isDeviceShare ? usesDeviceMicrophone || !routesSelectedSources : sourceMode === 'system';
|
||||
const wideSourceIsSelected = isDeviceShare ? usesDeviceMicrophone : sourceMode === 'system';
|
||||
if (!snapshot.available && !snapshot.loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {LiveBadge} from '@app/features/ui/components/LiveBadge';
|
||||
import type {TooltipPosition} from '@app/features/ui/tooltip/Tooltip';
|
||||
import styles from '@app/features/voice/components/StreamInfoPill.module.css';
|
||||
import type {StreamTrackInfo} from '@app/features/voice/components/useStreamTrackInfo';
|
||||
import type {ScreenShareTarget} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {formatScreenShareTargetLabel} from '@app/features/voice/utils/VoiceMessageDescriptors';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
@@ -19,8 +21,19 @@ type ResolutionHeight = 240 | 480 | 720 | 1080 | 1440 | 2160;
|
||||
|
||||
const RESOLUTION_HEIGHTS: Array<ResolutionHeight> = [480, 240, 720, 1080, 1440, 2160];
|
||||
|
||||
const RESOLUTION_LABELS: Record<ResolutionHeight, string> = {
|
||||
240: '240p',
|
||||
480: '480p',
|
||||
720: '720p',
|
||||
1080: '1080p',
|
||||
1440: '1440p',
|
||||
2160: '4K',
|
||||
};
|
||||
|
||||
type StreamInfoPillTone = 'default' | 'voice_tile';
|
||||
|
||||
export type StreamInfoPillQuality = StreamTrackInfo | {target: ScreenShareTarget};
|
||||
|
||||
function getClosestResolutionHeight(height: number) {
|
||||
let closest: ResolutionHeight = RESOLUTION_HEIGHTS[0];
|
||||
let smallestDiff = Math.abs(height - closest);
|
||||
@@ -35,7 +48,7 @@ function getClosestResolutionHeight(height: number) {
|
||||
}
|
||||
|
||||
interface StreamInfoPillProps {
|
||||
info: StreamTrackInfo;
|
||||
info: StreamInfoPillQuality;
|
||||
className?: string;
|
||||
showLiveBadge?: boolean;
|
||||
tone?: StreamInfoPillTone;
|
||||
@@ -50,29 +63,17 @@ export function StreamInfoPill({
|
||||
liveBadgeTooltipPosition,
|
||||
}: StreamInfoPillProps) {
|
||||
const {i18n} = useLingui();
|
||||
const resolutionText = useMemo(() => {
|
||||
const targetHeight = getClosestResolutionHeight(info.height);
|
||||
switch (targetHeight) {
|
||||
case 240:
|
||||
return '240p';
|
||||
case 480:
|
||||
return '480p';
|
||||
case 720:
|
||||
return '720p';
|
||||
case 1080:
|
||||
return '1080p';
|
||||
case 1440:
|
||||
return '1440p';
|
||||
case 2160:
|
||||
return '4K';
|
||||
default:
|
||||
return '720p';
|
||||
}
|
||||
}, [info.height]);
|
||||
const labelText = useMemo(() => {
|
||||
if ('target' in info) {
|
||||
return i18n._(RESOLUTION_WITH_FPS_DESCRIPTOR, {
|
||||
resolution: formatScreenShareTargetLabel(i18n, info.target),
|
||||
fps: i18n.number(info.target.frameRate),
|
||||
});
|
||||
}
|
||||
const resolutionText = RESOLUTION_LABELS[getClosestResolutionHeight(info.height)];
|
||||
if (!Number.isFinite(info.fps) || info.fps <= 0) return resolutionText;
|
||||
return i18n._(RESOLUTION_WITH_FPS_DESCRIPTOR, {resolution: resolutionText, fps: i18n.number(info.fps)});
|
||||
}, [info.fps, i18n.locale, resolutionText]);
|
||||
}, [info, i18n.locale]);
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.container, tone === 'voice_tile' && styles.containerOnTile, className)}
|
||||
|
||||
@@ -13,16 +13,21 @@ import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettin
|
||||
import {AudioSourcePickerLinuxSubmenu} from '@app/features/voice/components/AudioSourcePickerLinux';
|
||||
import styles from '@app/features/voice/components/StreamSettingsMenuContent.module.css';
|
||||
import {
|
||||
CAPTURE_DEVICES_USE_THE_GAMING_PRESET_DESCRIPTOR,
|
||||
type OfferedScreenShareResolution,
|
||||
type StreamSettingsAudioMenuViewState,
|
||||
type StreamSettingsQualityWrite,
|
||||
selectStreamSettingsAudioMenuState,
|
||||
selectStreamSettingsPresetOverriddenByContext,
|
||||
selectStreamSettingsQualityMenuState,
|
||||
} from '@app/features/voice/components/StreamSettingsMenuContentStateMachine';
|
||||
import MediaEngine, {useMediaEngineVersion} from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import ScreenShareCodecNegotiation from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
|
||||
import {resolveConfiguredScreenShareTarget} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||
import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices';
|
||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import VoiceSettings, {type ScreenshareResolution, type StreamingMode} from '@app/features/voice/state/VoiceSettings';
|
||||
import {resolveScreenShareContentHintForContext} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import VoiceSettings, {type StreamingMode} from '@app/features/voice/state/VoiceSettings';
|
||||
import {filterRoutableLinuxAudioSources} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
||||
import {
|
||||
getNativeAudioAvailabilityCached,
|
||||
@@ -34,20 +39,19 @@ import {
|
||||
usesNativeDisplayShareAudioSelection,
|
||||
} from '@app/features/voice/utils/ScreenShareEnvironment';
|
||||
import {
|
||||
buildScreenShareOptions,
|
||||
normaliseResolutionForContext,
|
||||
normaliseStreamingModeForContext,
|
||||
resolveScreenShareFrameRate,
|
||||
resolveStreamingModeSettings,
|
||||
type ScreenShareContext,
|
||||
type SupportedScreenShareFrameRate,
|
||||
resolveScreenShareTarget,
|
||||
type ScreenShareQualityInput,
|
||||
type ScreenShareTarget,
|
||||
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {isScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError';
|
||||
import {
|
||||
applyLiveScreenShareAudioSourceChange,
|
||||
buildConfiguredScreenShareOptions,
|
||||
reconfigureActiveDeviceShareAudio,
|
||||
reconfigureActiveLinuxAppShareAudio,
|
||||
reconfigureActiveLinuxScreenShareAudioLink,
|
||||
scheduleConfiguredScreenShareMutation,
|
||||
stopActiveLinuxScreenShareAudioLink,
|
||||
} from '@app/features/voice/utils/ScreenShareStartFlow';
|
||||
import {executeScreenShareOperation, handleScreenShareError} from '@app/features/voice/utils/ScreenShareUtils';
|
||||
@@ -60,9 +64,10 @@ import {
|
||||
import {hasHigherVideoQuality as resolveHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
||||
import {formatVoiceAudioDeviceLabel} from '@app/features/voice/utils/VoiceMessageDescriptors';
|
||||
import type {NativeAudioAvailability} from '@app/types/electron.d';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {CrownSimpleIcon, MicrophoneIcon} from '@phosphor-icons/react';
|
||||
import {CrownSimpleIcon, MicrophoneIcon, WaveformIcon} from '@phosphor-icons/react';
|
||||
import type {Track} from 'livekit-client';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
@@ -71,26 +76,26 @@ const GAMING_DESCRIPTOR = msg({
|
||||
message: 'Gaming',
|
||||
comment: 'Streaming preset label in the stream settings menu. Higher frame rate, optimized for video games.',
|
||||
});
|
||||
const FLUID_MOTION_AT_1440P_60_FPS_DESCRIPTOR = msg({
|
||||
message: 'Fluid motion at 1440p 60 FPS',
|
||||
const SMOOTH_60_FPS_AT_UP_TO_1440P_DESCRIPTOR = msg({
|
||||
message: 'Smooth 60 FPS at up to 1440p',
|
||||
comment:
|
||||
'Description for the high-tier Gaming streaming preset (Plutonium). Resolution and frame rate are technical tokens.',
|
||||
});
|
||||
const FLUID_MOTION_AT_720P_30_FPS_DESCRIPTOR = msg({
|
||||
message: 'Fluid motion at 720p 30 FPS',
|
||||
const SMOOTH_30_FPS_AT_UP_TO_720P_DESCRIPTOR = msg({
|
||||
message: 'Smooth 30 FPS at up to 720p',
|
||||
comment: 'Description for the free-tier Gaming streaming preset. Resolution and frame rate are technical tokens.',
|
||||
});
|
||||
const SCREENSHARE_DESCRIPTOR = msg({
|
||||
message: 'Screen share',
|
||||
comment: 'Streaming preset label in the stream settings menu. Optimized for sharp text in screen shares.',
|
||||
});
|
||||
const RAZOR_SHARP_TEXT_AT_NATIVE_SOURCE_15_FPS_DESCRIPTOR = msg({
|
||||
message: 'Razor-sharp text at native source resolution, 15 FPS',
|
||||
const SHARP_TEXT_AT_UP_TO_4K_DESCRIPTOR = msg({
|
||||
message: 'Sharp text at up to 4K, up to 15 FPS',
|
||||
comment:
|
||||
'Description for the high-tier Screen share streaming preset (Plutonium). Source resolution and frame rate are technical tokens.',
|
||||
'Description for the high-tier Screen share streaming preset (Plutonium). Resolution and frame rate are technical tokens.',
|
||||
});
|
||||
const SHARPER_TEXT_AT_720P_30_FPS_DESCRIPTOR = msg({
|
||||
message: 'Sharper text at 720p, 30 FPS',
|
||||
const SHARP_TEXT_AT_720P_DESCRIPTOR = msg({
|
||||
message: 'Sharp text at 720p, up to 30 FPS',
|
||||
comment:
|
||||
'Description for the free-tier Screen share streaming preset. Resolution and frame rate are technical tokens.',
|
||||
});
|
||||
@@ -106,18 +111,16 @@ const SOURCE_DESCRIPTOR = msg({
|
||||
message: 'Source',
|
||||
comment: 'Resolution option in the stream settings menu meaning the original source resolution (no downscale).',
|
||||
});
|
||||
const MESSAGE_15_FPS_DESCRIPTOR = msg({
|
||||
message: '15 FPS',
|
||||
comment: 'Frame rate option label in the stream settings menu. FPS is a technical token.',
|
||||
});
|
||||
const MESSAGE_30_FPS_DESCRIPTOR = msg({
|
||||
message: '30 FPS',
|
||||
comment: 'Frame rate option label in the stream settings menu. FPS is a technical token.',
|
||||
});
|
||||
const MESSAGE_60_FPS_DESCRIPTOR = msg({
|
||||
message: '60 FPS',
|
||||
comment: 'Frame rate option label in the stream settings menu. FPS is a technical token.',
|
||||
const FPS_DESCRIPTOR = msg({
|
||||
message: '{frameRate} FPS',
|
||||
comment: 'Frame rate option label in the video tab. FPS is a technical token and frameRate is a whole number.',
|
||||
});
|
||||
const SCREEN_SHARE_RESOLUTION_LABELS: Record<Exclude<OfferedScreenShareResolution, 'source'>, string> = {
|
||||
low_480p: '480p',
|
||||
medium: '720p',
|
||||
high: '1080p',
|
||||
ultra: '1440p',
|
||||
};
|
||||
const STREAMING_MODE_DESCRIPTOR = msg({
|
||||
message: 'Streaming mode',
|
||||
comment: 'Section header in the stream settings menu. Picks between Gaming / Screen share / Custom presets.',
|
||||
@@ -191,6 +194,10 @@ export function useHasHigherVideoQuality(): boolean {
|
||||
return resolveHigherVideoQuality();
|
||||
}
|
||||
|
||||
function getScreenShareResolutionLabel(i18n: I18n, resolution: OfferedScreenShareResolution): string {
|
||||
return resolution === 'source' ? i18n._(SOURCE_DESCRIPTOR) : SCREEN_SHARE_RESOLUTION_LABELS[resolution];
|
||||
}
|
||||
|
||||
function supportsStreamAudioCapture(shareContext: StreamSettingsShareContext): boolean {
|
||||
if (shareContext === 'device') {
|
||||
return typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getUserMedia);
|
||||
@@ -198,45 +205,21 @@ function supportsStreamAudioCapture(shareContext: StreamSettingsShareContext): b
|
||||
return supportsDesktopScreenShareAudioCapture();
|
||||
}
|
||||
|
||||
function getScreenShareContext(shareContext: StreamSettingsShareContext): ScreenShareContext {
|
||||
return shareContext === 'device' ? 'device' : 'display';
|
||||
}
|
||||
|
||||
function getPreferredDisplaySurface(shareContext: StreamSettingsShareContext): 'window' | 'monitor' | undefined {
|
||||
if (shareContext === 'app') return 'window';
|
||||
if (shareContext === 'display') return 'monitor';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function pushActiveStreamSettings(
|
||||
async function runActiveStreamSettingsPush(
|
||||
shareContext: StreamSettingsShareContext,
|
||||
displayShareEnvironment: DisplayShareEnvironment,
|
||||
hasHigherVideoQuality: boolean,
|
||||
options: PushActiveStreamSettingsOptions = {},
|
||||
): Promise<void> {
|
||||
const mode = VoiceSettings.getStreamingMode();
|
||||
const screenShareContext = getScreenShareContext(shareContext);
|
||||
const normalisedMode = normaliseStreamingModeForContext(mode, screenShareContext);
|
||||
const normalisedResolution = normaliseResolutionForContext(
|
||||
VoiceSettings.getScreenshareResolution(),
|
||||
screenShareContext,
|
||||
hasHigherVideoQuality,
|
||||
);
|
||||
const {resolution, frameRate} = resolveStreamingModeSettings(
|
||||
normalisedMode,
|
||||
normalisedResolution,
|
||||
VoiceSettings.getVideoFrameRate(),
|
||||
hasHigherVideoQuality,
|
||||
);
|
||||
options: PushActiveStreamSettingsOptions,
|
||||
): Promise<boolean> {
|
||||
const sourceDimensions = ActiveScreenShareSource.getSourceDimensions();
|
||||
const target = resolveConfiguredScreenShareTarget(shareContext, sourceDimensions);
|
||||
ActiveScreenShareSource.setTarget(target);
|
||||
const preferredDisplaySurface = getPreferredDisplaySurface(shareContext);
|
||||
const preferredScreenShareCodecPreference = VoiceSettings.getPreferredScreenShareCodec();
|
||||
const preferredVideoCodec = ScreenShareCodecNegotiation.selectScreenShareCodec(preferredScreenShareCodecPreference);
|
||||
const contentHint = resolveScreenShareContentHintForContext(
|
||||
VoiceSettings.getScreenShareContentHintOverride(),
|
||||
preferredVideoCodec,
|
||||
shareContext,
|
||||
normalisedMode,
|
||||
);
|
||||
const canControlAudio = supportsStreamAudioCapture(shareContext);
|
||||
const includeAudio =
|
||||
canControlAudio &&
|
||||
@@ -245,16 +228,13 @@ export async function pushActiveStreamSettings(
|
||||
: shareContext === 'device'
|
||||
? VoiceSettings.getShareDeviceAudio()
|
||||
: VoiceSettings.getShareDesktopAudio());
|
||||
const {captureOptions, publishOptions} = buildScreenShareOptions({
|
||||
resolution,
|
||||
frameRate,
|
||||
const {captureOptions, publishOptions} = buildConfiguredScreenShareOptions({
|
||||
target,
|
||||
sourceDimensions,
|
||||
includeAudio,
|
||||
contentHint,
|
||||
videoCodec: ScreenShareCodecNegotiation.selectScreenShareCodec(VoiceSettings.getPreferredScreenShareCodec()),
|
||||
preferredDisplaySurface,
|
||||
});
|
||||
if (preferredScreenShareCodecPreference !== 'auto') {
|
||||
publishOptions.videoCodec = preferredVideoCodec;
|
||||
}
|
||||
const localParticipant = MediaEngine.room?.localParticipant ?? null;
|
||||
const hasActiveScreenShareAudioPublication = localParticipant?.getTrackPublication(SCREEN_SHARE_AUDIO_SOURCE) != null;
|
||||
const linuxDesktopAudioShare = isLinuxDesktopAudioShare({
|
||||
@@ -282,12 +262,13 @@ export async function pushActiveStreamSettings(
|
||||
} catch (error) {
|
||||
if (isScreenShareRollbackIncompleteError(error)) handleScreenShareError(error);
|
||||
logger.warn('Failed to restart active screen share with audio enabled', error);
|
||||
return false;
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const activeCaptureOptions = canControlAudio
|
||||
? {...captureOptions, contentHint}
|
||||
: {contentHint, resolution: captureOptions.resolution};
|
||||
? {...captureOptions, contentHint: target.contentHint}
|
||||
: {contentHint: target.contentHint, resolution: captureOptions.resolution};
|
||||
if (
|
||||
shouldReconfigureAudioForActiveStreamSettings({
|
||||
platform: getElectronAPI()?.platform,
|
||||
@@ -318,12 +299,23 @@ export async function pushActiveStreamSettings(
|
||||
}
|
||||
}
|
||||
try {
|
||||
await MediaEngine.updateActiveScreenShareSettings(activeCaptureOptions, publishOptions);
|
||||
return await MediaEngine.updateActiveScreenShareSettings(activeCaptureOptions, publishOptions);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to push updated stream settings to the active share', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushActiveStreamSettings(
|
||||
shareContext: StreamSettingsShareContext,
|
||||
displayShareEnvironment: DisplayShareEnvironment,
|
||||
options: PushActiveStreamSettingsOptions = {},
|
||||
): Promise<void> {
|
||||
await scheduleConfiguredScreenShareMutation(() =>
|
||||
runActiveStreamSettingsPush(shareContext, displayShareEnvironment, options),
|
||||
);
|
||||
}
|
||||
|
||||
interface StreamSettingsMenuContentProps {
|
||||
applyToLiveStream?: boolean;
|
||||
shareContext?: StreamSettingsShareContext;
|
||||
@@ -350,18 +342,19 @@ export const StreamSettingsMenuContent = observer(
|
||||
const currentMode = VoiceSettings.getStreamingMode();
|
||||
const currentResolution = VoiceSettings.getScreenshareResolution();
|
||||
const currentFrameRate = resolveScreenShareFrameRate(VoiceSettings.getVideoFrameRate());
|
||||
const effectiveMode = normaliseStreamingModeForContext(currentMode, getScreenShareContext(shareContext));
|
||||
const effectiveResolution = normaliseResolutionForContext(
|
||||
currentResolution,
|
||||
getScreenShareContext(shareContext),
|
||||
hasHigherVideoQuality,
|
||||
);
|
||||
const effectiveQuality = resolveStreamingModeSettings(
|
||||
effectiveMode,
|
||||
effectiveResolution,
|
||||
currentFrameRate,
|
||||
hasHigherVideoQuality,
|
||||
);
|
||||
const quality: ScreenShareQualityInput = {
|
||||
mode: currentMode,
|
||||
storedResolution: currentResolution,
|
||||
storedFrameRate: currentFrameRate,
|
||||
entitled: hasHigherVideoQuality,
|
||||
context: shareContext,
|
||||
};
|
||||
const target: ScreenShareTarget = resolveScreenShareTarget({
|
||||
...quality,
|
||||
sourceDimensions: null,
|
||||
hintSetting: VoiceSettings.getScreenShareContentHint(),
|
||||
});
|
||||
const presetOverriddenByContext = selectStreamSettingsPresetOverriddenByContext(currentMode, shareContext);
|
||||
const captureAudioEnabled = isAppShare
|
||||
? VoiceSettings.getShareAppAudio()
|
||||
: isDeviceShare
|
||||
@@ -428,8 +421,8 @@ export const StreamSettingsMenuContent = observer(
|
||||
value: 'gaming',
|
||||
label: i18n._(GAMING_DESCRIPTOR),
|
||||
description: hasHigherVideoQuality
|
||||
? i18n._(FLUID_MOTION_AT_1440P_60_FPS_DESCRIPTOR)
|
||||
: i18n._(FLUID_MOTION_AT_720P_30_FPS_DESCRIPTOR),
|
||||
? i18n._(SMOOTH_60_FPS_AT_UP_TO_1440P_DESCRIPTOR)
|
||||
: i18n._(SMOOTH_30_FPS_AT_UP_TO_720P_DESCRIPTOR),
|
||||
isPremium: false,
|
||||
},
|
||||
];
|
||||
@@ -438,8 +431,8 @@ export const StreamSettingsMenuContent = observer(
|
||||
value: 'screenshare',
|
||||
label: i18n._(SCREENSHARE_DESCRIPTOR),
|
||||
description: hasHigherVideoQuality
|
||||
? i18n._(RAZOR_SHARP_TEXT_AT_NATIVE_SOURCE_15_FPS_DESCRIPTOR)
|
||||
: i18n._(SHARPER_TEXT_AT_720P_30_FPS_DESCRIPTOR),
|
||||
? i18n._(SHARP_TEXT_AT_UP_TO_4K_DESCRIPTOR)
|
||||
: i18n._(SHARP_TEXT_AT_720P_DESCRIPTOR),
|
||||
isPremium: false,
|
||||
});
|
||||
}
|
||||
@@ -451,32 +444,7 @@ export const StreamSettingsMenuContent = observer(
|
||||
});
|
||||
return modes;
|
||||
}, [isDeviceShare, hasHigherVideoQuality, i18n.locale]);
|
||||
const resolutionOptions: Array<Option<ScreenshareResolution>> = useMemo(() => {
|
||||
const options: Array<Option<ScreenshareResolution>> = [
|
||||
{value: 'low_480p', label: '480p', isPremium: false},
|
||||
{value: 'medium', label: '720p', isPremium: false},
|
||||
];
|
||||
if (hasHigherVideoQuality || showPremiumFeatures) {
|
||||
options.push(
|
||||
{value: 'high', label: '1080p', isPremium: true},
|
||||
{value: 'ultra', label: '1440p', isPremium: true},
|
||||
);
|
||||
}
|
||||
if (!isDeviceShare && (hasHigherVideoQuality || showPremiumFeatures)) {
|
||||
options.push({value: 'source', label: i18n._(SOURCE_DESCRIPTOR), isPremium: true});
|
||||
}
|
||||
return options;
|
||||
}, [hasHigherVideoQuality, isDeviceShare, showPremiumFeatures, i18n.locale]);
|
||||
const frameRateOptions: Array<Option<SupportedScreenShareFrameRate>> = useMemo(
|
||||
() => [
|
||||
{value: 15, label: i18n._(MESSAGE_15_FPS_DESCRIPTOR), isPremium: false},
|
||||
{value: 30, label: i18n._(MESSAGE_30_FPS_DESCRIPTOR), isPremium: false},
|
||||
...(hasHigherVideoQuality || showPremiumFeatures
|
||||
? [{value: 60 as const, label: i18n._(MESSAGE_60_FPS_DESCRIPTOR), isPremium: true}]
|
||||
: []),
|
||||
],
|
||||
[hasHigherVideoQuality, showPremiumFeatures, i18n.locale],
|
||||
);
|
||||
const qualityMenuState = selectStreamSettingsQualityMenuState({quality, target, showPremiumFeatures});
|
||||
const audioDeviceOptions = useMemo(() => {
|
||||
const real = inputDevices.filter((d) => d.deviceId && d.deviceId !== 'default');
|
||||
return real;
|
||||
@@ -485,10 +453,22 @@ export const StreamSettingsMenuContent = observer(
|
||||
(options?: PushActiveStreamSettingsOptions) => {
|
||||
if (!applyToLiveStream) return;
|
||||
void executeScreenShareOperation(() =>
|
||||
pushActiveStreamSettings(shareContext, displayShareEnvironment, hasHigherVideoQuality, options),
|
||||
pushActiveStreamSettings(shareContext, displayShareEnvironment, options),
|
||||
).catch(() => undefined);
|
||||
},
|
||||
[applyToLiveStream, displayShareEnvironment, hasHigherVideoQuality, shareContext],
|
||||
[applyToLiveStream, displayShareEnvironment, shareContext],
|
||||
);
|
||||
const applyQualityWrite = useCallback(
|
||||
(write: StreamSettingsQualityWrite) => {
|
||||
if (write.kind === 'premium') {
|
||||
PremiumModalCommands.open();
|
||||
return;
|
||||
}
|
||||
if (write.kind === 'none') return;
|
||||
VoiceSettingsCommands.update(write.patch);
|
||||
runApply();
|
||||
},
|
||||
[runApply],
|
||||
);
|
||||
const applyAudioSourceChange = useCallback(
|
||||
(nextWindowAudioScope?: WindowShareAudioScope) => {
|
||||
@@ -520,39 +500,11 @@ export const StreamSettingsMenuContent = observer(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (currentMode === option.value) return;
|
||||
if (target.mode === option.value) return;
|
||||
VoiceSettingsCommands.update({streamingMode: option.value});
|
||||
runApply();
|
||||
},
|
||||
[currentMode, hasHigherVideoQuality, runApply, showPremiumFeatures],
|
||||
);
|
||||
const handleResolutionSelect = useCallback(
|
||||
(option: Option<ScreenshareResolution>) => {
|
||||
if (option.isPremium && !hasHigherVideoQuality) {
|
||||
if (showPremiumFeatures) {
|
||||
PremiumModalCommands.open();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (currentResolution === option.value) return;
|
||||
VoiceSettingsCommands.update({screenshareResolution: option.value});
|
||||
runApply();
|
||||
},
|
||||
[currentResolution, hasHigherVideoQuality, runApply, showPremiumFeatures],
|
||||
);
|
||||
const handleFrameRateSelect = useCallback(
|
||||
(option: Option<SupportedScreenShareFrameRate>) => {
|
||||
if (option.isPremium && !hasHigherVideoQuality) {
|
||||
if (showPremiumFeatures) {
|
||||
PremiumModalCommands.open();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (currentFrameRate === option.value) return;
|
||||
VoiceSettingsCommands.update({videoFrameRate: option.value});
|
||||
runApply();
|
||||
},
|
||||
[currentFrameRate, hasHigherVideoQuality, runApply, showPremiumFeatures],
|
||||
[hasHigherVideoQuality, runApply, showPremiumFeatures, target.mode],
|
||||
);
|
||||
const handleCaptureAudioToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
@@ -592,36 +544,6 @@ export const StreamSettingsMenuContent = observer(
|
||||
? formatVoiceAudioDeviceLabel(i18n, selectedAudioDevice, i18n._(UNNAMED_INPUT_DESCRIPTOR))
|
||||
: i18n._(SYSTEM_DEFAULT_DESCRIPTOR);
|
||||
if (variant === 'compactLive') {
|
||||
const presetQuality =
|
||||
currentMode === 'custom'
|
||||
? null
|
||||
: resolveStreamingModeSettings(effectiveMode, currentResolution, currentFrameRate, true);
|
||||
const selectCompactResolution = (option: Option<ScreenshareResolution>) => {
|
||||
if (option.isPremium && !hasHigherVideoQuality) {
|
||||
if (showPremiumFeatures) PremiumModalCommands.open();
|
||||
return;
|
||||
}
|
||||
if (currentMode === 'custom' && effectiveQuality.resolution === option.value) return;
|
||||
VoiceSettingsCommands.update({
|
||||
streamingMode: 'custom',
|
||||
screenshareResolution: option.value,
|
||||
...(presetQuality ? {videoFrameRate: presetQuality.frameRate} : {}),
|
||||
});
|
||||
runApply();
|
||||
};
|
||||
const selectCompactFrameRate = (option: Option<SupportedScreenShareFrameRate>) => {
|
||||
if (option.isPremium && !hasHigherVideoQuality) {
|
||||
if (showPremiumFeatures) PremiumModalCommands.open();
|
||||
return;
|
||||
}
|
||||
if (currentMode === 'custom' && effectiveQuality.frameRate === option.value) return;
|
||||
VoiceSettingsCommands.update({
|
||||
streamingMode: 'custom',
|
||||
...(presetQuality ? {screenshareResolution: presetQuality.resolution} : {}),
|
||||
videoFrameRate: option.value,
|
||||
});
|
||||
runApply();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<MenuItemSubmenu
|
||||
@@ -635,13 +557,13 @@ export const StreamSettingsMenuContent = observer(
|
||||
>
|
||||
{i18n._(FRAMERATE_DESCRIPTOR)}
|
||||
</MenuGroupLabel>
|
||||
{frameRateOptions.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.isPremium;
|
||||
{qualityMenuState.frameRates.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.premium;
|
||||
return (
|
||||
<MenuItemRadio
|
||||
key={option.value}
|
||||
selected={effectiveQuality.frameRate === option.value}
|
||||
onSelect={() => selectCompactFrameRate(option)}
|
||||
selected={option.selected}
|
||||
onSelect={() => applyQualityWrite(option.write)}
|
||||
data-flx="voice.stream-settings-menu-content.compact-live.frame-rate-option"
|
||||
>
|
||||
<span
|
||||
@@ -652,7 +574,7 @@ export const StreamSettingsMenuContent = observer(
|
||||
className={styles.rowLabel}
|
||||
data-flx="voice.stream-settings-menu-content.compact-live.frame-rate-row-label"
|
||||
>
|
||||
{option.label}
|
||||
{i18n._(FPS_DESCRIPTOR, {frameRate: option.value})}
|
||||
</span>
|
||||
{isPlutoniumReserved && (
|
||||
<PremiumBadge data-flx="voice.stream-settings-menu-content.compact-live.frame-rate-premium-badge" />
|
||||
@@ -669,13 +591,13 @@ export const StreamSettingsMenuContent = observer(
|
||||
>
|
||||
{i18n._(RESOLUTION_DESCRIPTOR)}
|
||||
</MenuGroupLabel>
|
||||
{resolutionOptions.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.isPremium;
|
||||
{qualityMenuState.resolutions.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.premium;
|
||||
return (
|
||||
<MenuItemRadio
|
||||
key={option.value}
|
||||
selected={effectiveQuality.resolution === option.value}
|
||||
onSelect={() => selectCompactResolution(option)}
|
||||
selected={option.selected}
|
||||
onSelect={() => applyQualityWrite(option.write)}
|
||||
data-flx="voice.stream-settings-menu-content.compact-live.resolution-option"
|
||||
>
|
||||
<span
|
||||
@@ -686,7 +608,7 @@ export const StreamSettingsMenuContent = observer(
|
||||
className={styles.rowLabel}
|
||||
data-flx="voice.stream-settings-menu-content.compact-live.resolution-row-label"
|
||||
>
|
||||
{option.label}
|
||||
{getScreenShareResolutionLabel(i18n, option.value)}
|
||||
</span>
|
||||
{isPlutoniumReserved && (
|
||||
<PremiumBadge data-flx="voice.stream-settings-menu-content.compact-live.resolution-premium-badge" />
|
||||
@@ -728,7 +650,7 @@ export const StreamSettingsMenuContent = observer(
|
||||
return (
|
||||
<MenuItemRadio
|
||||
key={option.value}
|
||||
selected={currentMode === option.value}
|
||||
selected={target.mode === option.value}
|
||||
onSelect={() => handleModeSelect(option)}
|
||||
data-flx="voice.stream-settings-menu-content.menu-item-radio.mode-select"
|
||||
>
|
||||
@@ -748,6 +670,14 @@ export const StreamSettingsMenuContent = observer(
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
{presetOverriddenByContext && option.value === target.mode && (
|
||||
<span
|
||||
className={styles.modeDescription}
|
||||
data-flx="voice.stream-settings-menu-content.mode-context-note"
|
||||
>
|
||||
{i18n._(CAPTURE_DEVICES_USE_THE_GAMING_PRESET_DESCRIPTOR)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isPlutoniumReserved && <PremiumBadge data-flx="voice.stream-settings-menu-content.premium-badge" />}
|
||||
</span>
|
||||
@@ -761,18 +691,18 @@ export const StreamSettingsMenuContent = observer(
|
||||
label={i18n._(RESOLUTION_DESCRIPTOR)}
|
||||
render={() => (
|
||||
<MenuGroup data-flx="voice.stream-settings-menu-content.menu-group--3">
|
||||
{resolutionOptions.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.isPremium;
|
||||
{qualityMenuState.resolutions.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.premium;
|
||||
return (
|
||||
<MenuItemRadio
|
||||
key={option.value}
|
||||
selected={currentResolution === option.value}
|
||||
onSelect={() => handleResolutionSelect(option)}
|
||||
selected={option.selected}
|
||||
onSelect={() => applyQualityWrite(option.write)}
|
||||
data-flx="voice.stream-settings-menu-content.menu-item-radio.resolution-select"
|
||||
>
|
||||
<span className={styles.row} data-flx="voice.stream-settings-menu-content.row">
|
||||
<span className={styles.rowLabel} data-flx="voice.stream-settings-menu-content.row-label">
|
||||
{option.label}
|
||||
{getScreenShareResolutionLabel(i18n, option.value)}
|
||||
</span>
|
||||
{isPlutoniumReserved && (
|
||||
<PremiumBadge data-flx="voice.stream-settings-menu-content.premium-badge--2" />
|
||||
@@ -789,18 +719,18 @@ export const StreamSettingsMenuContent = observer(
|
||||
label={i18n._(FRAMERATE_DESCRIPTOR)}
|
||||
render={() => (
|
||||
<MenuGroup data-flx="voice.stream-settings-menu-content.menu-group--4">
|
||||
{frameRateOptions.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.isPremium;
|
||||
{qualityMenuState.frameRates.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.premium;
|
||||
return (
|
||||
<MenuItemRadio
|
||||
key={option.value}
|
||||
selected={currentFrameRate === option.value}
|
||||
onSelect={() => handleFrameRateSelect(option)}
|
||||
selected={option.selected}
|
||||
onSelect={() => applyQualityWrite(option.write)}
|
||||
data-flx="voice.stream-settings-menu-content.menu-item-radio.frame-rate-select"
|
||||
>
|
||||
<span className={styles.row} data-flx="voice.stream-settings-menu-content.row--2">
|
||||
<span className={styles.rowLabel} data-flx="voice.stream-settings-menu-content.row-label--2">
|
||||
{option.label}
|
||||
{i18n._(FPS_DESCRIPTOR, {frameRate: option.value})}
|
||||
</span>
|
||||
{isPlutoniumReserved && (
|
||||
<PremiumBadge data-flx="voice.stream-settings-menu-content.premium-badge--3" />
|
||||
@@ -919,7 +849,7 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps)
|
||||
data-flx="voice.stream-settings-menu-content.audio-group.audio-device-default"
|
||||
>
|
||||
<span className={styles.row} data-flx="voice.stream-settings-menu-content.audio-group.audio-device-row">
|
||||
<MicrophoneIcon
|
||||
<WaveformIcon
|
||||
className={styles.audioDeviceIcon}
|
||||
weight="fill"
|
||||
aria-hidden={true}
|
||||
@@ -933,13 +863,13 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps)
|
||||
className={styles.audioDeviceName}
|
||||
data-flx="voice.stream-settings-menu-content.audio-group.audio-device-name"
|
||||
>
|
||||
<Trans>Follow voice input</Trans>
|
||||
<Trans>Device audio only</Trans>
|
||||
</span>
|
||||
<span
|
||||
className={styles.audioDeviceSubtext}
|
||||
data-flx="voice.stream-settings-menu-content.audio-group.audio-device-subtext"
|
||||
>
|
||||
{selectedAudioDeviceLabel}
|
||||
<Trans>Silent when the capture device has no audio input of its own</Trans>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ScreenshareResolution, StreamingMode} from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
canRestartDisplayShareWithoutPreselectedSource,
|
||||
type DisplayShareEnvironment,
|
||||
prestartAudioToggleIsPickerOwned,
|
||||
} from '@app/features/voice/utils/ScreenShareEnvironment';
|
||||
import {
|
||||
normaliseResolutionForContext,
|
||||
normaliseStreamingModeForContext,
|
||||
resolveScreenShareQualityPick,
|
||||
type ScreenShareContext,
|
||||
type ScreenShareQualityInput,
|
||||
type ScreenShareQualityPatch,
|
||||
type ScreenShareQualityPick,
|
||||
type ScreenShareTarget,
|
||||
SUPPORTED_SCREEN_SHARE_FRAME_RATES,
|
||||
type SupportedScreenShareFrameRate,
|
||||
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {
|
||||
canSelectManualAudioSources,
|
||||
manualAudioSourcesGovernShare,
|
||||
@@ -15,8 +28,15 @@ import {
|
||||
type WindowShareAudioScope,
|
||||
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||
import type {NativeAudioAvailability} from '@app/types/electron.d';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {initialTransition, setup, transition} from 'xstate';
|
||||
|
||||
export const CAPTURE_DEVICES_USE_THE_GAMING_PRESET_DESCRIPTOR = msg({
|
||||
message: 'Capture devices use the Gaming preset.',
|
||||
comment:
|
||||
'Note shown beside the stored Screen share preset in the stream settings menu and the video settings tab of a capture device share, explaining why that preset is not used.',
|
||||
});
|
||||
|
||||
export type StreamSettingsAudioControlStateValue =
|
||||
| 'hidden'
|
||||
| 'unsupported'
|
||||
@@ -187,3 +207,123 @@ export function selectStreamSettingsAudioMenuState(
|
||||
showDeviceAudioMenu: signals.shareContext === 'device' && signals.captureAudioEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export type StreamSettingsQualityWrite =
|
||||
| {kind: 'none'}
|
||||
| {kind: 'premium'}
|
||||
| {kind: 'write'; patch: ScreenShareQualityPatch};
|
||||
|
||||
export interface StreamSettingsQualitySignals {
|
||||
quality: ScreenShareQualityInput;
|
||||
pick: ScreenShareQualityPick;
|
||||
premiumOption: boolean;
|
||||
showPremiumFeatures: boolean;
|
||||
}
|
||||
|
||||
export function selectStreamSettingsQualityWrite(signals: StreamSettingsQualitySignals): StreamSettingsQualityWrite {
|
||||
if (signals.premiumOption && !signals.quality.entitled) {
|
||||
return signals.showPremiumFeatures ? {kind: 'premium'} : {kind: 'none'};
|
||||
}
|
||||
const patch = resolveScreenShareQualityPick(signals.quality, signals.pick);
|
||||
return patch === null ? {kind: 'none'} : {kind: 'write', patch};
|
||||
}
|
||||
|
||||
export const OFFERED_SCREEN_SHARE_RESOLUTIONS = [
|
||||
'low_480p',
|
||||
'medium',
|
||||
'high',
|
||||
'ultra',
|
||||
'source',
|
||||
] as const satisfies ReadonlyArray<ScreenshareResolution>;
|
||||
|
||||
export type OfferedScreenShareResolution = (typeof OFFERED_SCREEN_SHARE_RESOLUTIONS)[number];
|
||||
|
||||
export function offeredScreenShareResolution(resolution: ScreenshareResolution): OfferedScreenShareResolution {
|
||||
return resolution === 'low_240p' ? 'low_480p' : resolution;
|
||||
}
|
||||
|
||||
const STREAM_SETTINGS_FREE_RESOLUTIONS: ReadonlyArray<OfferedScreenShareResolution> = ['low_480p', 'medium'];
|
||||
const STREAM_SETTINGS_PREMIUM_RESOLUTIONS: ReadonlyArray<OfferedScreenShareResolution> = ['high', 'ultra', 'source'];
|
||||
const STREAM_SETTINGS_FREE_FRAME_RATES: ReadonlyArray<SupportedScreenShareFrameRate> = [15, 30];
|
||||
const STREAM_SETTINGS_PREMIUM_FRAME_RATES: ReadonlyArray<SupportedScreenShareFrameRate> = [60];
|
||||
|
||||
export interface StreamSettingsQualityOption<T> {
|
||||
value: T;
|
||||
premium: boolean;
|
||||
selected: boolean;
|
||||
write: StreamSettingsQualityWrite;
|
||||
}
|
||||
|
||||
export interface StreamSettingsQualityMenuViewState {
|
||||
resolutions: Array<StreamSettingsQualityOption<OfferedScreenShareResolution>>;
|
||||
frameRates: Array<StreamSettingsQualityOption<SupportedScreenShareFrameRate>>;
|
||||
}
|
||||
|
||||
export interface StreamSettingsQualityMenuSignals {
|
||||
quality: ScreenShareQualityInput;
|
||||
target: ScreenShareTarget;
|
||||
showPremiumFeatures: boolean;
|
||||
}
|
||||
|
||||
export function contextAllowsScreenShareResolution(
|
||||
resolution: OfferedScreenShareResolution,
|
||||
context: ScreenShareContext,
|
||||
): boolean {
|
||||
return normaliseResolutionForContext(resolution, context, true) === resolution;
|
||||
}
|
||||
|
||||
export function selectStreamSettingsQualityMenuState(
|
||||
signals: StreamSettingsQualityMenuSignals,
|
||||
): StreamSettingsQualityMenuViewState {
|
||||
const offersPremium = signals.quality.entitled || signals.showPremiumFeatures;
|
||||
const selectedResolution = offeredScreenShareResolution(signals.target.resolution);
|
||||
const buildOption = <T>(
|
||||
value: T,
|
||||
premium: boolean,
|
||||
selected: boolean,
|
||||
pick: ScreenShareQualityPick,
|
||||
): StreamSettingsQualityOption<T> => ({
|
||||
value,
|
||||
premium,
|
||||
selected,
|
||||
write: selectStreamSettingsQualityWrite({
|
||||
quality: signals.quality,
|
||||
pick,
|
||||
premiumOption: premium,
|
||||
showPremiumFeatures: signals.showPremiumFeatures,
|
||||
}),
|
||||
});
|
||||
return {
|
||||
resolutions: OFFERED_SCREEN_SHARE_RESOLUTIONS.filter(
|
||||
(value) =>
|
||||
value === selectedResolution ||
|
||||
STREAM_SETTINGS_FREE_RESOLUTIONS.includes(value) ||
|
||||
(offersPremium &&
|
||||
STREAM_SETTINGS_PREMIUM_RESOLUTIONS.includes(value) &&
|
||||
contextAllowsScreenShareResolution(value, signals.quality.context)),
|
||||
).map((value) =>
|
||||
buildOption(value, STREAM_SETTINGS_PREMIUM_RESOLUTIONS.includes(value), value === selectedResolution, {
|
||||
axis: 'resolution',
|
||||
resolution: value,
|
||||
}),
|
||||
),
|
||||
frameRates: SUPPORTED_SCREEN_SHARE_FRAME_RATES.filter(
|
||||
(value) =>
|
||||
value === signals.target.frameRate ||
|
||||
STREAM_SETTINGS_FREE_FRAME_RATES.includes(value) ||
|
||||
(offersPremium && STREAM_SETTINGS_PREMIUM_FRAME_RATES.includes(value)),
|
||||
).map((value) =>
|
||||
buildOption(value, !STREAM_SETTINGS_FREE_FRAME_RATES.includes(value), value === signals.target.frameRate, {
|
||||
axis: 'frameRate',
|
||||
frameRate: value,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectStreamSettingsPresetOverriddenByContext(
|
||||
mode: StreamingMode,
|
||||
context: ScreenShareContext,
|
||||
): boolean {
|
||||
return normaliseStreamingModeForContext(mode, context) !== mode;
|
||||
}
|
||||
|
||||
@@ -220,12 +220,6 @@
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.streamUnderperformanceSlot {
|
||||
padding: 0 0.375rem;
|
||||
color: var(--status-warning);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.avatarFlexShrink {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -40,11 +40,10 @@ import {usePoppedOutTransition} from '@app/features/voice/components/popout/useP
|
||||
import {VoicePopoutScopeContext} from '@app/features/voice/components/popout/VoicePopoutScopeContext';
|
||||
import {ScreenShareBufferingFrame} from '@app/features/voice/components/ScreenShareBufferingFrame';
|
||||
import {registerStreamAudioPrefsTouch} from '@app/features/voice/components/StreamAudioPrefsTouchScheduler';
|
||||
import {StreamInfoPill} from '@app/features/voice/components/StreamInfoPill';
|
||||
import {StreamInfoPill, type StreamInfoPillQuality} from '@app/features/voice/components/StreamInfoPill';
|
||||
import {getStreamKey} from '@app/features/voice/components/StreamKeys';
|
||||
import {StreamSpectatorsPopout} from '@app/features/voice/components/StreamSpectatorsPopout';
|
||||
import {StreamWatchHoverCard} from '@app/features/voice/components/StreamWatchHoverCard';
|
||||
import {useScreenShareUnderperformance} from '@app/features/voice/components/useScreenShareUnderperformance';
|
||||
import {useScreenShareWatchFailure} from '@app/features/voice/components/useScreenShareWatchFailure';
|
||||
import {useStreamPreview} from '@app/features/voice/components/useStreamPreview';
|
||||
import {useStreamSpectators} from '@app/features/voice/components/useStreamSpectators';
|
||||
@@ -59,6 +58,7 @@ import {
|
||||
selectVoiceParticipantTileCameraActive,
|
||||
selectVoiceParticipantTileScreenShareState,
|
||||
shouldShowCameraBuffering,
|
||||
shouldShowTileControlPill,
|
||||
shouldShowTileStreamAudioControls,
|
||||
type VoiceParticipantTileScreenShareSignals,
|
||||
} from '@app/features/voice/components/VoiceParticipantTileStateMachine';
|
||||
@@ -83,7 +83,6 @@ import {
|
||||
CONNECTION_DESCRIPTOR,
|
||||
DESKTOP_DEVICE_DESCRIPTOR,
|
||||
getSourceDataAttr,
|
||||
getStreamUnderperformanceLabel,
|
||||
isCameraSource,
|
||||
logger,
|
||||
MOBILE_DEVICE_DESCRIPTOR,
|
||||
@@ -94,7 +93,6 @@ import {
|
||||
STREAM_BUFFERING_DESCRIPTOR,
|
||||
STREAM_ENDED_DESCRIPTOR,
|
||||
STREAM_HIDDEN_DESCRIPTOR,
|
||||
STREAM_NOT_KEEPING_UP_DESCRIPTOR,
|
||||
TILE_AVATAR_BASE,
|
||||
TILE_AVATAR_MEDIA_SIZE,
|
||||
TILE_AVATAR_STYLE,
|
||||
@@ -124,6 +122,7 @@ import {
|
||||
VoiceTrackSource,
|
||||
} from '@app/features/voice/engine/VoiceTrackSource';
|
||||
import {selectVoiceEngineV2AppEffectiveSelfMuteForVoiceStatePayload} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
|
||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import CallMediaPrefs from '@app/features/voice/state/CallMediaPrefs';
|
||||
import LocalVoiceState from '@app/features/voice/state/LocalVoiceState';
|
||||
import PopoutWindowManager, {getVoiceTilePopoutKey} from '@app/features/voice/state/PopoutWindowManager';
|
||||
@@ -163,7 +162,6 @@ import {
|
||||
PauseIcon,
|
||||
SpeakerSlashIcon,
|
||||
VideoCameraSlashIcon,
|
||||
WarningIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import type {Participant, RemoteTrackPublication, Track} from 'livekit-client';
|
||||
@@ -349,9 +347,6 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
||||
const streamKey = useMemo(() => getStreamKey(guildId, channelId, connectionId), [guildId, channelId, connectionId]);
|
||||
const {viewerIds, viewerUsers, spectatorEntries} = useStreamSpectators(isScreenShare ? streamKey : '', userId);
|
||||
const hasSpectatorDemand = viewerIds.length > 0;
|
||||
const streamUnderperformanceReason = useScreenShareUnderperformance(
|
||||
isOwnScreenShare && !isFocusedPlaceholderTile && viewerUsers.length > 0,
|
||||
);
|
||||
const isCameraTile = isCameraSource(trackRef.source);
|
||||
const cameraLocallyDisabled = callId !== '' && isCameraTile && CallMediaPrefs.isVideoDisabled(callId, identity);
|
||||
const screenSharePublicationMigrationVersion = ScreenSharePublicationMigration.version;
|
||||
@@ -544,11 +539,17 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
||||
const {previewUrl, isPreviewLoading} = useStreamPreview(previewEnabled, streamKey);
|
||||
const isStreamPlaceholder = isScreenShare && !isTrackReference(trackRef);
|
||||
const screenShareTrackSid = publication?.trackSid ?? null;
|
||||
const trackInfo = useStreamTrackInfo(isScreenShare && !isFocusPresentationTile ? trackRef : null, {
|
||||
nativeSource: isScreenShare ? VoiceTrackSource.ScreenShare : null,
|
||||
nativeTrackSid: screenShareTrackSid,
|
||||
participantIdentity: identity,
|
||||
});
|
||||
const committedTarget = isOwnScreenShare && ActiveScreenShareSource.encoding ? ActiveScreenShareSource.target : null;
|
||||
const capturedTrackInfo = useStreamTrackInfo(
|
||||
isScreenShare && !isOwnScreenShare && !isFocusPresentationTile ? trackRef : null,
|
||||
{
|
||||
nativeSource: isScreenShare ? VoiceTrackSource.ScreenShare : null,
|
||||
nativeTrackSid: screenShareTrackSid,
|
||||
participantIdentity: identity,
|
||||
},
|
||||
);
|
||||
const ownTargetInfo: StreamInfoPillQuality | null = committedTarget === null ? null : {target: committedTarget};
|
||||
const trackInfo = isOwnScreenShare ? ownTargetInfo : capturedTrackInfo;
|
||||
const isPublicationDesired = publication?.isDesired ?? publication?.isSubscribed ?? false;
|
||||
useScreenShareWatchFailure({
|
||||
enabled:
|
||||
@@ -659,12 +660,13 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
||||
presentation,
|
||||
});
|
||||
const showTileSpectatorPill = !isFocusPresentationTile && isScreenShare && viewerUsers.length > 0;
|
||||
const showTileControlPill =
|
||||
!isFocusedPlaceholderTile &&
|
||||
(showStreamAudioControls ||
|
||||
showTileSpectatorPill ||
|
||||
(isGridTile && groupHiddenCount > 0) ||
|
||||
showDeviceCollapseControl);
|
||||
const showTileControlPill = shouldShowTileControlPill({
|
||||
isFocusedPlaceholderTile,
|
||||
showStreamAudioControls,
|
||||
showSpectatorPill: showTileSpectatorPill,
|
||||
showGroupHiddenPill: isGridTile && groupHiddenCount > 0,
|
||||
showDeviceCollapseControl,
|
||||
});
|
||||
const viewerStreamCount = graphViewerStreamKeys.length;
|
||||
const addStreamTooltipText = plural(
|
||||
{count: viewerStreamCount},
|
||||
@@ -1326,26 +1328,6 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isOwnScreenShare && viewerUsers.length > 0 && streamUnderperformanceReason && (
|
||||
<Tooltip
|
||||
text={getStreamUnderperformanceLabel(i18n, streamUnderperformanceReason)}
|
||||
position="top"
|
||||
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tooltip"
|
||||
>
|
||||
<div
|
||||
className={clsx(voiceCallStyles.tileControlPillSlot, styles.streamUnderperformanceSlot)}
|
||||
role="img"
|
||||
aria-label={i18n._(STREAM_NOT_KEEPING_UP_DESCRIPTOR)}
|
||||
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-underperformance-slot"
|
||||
>
|
||||
<WarningIcon
|
||||
weight="fill"
|
||||
className={styles.tilePillIcon}
|
||||
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-pill-icon"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showTileSpectatorPill && (
|
||||
<StreamSpectatorsPopout
|
||||
viewerUsers={viewerUsers}
|
||||
|
||||
@@ -41,6 +41,14 @@ export interface VoiceParticipantTileStreamAudioSignals {
|
||||
presentation: VoiceParticipantTilePresentation;
|
||||
}
|
||||
|
||||
export interface VoiceParticipantTileControlPillSignals {
|
||||
isFocusedPlaceholderTile: boolean;
|
||||
showStreamAudioControls: boolean;
|
||||
showSpectatorPill: boolean;
|
||||
showGroupHiddenPill: boolean;
|
||||
showDeviceCollapseControl: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceParticipantTileCameraActiveSignals {
|
||||
isCameraTile: boolean;
|
||||
isOwnContent: boolean;
|
||||
@@ -164,6 +172,16 @@ export function shouldShowTileStreamAudioControls(signals: VoiceParticipantTileS
|
||||
return signals.presentation === 'grid' || signals.presentation === 'focus-main';
|
||||
}
|
||||
|
||||
export function shouldShowTileControlPill(signals: VoiceParticipantTileControlPillSignals): boolean {
|
||||
if (signals.isFocusedPlaceholderTile) return false;
|
||||
return (
|
||||
signals.showStreamAudioControls ||
|
||||
signals.showSpectatorPill ||
|
||||
signals.showGroupHiddenPill ||
|
||||
signals.showDeviceCollapseControl
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowCameraBuffering(signals: VoiceParticipantTileCameraBufferingSignals): boolean {
|
||||
if (signals.isScreenShare) return false;
|
||||
if (signals.isFocusedPlaceholderTile) return false;
|
||||
|
||||
@@ -62,7 +62,10 @@ import {
|
||||
StreamSettingsMenuContent,
|
||||
useHasHigherVideoQuality,
|
||||
} from '@app/features/voice/components/StreamSettingsMenuContent';
|
||||
import {selectStreamSettingsAudioMenuState} from '@app/features/voice/components/StreamSettingsMenuContentStateMachine';
|
||||
import {
|
||||
offeredScreenShareResolution,
|
||||
selectStreamSettingsAudioMenuState,
|
||||
} from '@app/features/voice/components/StreamSettingsMenuContentStateMachine';
|
||||
import MediaEngine, {useVoiceEngineV2Model} from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
|
||||
import {selectVoiceEngineV2AppConnection} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
|
||||
@@ -72,23 +75,21 @@ import VoiceSettings, {
|
||||
type LastScreenShareSource,
|
||||
type LastScreenShareSourceKind,
|
||||
type ScreenshareResolution,
|
||||
type StreamingMode,
|
||||
} from '@app/features/voice/state/VoiceSettings';
|
||||
import {filterRoutableLinuxAudioSources} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
||||
import {getNativeAudioAvailabilityCached} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
||||
import {isScreenShareAudioCaptureError} from '@app/features/voice/utils/ScreenShareAudioCaptureError';
|
||||
import {formatScreenShareAudioSummary} from '@app/features/voice/utils/ScreenShareAudioSummary';
|
||||
import {
|
||||
formatScreenShareAudioSummary,
|
||||
resolveDeviceShareAudioPairing,
|
||||
} from '@app/features/voice/utils/ScreenShareAudioSummary';
|
||||
import {
|
||||
getDisplayShareEnvironment,
|
||||
shouldShowDesktopDownloadCta,
|
||||
supportsDeviceScreenShare,
|
||||
usesNativeDisplaySharePicker,
|
||||
} from '@app/features/voice/utils/ScreenShareEnvironment';
|
||||
import {
|
||||
normaliseResolutionForContext,
|
||||
normaliseStreamingModeForContext,
|
||||
resolveStreamingModeSettings,
|
||||
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {resolveScreenShareTarget} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {
|
||||
startConfiguredDeviceScreenShare,
|
||||
startConfiguredDisplayScreenShare,
|
||||
@@ -602,38 +603,12 @@ function useScreenSharePickerTabState(initialTab: ScreenSharePickerTab | undefin
|
||||
};
|
||||
}
|
||||
|
||||
interface EffectiveStreamSummary {
|
||||
mode: StreamingMode;
|
||||
resolution: ScreenshareResolution;
|
||||
frameRate: number;
|
||||
}
|
||||
|
||||
function resolveEffectiveStreamSummary(
|
||||
activeTab: ScreenSharePickerTab,
|
||||
hasHigherVideoQuality: boolean,
|
||||
): EffectiveStreamSummary {
|
||||
const context = activeTab === 'devices' ? 'device' : 'display';
|
||||
const mode = normaliseStreamingModeForContext(VoiceSettings.getStreamingMode(), context);
|
||||
const resolution = normaliseResolutionForContext(
|
||||
VoiceSettings.getScreenshareResolution(),
|
||||
context,
|
||||
hasHigherVideoQuality,
|
||||
);
|
||||
const effective = resolveStreamingModeSettings(
|
||||
mode,
|
||||
resolution,
|
||||
VoiceSettings.getVideoFrameRate(),
|
||||
hasHigherVideoQuality,
|
||||
);
|
||||
return {mode, resolution: effective.resolution, frameRate: effective.frameRate};
|
||||
}
|
||||
|
||||
function getStreamSummaryResolutionLabel(resolution: ScreenshareResolution, sourceLabel: string): string {
|
||||
if (resolution === 'low_240p') return '240p';
|
||||
if (resolution === 'low_480p') return '480p';
|
||||
if (resolution === 'medium') return '720p';
|
||||
if (resolution === 'high') return '1080p';
|
||||
if (resolution === 'ultra') return '1440p';
|
||||
const offered = offeredScreenShareResolution(resolution);
|
||||
if (offered === 'low_480p') return '480p';
|
||||
if (offered === 'medium') return '720p';
|
||||
if (offered === 'high') return '1080p';
|
||||
if (offered === 'ultra') return '1440p';
|
||||
return sourceLabel;
|
||||
}
|
||||
|
||||
@@ -1444,7 +1419,16 @@ const ScreenSharePickerModalLoadedContent = observer(
|
||||
const emptyStateCopy = activeTab === 'devices' ? deviceEmptyStateCopy : desktopEmptyStateCopy;
|
||||
const emptyStateIcon =
|
||||
activeTab === 'apps' ? AppWindowIcon : activeTab === 'displays' ? MonitorIcon : VideoCameraIcon;
|
||||
const streamSummary = resolveEffectiveStreamSummary(activeTab, hasHigherVideoQuality);
|
||||
const shareContext = activeTab === 'devices' ? 'device' : activeTab === 'apps' ? 'app' : 'display';
|
||||
const streamSummary = resolveScreenShareTarget({
|
||||
mode: VoiceSettings.getStreamingMode(),
|
||||
storedResolution: VoiceSettings.getScreenshareResolution(),
|
||||
storedFrameRate: VoiceSettings.getVideoFrameRate(),
|
||||
entitled: hasHigherVideoQuality,
|
||||
context: shareContext,
|
||||
sourceDimensions: null,
|
||||
hintSetting: VoiceSettings.getScreenShareContentHint(),
|
||||
});
|
||||
const streamSummaryTitle =
|
||||
streamSummary.mode === 'gaming'
|
||||
? i18n._(GAMING_DESCRIPTOR)
|
||||
@@ -1457,7 +1441,6 @@ const ScreenSharePickerModalLoadedContent = observer(
|
||||
: streamSummary.mode === 'screenshare'
|
||||
? i18n._(CLEARER_TEXT_DESCRIPTOR)
|
||||
: null;
|
||||
const shareContext = activeTab === 'devices' ? 'device' : activeTab === 'apps' ? 'app' : 'display';
|
||||
const configuredCaptureAudioEnabled =
|
||||
activeTab === 'devices'
|
||||
? VoiceSettings.getShareDeviceAudio()
|
||||
@@ -1496,6 +1479,11 @@ const ScreenSharePickerModalLoadedContent = observer(
|
||||
includeSources: audioIncludeSources,
|
||||
shareContext,
|
||||
microphoneLabel,
|
||||
chosenAudioDeviceId: VoiceSettings.getScreenShareAudioDeviceId(),
|
||||
deviceAudioPairing: resolveDeviceShareAudioPairing(
|
||||
[...videoDevices, ...inputDevices],
|
||||
selectedDeviceId ?? '',
|
||||
),
|
||||
displayShareEnvironment,
|
||||
windowAudioScope,
|
||||
usesDeviceMicrophone: VoiceSettings.getScreenShareDeviceAudioUsesMicrophone(),
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {
|
||||
getAppliedScreenShareFrameRate,
|
||||
getDeliberateScreenShareQualityChangeAt,
|
||||
parseScreenShareQualityLimitationReason,
|
||||
SCREEN_SHARE_UNDERPERFORMANCE_SAMPLE_INTERVAL_MS,
|
||||
type ScreenShareUnderperformanceReason,
|
||||
ScreenShareUnderperformanceTracker,
|
||||
} from '@app/features/voice/engine/ScreenShareUnderperformance';
|
||||
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
|
||||
import {classifyVoiceEngineV2TrackStats, type VoiceEngineV2PerTrackStats} from '@fluxer/voice_engine_v2';
|
||||
import type {Track} from 'livekit-client';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const CAMERA_SOURCE = VoiceTrackSource.Camera as Track.Source;
|
||||
const SCREEN_SHARE_SOURCE = VoiceTrackSource.ScreenShare as Track.Source;
|
||||
|
||||
interface LocalScreenShareTrackStats {
|
||||
trackId: string;
|
||||
stats: VoiceEngineV2PerTrackStats;
|
||||
snapshot: ReadonlyArray<VoiceEngineV2PerTrackStats>;
|
||||
}
|
||||
|
||||
function selectLocalScreenShareTrackStats(): LocalScreenShareTrackStats | null {
|
||||
const perTrackStats = MediaEngine.perTrackStats;
|
||||
if (perTrackStats.length === 0) return null;
|
||||
const localParticipant = MediaEngine.room?.localParticipant;
|
||||
const localScreenShareTrackId =
|
||||
localParticipant?.getTrackPublication(SCREEN_SHARE_SOURCE)?.videoTrack?.mediaStreamTrack?.id ?? null;
|
||||
if (!localScreenShareTrackId) return null;
|
||||
const classification = classifyVoiceEngineV2TrackStats({
|
||||
tracks: perTrackStats.map((track) => ({
|
||||
direction: track.direction,
|
||||
kind: track.kind,
|
||||
rid: track.rid,
|
||||
trackIdentifier: track.trackIdentifier,
|
||||
bitrateKbps: track.bitrateKbps,
|
||||
})),
|
||||
publications: {
|
||||
localCameraTrackId:
|
||||
localParticipant?.getTrackPublication(CAMERA_SOURCE)?.videoTrack?.mediaStreamTrack?.id ?? null,
|
||||
localMicrophoneTrackId: null,
|
||||
localScreenShareTrackId,
|
||||
localScreenShareAudioTrackId: null,
|
||||
remoteMicrophoneTrackIds: [],
|
||||
remoteScreenShareTrackIds: [],
|
||||
remoteScreenShareAudioTrackIds: [],
|
||||
},
|
||||
});
|
||||
const index = classification.localScreenShareTrackIndex;
|
||||
if (index == null) return null;
|
||||
const stats = perTrackStats[index];
|
||||
if (!stats) return null;
|
||||
return {trackId: localScreenShareTrackId, stats, snapshot: perTrackStats};
|
||||
}
|
||||
|
||||
function readEncodeFrameRate(stats: VoiceEngineV2PerTrackStats): number {
|
||||
const framesPerSecond = stats.framesPerSecond;
|
||||
return typeof framesPerSecond === 'number' && Number.isFinite(framesPerSecond) ? framesPerSecond : 0;
|
||||
}
|
||||
|
||||
export function useScreenShareUnderperformance(enabled: boolean): ScreenShareUnderperformanceReason | null {
|
||||
const [reason, setReason] = useState<ScreenShareUnderperformanceReason | null>(null);
|
||||
useEffect(() => {
|
||||
setReason(null);
|
||||
if (!enabled) return;
|
||||
const tracker = new ScreenShareUnderperformanceTracker();
|
||||
let lastSnapshot: ReadonlyArray<VoiceEngineV2PerTrackStats> | null = null;
|
||||
const sample = () => {
|
||||
const selected = selectLocalScreenShareTrackStats();
|
||||
if (!selected) {
|
||||
lastSnapshot = null;
|
||||
tracker.reset();
|
||||
setReason(null);
|
||||
return;
|
||||
}
|
||||
if (selected.snapshot === lastSnapshot) return;
|
||||
lastSnapshot = selected.snapshot;
|
||||
const requestedFrameRate = getAppliedScreenShareFrameRate(selected.trackId);
|
||||
if (requestedFrameRate === null) {
|
||||
setReason(null);
|
||||
return;
|
||||
}
|
||||
setReason(
|
||||
tracker.observe({
|
||||
sample: {
|
||||
encodeFrameRate: readEncodeFrameRate(selected.stats),
|
||||
limitationReason: parseScreenShareQualityLimitationReason(selected.stats.qualityLimitationReason),
|
||||
},
|
||||
requestedFrameRate,
|
||||
trackId: selected.trackId,
|
||||
qualityChangeAt: getDeliberateScreenShareQualityChangeAt(),
|
||||
now: Date.now(),
|
||||
}),
|
||||
);
|
||||
};
|
||||
sample();
|
||||
const timer = setInterval(sample, SCREEN_SHARE_UNDERPERFORMANCE_SAMPLE_INTERVAL_MS);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
tracker.reset();
|
||||
};
|
||||
}, [enabled]);
|
||||
return reason;
|
||||
}
|
||||
@@ -200,9 +200,7 @@ export function useStatsForNerds({enabled = true}: UseStatsForNerdsOptions = {})
|
||||
codecPreferenceOrder: [...getScreenShareCodecPreferenceOrder()],
|
||||
contentHint: VoiceSettings.getScreenShareContentHint(),
|
||||
encoderMode: VoiceSettings.getScreenShareEncoderMode(),
|
||||
softwareQuality: VoiceSettings.getScreenShareSoftwareQuality(),
|
||||
scalabilityMode: VoiceSettings.getScreenShareScalabilityMode(),
|
||||
backupCodecMode: VoiceSettings.getScreenShareBackupCodecMode(),
|
||||
maxBitrateMbps:
|
||||
(getPublishedScreenShareMaxBitrateBps(localParticipant) ??
|
||||
getScreenShareBitrateBps(effectiveScreenShareSettings.resolution, effectiveScreenShareSettings.frameRate)) /
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import type {ScreenShareUnderperformanceReason} from '@app/features/voice/engine/ScreenShareUnderperformance';
|
||||
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
|
||||
import {MEDIA_PROXY_AVATAR_SIZE_PROFILE} from '@fluxer/constants/src/MediaProxyAssetSizes';
|
||||
import type {MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSizes';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import type {TrackReferenceOrPlaceholder} from '@livekit/components-react';
|
||||
import type React from 'react';
|
||||
@@ -77,24 +75,6 @@ export const WATCHING_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Spectator count badge on a screen-share tile. {length} is the integer number of viewers. Consider pluralization on review.',
|
||||
});
|
||||
export const STREAM_NOT_KEEPING_UP_DESCRIPTOR = msg({
|
||||
message: 'Stream is not keeping up',
|
||||
comment:
|
||||
'Accessible label for a passive badge on your own screen-share tile, shown when the encoder stays below the frame rate you asked for.',
|
||||
});
|
||||
export const STREAM_LIMITED_BY_CPU_DESCRIPTOR = msg({
|
||||
message: 'Your CPU cannot keep up, so viewers are getting fewer frames than you asked for.',
|
||||
comment: 'Tooltip on the passive badge on your own screen-share tile when the encoder is limited by CPU.',
|
||||
});
|
||||
export const STREAM_LIMITED_BY_BANDWIDTH_DESCRIPTOR = msg({
|
||||
message: 'Your network cannot keep up, so viewers are getting fewer frames than you asked for.',
|
||||
comment: 'Tooltip on the passive badge on your own screen-share tile when the encoder is limited by bandwidth.',
|
||||
});
|
||||
export const STREAM_LIMITED_DESCRIPTOR = msg({
|
||||
message: 'Something is limiting this stream, so viewers are getting fewer frames than you asked for.',
|
||||
comment:
|
||||
'Fallback tooltip on the passive badge on your own screen-share tile when the encoder reports no specific limitation.',
|
||||
});
|
||||
export const MUTED_DESCRIPTOR = msg({
|
||||
message: 'Muted',
|
||||
context: 'microphone-state',
|
||||
@@ -147,12 +127,6 @@ export interface VoiceParticipantTileInnerProps {
|
||||
showParticipantMetadata: boolean;
|
||||
}
|
||||
|
||||
export function getStreamUnderperformanceLabel(i18n: I18n, reason: ScreenShareUnderperformanceReason): string {
|
||||
if (reason === 'cpu') return i18n._(STREAM_LIMITED_BY_CPU_DESCRIPTOR);
|
||||
if (reason === 'bandwidth') return i18n._(STREAM_LIMITED_BY_BANDWIDTH_DESCRIPTOR);
|
||||
return i18n._(STREAM_LIMITED_DESCRIPTOR);
|
||||
}
|
||||
|
||||
export function isCameraSource(source: unknown) {
|
||||
return source === VoiceTrackSource.Camera;
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ import type {VoiceEngineV2AppScreenShareControllerGateway} from '@app/features/v
|
||||
import voiceEngineV2AppScreenShareExecutionAdapter, {
|
||||
type DeviceScreenShareCaptureOptions,
|
||||
type ScreenShareReconnectSnapshot,
|
||||
shouldRestoreScreenShareAfterReconnect,
|
||||
} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter';
|
||||
import {selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
|
||||
import {VoiceEngineV2AppSourceLifecycleBridge} from '@app/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge';
|
||||
@@ -804,10 +805,7 @@ class MediaEngineFacade extends Store {
|
||||
room: Room | null;
|
||||
restoreCamera: boolean;
|
||||
restoreStream: boolean;
|
||||
restoreStreamFromSnapshot: (() => Promise<boolean>) | null;
|
||||
streamSnapshotFallback: boolean;
|
||||
streamPlaySound: boolean;
|
||||
settleStreamStateWhenNotRestored: boolean;
|
||||
screenShareSnapshot: ScreenShareReconnectSnapshot | null;
|
||||
toastWhenStreamNotRestored: boolean;
|
||||
}): Promise<void> {
|
||||
assert.equal(typeof args.restoreCamera, 'boolean', 'restoreLocalMedia requires a camera flag');
|
||||
@@ -822,30 +820,20 @@ class MediaEngineFacade extends Store {
|
||||
logger.warn('Failed to restore camera during local media restore', {reason: args.reason, error});
|
||||
}
|
||||
}
|
||||
if (!args.restoreStream) return;
|
||||
if (!shouldRestoreScreenShareAfterReconnect(args.restoreStream, args.screenShareSnapshot)) return;
|
||||
let restored = false;
|
||||
if (args.restoreStreamFromSnapshot) {
|
||||
restored = await args.restoreStreamFromSnapshot();
|
||||
if (!restored && !args.streamSnapshotFallback) {
|
||||
voiceEngineV2AppMediaStateAdapter.applyScreenShareState(false, {sendUpdate: false});
|
||||
return;
|
||||
}
|
||||
}
|
||||
let streamRestoreFailed = false;
|
||||
if (!restored) {
|
||||
try {
|
||||
await voiceEngineV2AppScreenShareExecutionAdapter.setScreenShareEnabled(args.room, true, {
|
||||
sendUpdate: false,
|
||||
...(args.streamPlaySound ? {} : {playSound: false}),
|
||||
});
|
||||
restored = LocalVoiceState.getSelfStream();
|
||||
} catch (error) {
|
||||
streamRestoreFailed = true;
|
||||
logger.warn('Failed to restore screen share during local media restore', {reason: args.reason, error});
|
||||
}
|
||||
try {
|
||||
restored = await voiceEngineV2AppScreenShareExecutionAdapter.restoreScreenShareAfterReconnect(
|
||||
args.room,
|
||||
args.screenShareSnapshot,
|
||||
);
|
||||
} catch (error) {
|
||||
streamRestoreFailed = true;
|
||||
logger.warn('Failed to restore screen share during local media restore', {reason: args.reason, error});
|
||||
}
|
||||
if (restored) return;
|
||||
if (streamRestoreFailed || args.settleStreamStateWhenNotRestored) {
|
||||
if (streamRestoreFailed || args.screenShareSnapshot !== null) {
|
||||
voiceEngineV2AppMediaStateAdapter.applyScreenShareState(false, {sendUpdate: false});
|
||||
}
|
||||
if (args.toastWhenStreamNotRestored && this.i18n) {
|
||||
@@ -2105,6 +2093,7 @@ class MediaEngineFacade extends Store {
|
||||
playSound?: boolean;
|
||||
restartIfEnabled?: boolean;
|
||||
preserveStreamAudioPreferences?: boolean;
|
||||
keepShareTracking?: boolean;
|
||||
},
|
||||
publishOptions?: TrackPublishOptions,
|
||||
): Promise<void> {
|
||||
@@ -2663,20 +2652,12 @@ class MediaEngineFacade extends Store {
|
||||
if (!participant) return;
|
||||
const pendingScreenShareReconnect = this.pendingScreenShareReconnect;
|
||||
this.transitionFacadeState({type: 'screenShareReconnect.consume'});
|
||||
const shouldRestoreScreenShare =
|
||||
(LocalVoiceState.getSelfStream() || pendingScreenShareReconnect !== null) && !participant.isScreenShareEnabled;
|
||||
await this.restoreLocalMedia({
|
||||
reason: 'voice room connected',
|
||||
room,
|
||||
restoreCamera: LocalVoiceState.getSelfVideo() && !participant.isCameraEnabled,
|
||||
restoreStream: shouldRestoreScreenShare,
|
||||
restoreStreamFromSnapshot: pendingScreenShareReconnect
|
||||
? () =>
|
||||
voiceEngineV2AppScreenShareExecutionAdapter.restoreScreenShareReconnect(room, pendingScreenShareReconnect)
|
||||
: null,
|
||||
streamSnapshotFallback: false,
|
||||
streamPlaySound: true,
|
||||
settleStreamStateWhenNotRestored: false,
|
||||
restoreStream: LocalVoiceState.getSelfStream() && !participant.isScreenShareEnabled,
|
||||
screenShareSnapshot: pendingScreenShareReconnect,
|
||||
toastWhenStreamNotRestored: false,
|
||||
});
|
||||
}
|
||||
@@ -2692,10 +2673,7 @@ class MediaEngineFacade extends Store {
|
||||
room: this.room,
|
||||
restoreCamera: pendingRestore.restoreVideo,
|
||||
restoreStream: pendingRestore.restoreStream,
|
||||
restoreStreamFromSnapshot: null,
|
||||
streamSnapshotFallback: false,
|
||||
streamPlaySound: true,
|
||||
settleStreamStateWhenNotRestored: false,
|
||||
screenShareSnapshot: null,
|
||||
toastWhenStreamNotRestored: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getDesktopTroubleshootingSettings} from '@app/features/devtools/utils/DesktopTroubleshootingUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {isFirefoxBrowser} from '@app/features/ui/utils/NativeUtils';
|
||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
type CodecCapabilityReport,
|
||||
buildScreenShareCodecProfile,
|
||||
type CodecPreference,
|
||||
type CodecSupportInfo,
|
||||
getCodecCapabilityReport,
|
||||
isVideoCodecAllowedForPublish,
|
||||
resolveEffectiveScreenShareEncoderMode,
|
||||
type ScreenShareEncoderMode,
|
||||
selectNativeScreenCaptureScreenShareCodec,
|
||||
selectOptimalScreenShareCodec,
|
||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
@@ -18,7 +14,23 @@ import {loadGpuEncoderReport} from '@app/features/voice/utils/GpuEncoderCapabili
|
||||
import {loadNativeHardwareEncoderCapabilities} from '@app/features/voice/utils/NativeHardwareEncoderCapabilities';
|
||||
import {loadOpenH264Status} from '@app/features/voice/utils/OpenH264Status';
|
||||
import {
|
||||
clearScreenShareDecodeFailures,
|
||||
buildScreenShareCodecAdvertisements,
|
||||
CODEC_PREFERENCE,
|
||||
type CodecNegotiationSelection,
|
||||
computeNegotiatedVideoCodec,
|
||||
countUnknownScreenShareParticipants,
|
||||
type FluxerCodecAdvertisement,
|
||||
type FluxerCodecName,
|
||||
type FluxerCodecType,
|
||||
type FluxerVideoCodecName,
|
||||
getDecodeSet,
|
||||
getEncodeSet,
|
||||
type NegotiationReason,
|
||||
rankScreenShareCodecs,
|
||||
SCREEN_SHARE_CODEC_ADVERTISEMENT_GRACE_MS,
|
||||
VIDEO_CODEC_NAMES,
|
||||
} from '@app/features/voice/utils/ScreenShareCodecSelection';
|
||||
import {
|
||||
getScreenShareDecodeFailures,
|
||||
getVideoDecoderExclusionsSync,
|
||||
loadVideoDecoderExclusions,
|
||||
@@ -40,60 +52,6 @@ const EXPERIMENTS_MAX = 16;
|
||||
const EXPERIMENT_NAME_CHARS_MAX = 128;
|
||||
const RTP_PAYLOAD_TYPE_MAX = 255;
|
||||
const CODEC_PRIORITY_MAX = 65_535;
|
||||
const CODEC_PREFERENCE: ReadonlyArray<VideoCodec> = ['av1', 'h265', 'h264', 'vp9', 'vp8'];
|
||||
const SOFTWARE_CODEC_PREFERENCE: ReadonlyArray<VideoCodec> = ['av1', 'vp9', 'h264', 'vp8', 'h265'];
|
||||
const GECKO_SOFTWARE_CODEC_PREFERENCE: ReadonlyArray<VideoCodec> = ['vp8', 'h264'];
|
||||
const COMPATIBILITY_FALLBACK_CODEC_PREFERENCE: ReadonlyArray<VideoCodec> = ['h264', 'vp9', 'vp8'];
|
||||
const BASELINE_VIDEO_CODEC: VideoCodec = 'vp8';
|
||||
const VIDEO_CODEC_NAMES: Record<VideoCodec, FluxerVideoCodecName> = {
|
||||
av1: 'AV1',
|
||||
h265: 'H265',
|
||||
h264: 'H264',
|
||||
vp9: 'VP9',
|
||||
vp8: 'VP8',
|
||||
};
|
||||
const NAME_TO_VIDEO_CODEC: Record<FluxerVideoCodecName, VideoCodec> = {
|
||||
AV1: 'av1',
|
||||
H265: 'h265',
|
||||
H264: 'h264',
|
||||
VP9: 'vp9',
|
||||
VP8: 'vp8',
|
||||
};
|
||||
const VIDEO_CODEC_PROTOCOL_TABLE: Record<
|
||||
VideoCodec,
|
||||
{
|
||||
payloadType: number;
|
||||
rtxPayloadType: number;
|
||||
priority: number;
|
||||
}
|
||||
> = {
|
||||
av1: {payloadType: 101, rtxPayloadType: 102, priority: 5000},
|
||||
h265: {payloadType: 105, rtxPayloadType: 106, priority: 4000},
|
||||
h264: {payloadType: 103, rtxPayloadType: 104, priority: 3000},
|
||||
vp9: {payloadType: 109, rtxPayloadType: 110, priority: 2000},
|
||||
vp8: {payloadType: 107, rtxPayloadType: 108, priority: 1000},
|
||||
};
|
||||
|
||||
type FluxerVideoCodecName = 'AV1' | 'H265' | 'H264' | 'VP9' | 'VP8';
|
||||
type FluxerCodecName = 'opus' | FluxerVideoCodecName;
|
||||
type FluxerCodecType = 'audio' | 'video';
|
||||
export type NegotiationReason =
|
||||
| 'connected'
|
||||
| 'data'
|
||||
| 'participant-connected'
|
||||
| 'participant-disconnected'
|
||||
| 'reconnected'
|
||||
| 'manual';
|
||||
|
||||
export interface FluxerCodecAdvertisement {
|
||||
name: FluxerCodecName;
|
||||
type: FluxerCodecType;
|
||||
payload_type: number;
|
||||
rtx_payload_type?: number;
|
||||
priority: number;
|
||||
encode?: boolean;
|
||||
decode?: boolean;
|
||||
}
|
||||
|
||||
export interface FluxerSelectProtocolMessage {
|
||||
op: typeof SELECT_PROTOCOL_OP;
|
||||
@@ -120,13 +78,6 @@ export interface FluxerSessionUpdateMessage {
|
||||
|
||||
export type FluxerCodecNegotiationMessage = FluxerSelectProtocolMessage | FluxerSessionUpdateMessage;
|
||||
|
||||
interface CodecNegotiationSelection {
|
||||
codec: VideoCodec;
|
||||
reason: NegotiationReason;
|
||||
candidates: Array<VideoCodec>;
|
||||
unknownParticipants: number;
|
||||
}
|
||||
|
||||
interface ScreenShareCodecNegotiationMachineContext {
|
||||
selectedCodec: VideoCodec | null;
|
||||
selection: CodecNegotiationSelection | null;
|
||||
@@ -187,149 +138,15 @@ function getLocalDecodeCapabilities(): Record<VideoCodec, boolean> {
|
||||
export function getScreenShareCodecPreferenceOrder(
|
||||
preference: CodecPreference = VoiceSettings.getPreferredScreenShareCodec(),
|
||||
): ReadonlyArray<VideoCodec> {
|
||||
const encoderMode = resolveEffectiveScreenShareEncoderMode(VoiceSettings.getScreenShareEncoderMode());
|
||||
const automaticOrder = isFirefoxBrowser()
|
||||
? GECKO_SOFTWARE_CODEC_PREFERENCE
|
||||
: encoderMode === 'software'
|
||||
? SOFTWARE_CODEC_PREFERENCE
|
||||
: getHardwareFirstScreenShareCodecPreferenceOrder();
|
||||
const order =
|
||||
preference !== 'auto' ? [preference, ...automaticOrder.filter((codec) => codec !== preference)] : automaticOrder;
|
||||
return order.filter((codec) => isVideoCodecAllowedForPublish(codec));
|
||||
}
|
||||
|
||||
function getHardwareFirstScreenShareCodecPreferenceOrder(): ReadonlyArray<VideoCodec> {
|
||||
const report = getCodecCapabilityReport();
|
||||
const hardwareCodecs = CODEC_PREFERENCE.filter(
|
||||
(codec) => report[codec].supported && report[codec].hardwareAccelerated === 'hardware',
|
||||
);
|
||||
if (hardwareCodecs.length === 0) return SOFTWARE_CODEC_PREFERENCE;
|
||||
return [...hardwareCodecs, ...CODEC_PREFERENCE.filter((codec) => !hardwareCodecs.includes(codec))];
|
||||
}
|
||||
|
||||
function getLocalEncodeCapabilities(): Record<VideoCodec, boolean> {
|
||||
const report = getCodecCapabilityReport();
|
||||
const encoderMode = resolveEffectiveScreenShareEncoderMode(VoiceSettings.getScreenShareEncoderMode());
|
||||
const pinned = VoiceSettings.getPreferredScreenShareCodec();
|
||||
const hardwareEncodeAvailable = CODEC_PREFERENCE.some(
|
||||
(codec) => report[codec].supported && report[codec].hardwareAccelerated === 'hardware',
|
||||
);
|
||||
const advertise = (codec: VideoCodec): boolean =>
|
||||
isVideoCodecAllowedForPublish(codec) &&
|
||||
((pinned === codec && report[codec].supported) ||
|
||||
shouldAdvertiseVideoEncode(codec, report[codec], encoderMode, hardwareEncodeAvailable, report));
|
||||
return {
|
||||
av1: advertise('av1'),
|
||||
h265: advertise('h265'),
|
||||
h264: advertise('h264'),
|
||||
vp9: advertise('vp9'),
|
||||
vp8: report.vp8.supported && isVideoCodecAllowedForPublish('vp8'),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldAdvertiseVideoEncode(
|
||||
codec: VideoCodec,
|
||||
info: CodecSupportInfo,
|
||||
encoderMode: ScreenShareEncoderMode,
|
||||
hardwareEncodeAvailable: boolean,
|
||||
report: CodecCapabilityReport,
|
||||
): boolean {
|
||||
if (!info.supported) return false;
|
||||
if (encoderMode === 'software') {
|
||||
if (codec === 'h265') return false;
|
||||
return true;
|
||||
}
|
||||
if (hardwareEncodeAvailable) {
|
||||
if (codec === 'h264') return true;
|
||||
if (codec === 'vp8') return !report.h264.supported;
|
||||
return info.hardwareAccelerated === 'hardware';
|
||||
}
|
||||
if (codec === 'h265') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildVideoCodecAdvertisement(
|
||||
codec: VideoCodec,
|
||||
encodeCaps: Record<VideoCodec, boolean>,
|
||||
decodeCaps: Record<VideoCodec, boolean>,
|
||||
): FluxerCodecAdvertisement {
|
||||
const table = VIDEO_CODEC_PROTOCOL_TABLE[codec];
|
||||
return {
|
||||
name: VIDEO_CODEC_NAMES[codec],
|
||||
type: 'video',
|
||||
payload_type: table.payloadType,
|
||||
rtx_payload_type: table.rtxPayloadType,
|
||||
priority: table.priority,
|
||||
encode: encodeCaps[codec],
|
||||
decode: decodeCaps[codec],
|
||||
};
|
||||
return rankScreenShareCodecs({
|
||||
profile: buildScreenShareCodecProfile(),
|
||||
encoderModeSetting: VoiceSettings.getScreenShareEncoderMode(),
|
||||
pin: preference,
|
||||
}).order;
|
||||
}
|
||||
|
||||
export function buildLocalCodecAdvertisements(): Array<FluxerCodecAdvertisement> {
|
||||
const encodeCaps = getLocalEncodeCapabilities();
|
||||
const decodeCaps = getLocalDecodeCapabilities();
|
||||
return [
|
||||
{
|
||||
name: 'opus',
|
||||
type: 'audio',
|
||||
payload_type: 120,
|
||||
priority: 1000,
|
||||
encode: true,
|
||||
decode: true,
|
||||
},
|
||||
...CODEC_PREFERENCE.map((codec) => buildVideoCodecAdvertisement(codec, encodeCaps, decodeCaps)),
|
||||
];
|
||||
}
|
||||
|
||||
function getDecodeSet(codecs: ReadonlyArray<FluxerCodecAdvertisement>): Set<VideoCodec> {
|
||||
const result = new Set<VideoCodec>();
|
||||
for (const codec of codecs) {
|
||||
if (codec.type !== 'video' || codec.decode !== true) continue;
|
||||
const mapped = NAME_TO_VIDEO_CODEC[codec.name as FluxerVideoCodecName];
|
||||
if (mapped) result.add(mapped);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getEncodeSet(codecs: ReadonlyArray<FluxerCodecAdvertisement>): Set<VideoCodec> {
|
||||
const result = new Set<VideoCodec>();
|
||||
for (const codec of codecs) {
|
||||
if (codec.type !== 'video' || codec.encode !== true) continue;
|
||||
const mapped = NAME_TO_VIDEO_CODEC[codec.name as FluxerVideoCodecName];
|
||||
if (mapped) result.add(mapped);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function selectCompatibilityFallbackCodec(localEncode: ReadonlySet<VideoCodec>): VideoCodec {
|
||||
for (const codec of COMPATIBILITY_FALLBACK_CODEC_PREFERENCE) {
|
||||
if (localEncode.has(codec)) return codec;
|
||||
}
|
||||
return BASELINE_VIDEO_CODEC;
|
||||
}
|
||||
|
||||
export function computeNegotiatedVideoCodec(
|
||||
localCodecs: ReadonlyArray<FluxerCodecAdvertisement>,
|
||||
remoteCodecs: ReadonlyArray<ReadonlyArray<FluxerCodecAdvertisement>>,
|
||||
unknownParticipants = 0,
|
||||
codecPreference: ReadonlyArray<VideoCodec> = CODEC_PREFERENCE,
|
||||
): CodecNegotiationSelection {
|
||||
const localEncode = getEncodeSet(localCodecs);
|
||||
const candidates = codecPreference.filter((codec) => localEncode.has(codec));
|
||||
const constrainedCandidates = candidates.filter((codec) => {
|
||||
if (unknownParticipants > 0 && !COMPATIBILITY_FALLBACK_CODEC_PREFERENCE.includes(codec)) return false;
|
||||
for (const remote of remoteCodecs) {
|
||||
if (!getDecodeSet(remote).has(codec)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const codec = constrainedCandidates[0] ?? selectCompatibilityFallbackCodec(localEncode);
|
||||
return {
|
||||
codec,
|
||||
reason: 'manual',
|
||||
candidates: constrainedCandidates,
|
||||
unknownParticipants,
|
||||
};
|
||||
return buildScreenShareCodecAdvertisements(getScreenShareCodecPreferenceOrder(), getLocalDecodeCapabilities());
|
||||
}
|
||||
|
||||
function evaluateScreenShareCodecNegotiation(
|
||||
@@ -505,6 +322,9 @@ class ScreenShareCodecNegotiation {
|
||||
private selectedCodec: VideoCodec | null = null;
|
||||
private localCodecs: Array<FluxerCodecAdvertisement> = [];
|
||||
private remoteCodecsByIdentity = new Map<string, Array<FluxerCodecAdvertisement>>();
|
||||
private remoteFirstSeenAt = new Map<string, number>();
|
||||
private answeredIdentities = new Set<string>();
|
||||
private graceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private rtcConnectionId = createId('rtc');
|
||||
private mediaSessionId = createId('media');
|
||||
private negotiationSnapshot = createScreenShareCodecNegotiationSnapshot();
|
||||
@@ -528,6 +348,11 @@ class ScreenShareCodecNegotiation {
|
||||
return result;
|
||||
}
|
||||
|
||||
getRemoteDecodeInputs(): {knownDecode: Array<Set<VideoCodec>>; unknownParticipants: number} {
|
||||
const {knownRemoteCodecs, unknownParticipants} = this.getRemoteCodecInputs();
|
||||
return {knownDecode: knownRemoteCodecs.map((codecs) => getDecodeSet(codecs)), unknownParticipants};
|
||||
}
|
||||
|
||||
setSelectionChangeListener(
|
||||
listener: ((room: Room, codec: VideoCodec, reason: NegotiationReason) => void) | null,
|
||||
): void {
|
||||
@@ -594,17 +419,55 @@ class ScreenShareCodecNegotiation {
|
||||
knownRemoteCodecs: Array<Array<FluxerCodecAdvertisement>>;
|
||||
unknownParticipants: number;
|
||||
} {
|
||||
const now = Date.now();
|
||||
const knownRemoteCodecs: Array<Array<FluxerCodecAdvertisement>> = [];
|
||||
let unknownParticipants = 0;
|
||||
const participants: Array<{identity: string; firstSeenAt: number}> = [];
|
||||
for (const participant of room?.remoteParticipants.values() ?? []) {
|
||||
const firstSeenAt = this.remoteFirstSeenAt.get(participant.identity) ?? now;
|
||||
this.remoteFirstSeenAt.set(participant.identity, firstSeenAt);
|
||||
participants.push({identity: participant.identity, firstSeenAt});
|
||||
const codecs = this.remoteCodecsByIdentity.get(participant.identity);
|
||||
if (codecs) {
|
||||
knownRemoteCodecs.push(codecs);
|
||||
} else {
|
||||
unknownParticipants++;
|
||||
}
|
||||
if (codecs) knownRemoteCodecs.push(codecs);
|
||||
}
|
||||
return {knownRemoteCodecs, unknownParticipants};
|
||||
return {
|
||||
knownRemoteCodecs,
|
||||
unknownParticipants: countUnknownScreenShareParticipants(
|
||||
participants,
|
||||
new Set(this.remoteCodecsByIdentity.keys()),
|
||||
now,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleGraceReevaluations(room: Room, bindingRevision: number): void {
|
||||
const now = Date.now();
|
||||
for (const participant of room.remoteParticipants.values()) {
|
||||
const {identity} = participant;
|
||||
if (this.remoteCodecsByIdentity.has(identity) || this.graceTimers.has(identity)) continue;
|
||||
const remaining = (this.remoteFirstSeenAt.get(identity) ?? now) + SCREEN_SHARE_CODEC_ADVERTISEMENT_GRACE_MS - now;
|
||||
if (remaining <= 0) continue;
|
||||
this.graceTimers.set(
|
||||
identity,
|
||||
setTimeout(() => {
|
||||
this.graceTimers.delete(identity);
|
||||
void this.updateSelection(room, 'participant-connected', bindingRevision);
|
||||
}, remaining),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private clearGraceTimer(identity: string): void {
|
||||
const timer = this.graceTimers.get(identity);
|
||||
if (timer === undefined) return;
|
||||
clearTimeout(timer);
|
||||
this.graceTimers.delete(identity);
|
||||
}
|
||||
|
||||
private forgetIdentity(identity: string): void {
|
||||
this.clearGraceTimer(identity);
|
||||
this.remoteCodecsByIdentity.delete(identity);
|
||||
this.remoteFirstSeenAt.delete(identity);
|
||||
this.answeredIdentities.delete(identity);
|
||||
}
|
||||
|
||||
bind(room: Room): () => void {
|
||||
@@ -625,7 +488,7 @@ class ScreenShareCodecNegotiation {
|
||||
};
|
||||
const onParticipantDisconnected = (participant: Participant): void => {
|
||||
if (!this.isBindingCurrent(room, bindingRevision)) return;
|
||||
this.remoteCodecsByIdentity.delete(participant.identity);
|
||||
this.forgetIdentity(participant.identity);
|
||||
void this.updateSelection(room, 'participant-disconnected', bindingRevision);
|
||||
};
|
||||
const onReconnected = (): void => {
|
||||
@@ -652,10 +515,13 @@ class ScreenShareCodecNegotiation {
|
||||
this.room = null;
|
||||
this.selectedCodec = null;
|
||||
this.localCodecs = [];
|
||||
for (const timer of this.graceTimers.values()) clearTimeout(timer);
|
||||
this.graceTimers.clear();
|
||||
this.remoteCodecsByIdentity.clear();
|
||||
this.remoteFirstSeenAt.clear();
|
||||
this.answeredIdentities.clear();
|
||||
this.mediaSessionId = createId('media');
|
||||
this.negotiationSnapshot = createScreenShareCodecNegotiationSnapshot();
|
||||
clearScreenShareDecodeFailures();
|
||||
}
|
||||
|
||||
async publishLocalCapabilities(
|
||||
@@ -666,6 +532,11 @@ class ScreenShareCodecNegotiation {
|
||||
return await this.publishBoundLocalCapabilities(room, reason, this.bindingRevision);
|
||||
}
|
||||
|
||||
async refreshSelection(room: Room | null = this.room): Promise<CodecNegotiationSelection | null> {
|
||||
if (!room) return null;
|
||||
return await this.updateSelection(room, 'manual', this.bindingRevision);
|
||||
}
|
||||
|
||||
private async publishBoundLocalCapabilities(
|
||||
room: Room,
|
||||
reason: NegotiationReason,
|
||||
@@ -678,9 +549,17 @@ class ScreenShareCodecNegotiation {
|
||||
loadNativeHardwareEncoderCapabilities(),
|
||||
loadVideoDecoderExclusions(),
|
||||
loadOpenH264Status(),
|
||||
getDesktopTroubleshootingSettings(),
|
||||
]);
|
||||
if (!this.isBindingCurrent(room, bindingRevision)) return null;
|
||||
this.localCodecs = buildLocalCodecAdvertisements();
|
||||
await this.publishSelectProtocol(room);
|
||||
if (!this.isBindingCurrent(room, bindingRevision)) return null;
|
||||
return await this.updateSelection(room, reason, bindingRevision);
|
||||
}
|
||||
|
||||
private async publishSelectProtocol(room: Room): Promise<void> {
|
||||
if (this.localCodecs.length === 0) this.localCodecs = buildLocalCodecAdvertisements();
|
||||
const message: FluxerSelectProtocolMessage = {
|
||||
op: SELECT_PROTOCOL_OP,
|
||||
d: {
|
||||
@@ -694,19 +573,15 @@ class ScreenShareCodecNegotiation {
|
||||
},
|
||||
};
|
||||
await this.publishMessage(room, message);
|
||||
if (!this.isBindingCurrent(room, bindingRevision)) return null;
|
||||
return await this.updateSelection(room, reason, bindingRevision);
|
||||
}
|
||||
|
||||
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', bindingRevision);
|
||||
} else if (message.op === SESSION_UPDATE_OP) {
|
||||
this.remoteCodecsByIdentity.set(participant.identity, message.d.codecs);
|
||||
this.remoteCodecsByIdentity.set(participant.identity, message.d.codecs);
|
||||
this.clearGraceTimer(participant.identity);
|
||||
if (message.op === SESSION_UPDATE_OP) {
|
||||
logger.debug('Received remote codec session update', {
|
||||
participantIdentity: participant.identity,
|
||||
videoCodec: message.d.video_codec,
|
||||
@@ -714,7 +589,18 @@ class ScreenShareCodecNegotiation {
|
||||
reason: message.d.reason,
|
||||
});
|
||||
void this.updateSelection(room, 'data', bindingRevision);
|
||||
return;
|
||||
}
|
||||
void this.answerSelectProtocol(room, participant.identity, bindingRevision);
|
||||
}
|
||||
|
||||
private async answerSelectProtocol(room: Room, identity: string, bindingRevision: number): Promise<void> {
|
||||
if (!this.answeredIdentities.has(identity)) {
|
||||
this.answeredIdentities.add(identity);
|
||||
await this.publishSelectProtocol(room);
|
||||
if (!this.isBindingCurrent(room, bindingRevision)) return;
|
||||
}
|
||||
await this.updateSelection(room, 'data', bindingRevision);
|
||||
}
|
||||
|
||||
private async updateSelection(
|
||||
@@ -731,6 +617,7 @@ class ScreenShareCodecNegotiation {
|
||||
return null;
|
||||
}
|
||||
const {knownRemoteCodecs, unknownParticipants} = this.getRemoteCodecInputs(room);
|
||||
this.scheduleGraceReevaluations(room, bindingRevision);
|
||||
const previousCodec = this.selectedCodec;
|
||||
this.negotiationSnapshot = transitionScreenShareCodecNegotiationSnapshot(this.negotiationSnapshot, {
|
||||
type: 'negotiation.evaluate',
|
||||
@@ -781,6 +668,6 @@ class ScreenShareCodecNegotiation {
|
||||
}
|
||||
}
|
||||
|
||||
export {CODEC_PREFERENCE, PROTOCOL_TOPIC as SCREEN_SHARE_CODEC_NEGOTIATION_TOPIC};
|
||||
export {PROTOCOL_TOPIC as SCREEN_SHARE_CODEC_NEGOTIATION_TOPIC};
|
||||
|
||||
export default new ScreenShareCodecNegotiation();
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const SCREEN_SHARE_UNDERPERFORMANCE_SAMPLE_INTERVAL_MS = 1000;
|
||||
|
||||
const SHORT_WINDOW_SAMPLES = 15;
|
||||
const LONG_WINDOW_SAMPLES = 30;
|
||||
const MIN_WINDOW_SAMPLES = 15;
|
||||
const MIN_ENCODE_FRAME_RATE_FLOOR = 8;
|
||||
const MIN_LIMITED_SAMPLE_RATIO = 0.5;
|
||||
const QUALITY_CHANGE_SUPPRESSION_MS = 20_000;
|
||||
const MAX_APPLIED_FRAME_RATE_RECORDS = 8;
|
||||
|
||||
export type ScreenShareQualityLimitationReason = 'none' | 'cpu' | 'bandwidth' | 'other' | 'unknown';
|
||||
|
||||
export type ScreenShareUnderperformanceReason = 'cpu' | 'bandwidth' | 'other';
|
||||
|
||||
export interface ScreenShareUnderperformanceSample {
|
||||
encodeFrameRate: number;
|
||||
limitationReason: ScreenShareQualityLimitationReason;
|
||||
}
|
||||
|
||||
export interface ScreenShareUnderperformanceObservation {
|
||||
sample: ScreenShareUnderperformanceSample;
|
||||
requestedFrameRate: number;
|
||||
trackId: string;
|
||||
qualityChangeAt: number;
|
||||
now: number;
|
||||
}
|
||||
|
||||
let deliberateQualityChangeAt = 0;
|
||||
const appliedFrameRateByTrackId = new Map<string, number>();
|
||||
|
||||
export function noteDeliberateScreenShareQualityChange(): void {
|
||||
deliberateQualityChangeAt = Date.now();
|
||||
}
|
||||
|
||||
export function getDeliberateScreenShareQualityChangeAt(): number {
|
||||
return deliberateQualityChangeAt;
|
||||
}
|
||||
|
||||
export function noteAppliedScreenShareFrameRate(trackId: string, frameRate: number): void {
|
||||
appliedFrameRateByTrackId.delete(trackId);
|
||||
appliedFrameRateByTrackId.set(trackId, frameRate);
|
||||
while (appliedFrameRateByTrackId.size > MAX_APPLIED_FRAME_RATE_RECORDS) {
|
||||
const oldest = appliedFrameRateByTrackId.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
appliedFrameRateByTrackId.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAppliedScreenShareFrameRate(trackId: string): number | null {
|
||||
return appliedFrameRateByTrackId.get(trackId) ?? null;
|
||||
}
|
||||
|
||||
export function parseScreenShareQualityLimitationReason(value: unknown): ScreenShareQualityLimitationReason {
|
||||
if (value === 'none' || value === 'cpu' || value === 'bandwidth' || value === 'other') {
|
||||
return value;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function getScreenShareEncodeFrameRateFloor(requestedFrameRate: number): number {
|
||||
return Math.max(MIN_ENCODE_FRAME_RATE_FLOOR, requestedFrameRate / 2);
|
||||
}
|
||||
|
||||
function evaluateWindow(
|
||||
samples: ReadonlyArray<ScreenShareUnderperformanceSample>,
|
||||
windowSamples: number,
|
||||
requestedFrameRate: number,
|
||||
): ScreenShareUnderperformanceReason | null {
|
||||
const window = samples.slice(-windowSamples);
|
||||
if (window.length < MIN_WINDOW_SAMPLES) return null;
|
||||
let totalFrameRate = 0;
|
||||
let cpuSamples = 0;
|
||||
let bandwidthSamples = 0;
|
||||
let otherSamples = 0;
|
||||
for (const sample of window) {
|
||||
totalFrameRate += sample.encodeFrameRate;
|
||||
if (sample.limitationReason === 'cpu') {
|
||||
cpuSamples += 1;
|
||||
} else if (sample.limitationReason === 'bandwidth') {
|
||||
bandwidthSamples += 1;
|
||||
} else if (sample.limitationReason === 'other') {
|
||||
otherSamples += 1;
|
||||
}
|
||||
}
|
||||
if (totalFrameRate / window.length >= getScreenShareEncodeFrameRateFloor(requestedFrameRate)) return null;
|
||||
if (cpuSamples + bandwidthSamples + otherSamples < window.length * MIN_LIMITED_SAMPLE_RATIO) return null;
|
||||
if (cpuSamples >= bandwidthSamples && cpuSamples >= otherSamples) return 'cpu';
|
||||
if (bandwidthSamples >= otherSamples) return 'bandwidth';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export class ScreenShareUnderperformanceTracker {
|
||||
private samples: Array<ScreenShareUnderperformanceSample> = [];
|
||||
private requestedFrameRate: number | null = null;
|
||||
private trackId: string | null = null;
|
||||
private qualityChangeAt: number | null = null;
|
||||
|
||||
reset(): void {
|
||||
this.samples = [];
|
||||
this.requestedFrameRate = null;
|
||||
this.trackId = null;
|
||||
this.qualityChangeAt = null;
|
||||
}
|
||||
|
||||
observe(observation: ScreenShareUnderperformanceObservation): ScreenShareUnderperformanceReason | null {
|
||||
if (
|
||||
observation.requestedFrameRate !== this.requestedFrameRate ||
|
||||
observation.trackId !== this.trackId ||
|
||||
observation.qualityChangeAt !== this.qualityChangeAt
|
||||
) {
|
||||
this.samples = [];
|
||||
this.requestedFrameRate = observation.requestedFrameRate;
|
||||
this.trackId = observation.trackId;
|
||||
this.qualityChangeAt = observation.qualityChangeAt;
|
||||
}
|
||||
this.samples.push(observation.sample);
|
||||
if (this.samples.length > LONG_WINDOW_SAMPLES) {
|
||||
this.samples.splice(0, this.samples.length - LONG_WINDOW_SAMPLES);
|
||||
}
|
||||
if (observation.now - observation.qualityChangeAt < QUALITY_CHANGE_SUPPRESSION_MS) return null;
|
||||
return (
|
||||
evaluateWindow(this.samples, SHORT_WINDOW_SAMPLES, observation.requestedFrameRate) ??
|
||||
evaluateWindow(this.samples, LONG_WINDOW_SAMPLES, observation.requestedFrameRate)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ interface MediaEngineAccess {
|
||||
voiceEngineV2Controller?: VoiceEngineV2Controller;
|
||||
voiceEngineV2Model?: VoiceEngineV2Model;
|
||||
voiceEngineV2Snapshot?: VoiceEngineV2Snapshot;
|
||||
getAllVoiceStates?: () => VoiceMediaEngineVoiceStates;
|
||||
getAllVoiceStatesInChannel?: (
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
@@ -109,6 +110,14 @@ export function getVoiceConnectionContextFromMediaEngine(): VoiceMediaEngineConn
|
||||
};
|
||||
}
|
||||
|
||||
export type VoiceMediaEngineVoiceStates = Readonly<
|
||||
Record<string, Readonly<Record<string, Readonly<Record<string, VoiceMediaEngineVoiceState>>>>>
|
||||
>;
|
||||
|
||||
export function getAllVoiceStatesFromMediaEngine(): VoiceMediaEngineVoiceStates {
|
||||
return getMediaEngine()?.getAllVoiceStates?.() ?? {};
|
||||
}
|
||||
|
||||
export function getAllVoiceStatesInChannelFromMediaEngine(
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
|
||||
@@ -146,6 +146,8 @@ export function bindRoomEvents(
|
||||
let codecNegotiationDisposer: (() => void) | null = null;
|
||||
let screenShareMigrationDisposer: (() => void) | null = null;
|
||||
let screenShareNegotiationBound = false;
|
||||
let roomReconnecting = false;
|
||||
let screenSharePublicationAwaitingReconnect: LocalTrackPublication | null = null;
|
||||
const remoteTrackLifecycleRoleFor = (pub: RemoteTrackPublication): 'remote-screen-share' | 'remote-camera' | null => {
|
||||
if (pub.kind !== Track.Kind.Video) return null;
|
||||
if (pub.source === Track.Source.ScreenShare) return 'remote-screen-share';
|
||||
@@ -309,6 +311,17 @@ export function bindRoomEvents(
|
||||
participantSpeakingDisposers.get(identity)?.dispose();
|
||||
participantSpeakingDisposers.delete(identity);
|
||||
};
|
||||
const endScreenShareIfPublicationDidNotReturn = (): void => {
|
||||
const publication = screenSharePublicationAwaitingReconnect;
|
||||
screenSharePublicationAwaitingReconnect = null;
|
||||
if (!publication) return;
|
||||
if (dependencies.screenShare.isScreenSharePublicationReplaceInFlight()) return;
|
||||
if (!('localParticipant' in room) || !room.localParticipant) return;
|
||||
if (room.localParticipant.getTrackPublication(Track.Source.ScreenShare)) return;
|
||||
const didChangeLocalState = dependencies.mediaState.handleLocalTrackStateChange(Track.Source.ScreenShare, false);
|
||||
if (!didChangeLocalState) return;
|
||||
dependencies.screenShare.handleLocalScreenShareTrackUnpublished(room, didChangeLocalState, publication);
|
||||
};
|
||||
bindParticipantSpeakingEvents(room.localParticipant);
|
||||
room.remoteParticipants.forEach((participant) => bindParticipantSpeakingEvents(participant));
|
||||
room.on(
|
||||
@@ -344,6 +357,8 @@ export function bindRoomEvents(
|
||||
room.on(
|
||||
RoomEvent.Disconnected,
|
||||
guard(attemptId, () => {
|
||||
roomReconnecting = false;
|
||||
screenSharePublicationAwaitingReconnect = null;
|
||||
participantSpeakingDisposers.forEach(({dispose}) => dispose());
|
||||
participantSpeakingDisposers.clear();
|
||||
unbindScreenShareNegotiation();
|
||||
@@ -365,6 +380,7 @@ export function bindRoomEvents(
|
||||
room.on(
|
||||
RoomEvent.Reconnecting,
|
||||
guard(attemptId, () => {
|
||||
roomReconnecting = true;
|
||||
callbacks.onReconnecting();
|
||||
dependencies.connection.markReconnecting();
|
||||
}),
|
||||
@@ -372,6 +388,7 @@ export function bindRoomEvents(
|
||||
room.on(
|
||||
RoomEvent.Reconnected,
|
||||
guard(attemptId, () => {
|
||||
roomReconnecting = false;
|
||||
dependencies.participants.hydrateFromRoom(room);
|
||||
bindParticipantSpeakingEvents(room.localParticipant);
|
||||
room.remoteParticipants.forEach((participant) => bindParticipantSpeakingEvents(participant));
|
||||
@@ -381,6 +398,7 @@ export function bindRoomEvents(
|
||||
callbacks.onReconnected();
|
||||
dependencies.media.applyAllLocalAudioPreferences(room);
|
||||
dependencies.subscriptions.reconcileSubscriptions();
|
||||
endScreenShareIfPublicationDidNotReturn();
|
||||
}),
|
||||
);
|
||||
room.on(
|
||||
@@ -547,12 +565,16 @@ export function bindRoomEvents(
|
||||
RoomEvent.LocalTrackUnpublished,
|
||||
guard(attemptId, (pub, p: Participant) => {
|
||||
if (p.isLocal) {
|
||||
if (
|
||||
pub.source === Track.Source.ScreenShare &&
|
||||
dependencies.screenShare.isScreenSharePublicationReplaceInFlight()
|
||||
) {
|
||||
dependencies.participants.upsertParticipant(p);
|
||||
return;
|
||||
if (pub.source === Track.Source.ScreenShare) {
|
||||
if (dependencies.screenShare.isScreenSharePublicationReplaceInFlight()) {
|
||||
dependencies.participants.upsertParticipant(p);
|
||||
return;
|
||||
}
|
||||
if (roomReconnecting) {
|
||||
screenSharePublicationAwaitingReconnect = pub as LocalTrackPublication;
|
||||
dependencies.participants.upsertParticipant(p);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const didChangeLocalState = dependencies.mediaState.handleLocalTrackStateChange(pub.source, false);
|
||||
if (pub.source === Track.Source.ScreenShare && 'localParticipant' in room && room.localParticipant) {
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
import {setNoiseSuppressionScopeGuildId} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionSelection';
|
||||
import {SCREEN_SHARE_MAX_VIDEO_BITRATE_BPS} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {
|
||||
clearScreenShareDecodeFailures,
|
||||
getVideoDecoderExclusionsSync,
|
||||
loadVideoDecoderExclusions,
|
||||
} from '@app/features/voice/utils/VideoDecoderCapabilities';
|
||||
@@ -118,22 +119,19 @@ const initialHotSwapState: RegionHotSwapState = {
|
||||
const REGION_HOT_SWAP_TIMEOUT_MS = 10000;
|
||||
|
||||
async function getRoomVideoDecoderExclusions(): Promise<RoomOptions['subscriberVideoCodecExclusions']> {
|
||||
const cached = getVideoDecoderExclusionsSync();
|
||||
if (cached) return cached.length > 0 ? cached : undefined;
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<null>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve(null), VIDEO_DECODER_EXCLUSION_TIMEOUT_MS);
|
||||
});
|
||||
try {
|
||||
const exclusions = await Promise.race([loadVideoDecoderExclusions(), timeout]);
|
||||
if (exclusions && exclusions.length > 0) return exclusions;
|
||||
const latest = getVideoDecoderExclusionsSync();
|
||||
return latest && latest.length > 0 ? latest : undefined;
|
||||
await Promise.race([loadVideoDecoderExclusions(), timeout]);
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
const exclusions = getVideoDecoderExclusionsSync();
|
||||
return exclusions && exclusions.length > 0 ? exclusions : undefined;
|
||||
}
|
||||
|
||||
function createWebAudioMixOption(): RoomOptions['webAudioMix'] {
|
||||
@@ -542,6 +540,7 @@ export class VoiceEngineV2AppConnectionHostAdapter extends Store {
|
||||
});
|
||||
this.throttle.setInFlightConnect(true);
|
||||
const e2eeKey = raw.e2ee_key ?? null;
|
||||
clearScreenShareDecodeFailures();
|
||||
const subscriberVideoCodecExclusions = await getRoomVideoDecoderExclusions();
|
||||
if (
|
||||
!this.isLatestConnectionAttempt(attemptId) ||
|
||||
|
||||
+171
-69
@@ -49,16 +49,19 @@ import {
|
||||
captureScreenSharePublicationCleanup,
|
||||
type DeviceScreenShareCaptureOptions,
|
||||
type DisplayScreenShareCaptureContext,
|
||||
ensureCommittedScreenShareTarget,
|
||||
logger,
|
||||
mergeScreenShareCaptureCleanupSnapshots,
|
||||
releaseScreenShareCaptureCleanup,
|
||||
getEffectivePublishOptions as resolveEffectivePublishOptions,
|
||||
type ScreenShareCaptureCleanupSnapshot,
|
||||
type ScreenShareCodecReadinessStatus,
|
||||
scheduleScreenShareEncoderVerification,
|
||||
startScreenShareEncoderMonitor,
|
||||
stopMediaTrack,
|
||||
} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import ActiveScreenShareSource, {
|
||||
type PublishedScreenShareSource,
|
||||
} from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import LocalVoiceState from '@app/features/voice/state/LocalVoiceState';
|
||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
@@ -87,7 +90,10 @@ import {
|
||||
recordScreenShareStopped,
|
||||
type ScreenShareEndedModal,
|
||||
} from '@app/features/voice/utils/ScreenShareLifecycleLog';
|
||||
import {SCREEN_SHARE_DEGRADATION_PREFERENCE} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {
|
||||
resolveScreenShareDegradationPreference,
|
||||
type ScreenShareTarget,
|
||||
} 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';
|
||||
@@ -140,11 +146,27 @@ type ScreenShareVideoConstraints = MediaTrackConstraints & {
|
||||
cursor?: 'always' | 'motion' | 'never';
|
||||
};
|
||||
|
||||
export interface ScreenShareSourceSnapshot {
|
||||
publishedSource: PublishedScreenShareSource | null;
|
||||
sourceId: string | null;
|
||||
isOwnWindow: boolean;
|
||||
sourceDimensions: {width: number; height: number} | null;
|
||||
target: ScreenShareTarget | null;
|
||||
}
|
||||
|
||||
export interface ScreenShareReconnectSnapshot {
|
||||
videoTrack: MediaStreamTrack;
|
||||
audioTrack?: MediaStreamTrack;
|
||||
audioMuted: boolean;
|
||||
contentSource: ScreenShareContentSource;
|
||||
source: ScreenShareSourceSnapshot;
|
||||
}
|
||||
|
||||
export function shouldRestoreScreenShareAfterReconnect(
|
||||
restoreStream: boolean,
|
||||
snapshot: ScreenShareReconnectSnapshot | null,
|
||||
): boolean {
|
||||
return restoreStream || snapshot !== null;
|
||||
}
|
||||
|
||||
const selectScreenShareSetEnabledOptions = selectVoiceEngineV2AppScreenShareSetEnabledOptions;
|
||||
@@ -155,7 +177,10 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
private readonly trackPlumbing: VoiceEngineV2AppScreenShareTrackPlumbing;
|
||||
private activeScreenShareEndListener: (() => void) | null = null;
|
||||
private endedScreenShareStopInFlight: Promise<void> | null = null;
|
||||
encoderVerificationTimer: NodeJS.Timeout | null = null;
|
||||
encoderVerificationTimer: (() => void) | null = null;
|
||||
private screenShareTrackingHolds = 0;
|
||||
private screenShareTrackingGeneration = 0;
|
||||
private readonly reconciledCodecPairs = new Set<string>();
|
||||
private readonly verifiedCodecCorrectionsByTrack = new WeakMap<MediaStreamTrack, number>();
|
||||
sourceLifecycleBridge: VoiceEngineV2AppSourceLifecycleBridge | null = null;
|
||||
|
||||
@@ -295,8 +320,8 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
async enforceScreenShareSenderParametersInternal(
|
||||
participant: LocalParticipant,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
): Promise<void> {
|
||||
await this.trackPlumbing.enforceSenderParameters(participant, publishOptions);
|
||||
): Promise<boolean> {
|
||||
return this.trackPlumbing.enforceSenderParameters(participant, publishOptions?.videoCodec);
|
||||
}
|
||||
|
||||
applyScreenShareAudioContentHintInternal(participant: LocalParticipant): void {
|
||||
@@ -339,6 +364,13 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
...(liveAudioTrack ? {audioTrack: liveAudioTrack} : {}),
|
||||
audioMuted: screenShareAudioPublication?.isMuted ?? false,
|
||||
contentSource: this.getActiveScreenShareContentSourceInternal(),
|
||||
source: {
|
||||
publishedSource: ActiveScreenShareSource.getPublishedSource(),
|
||||
sourceId: ActiveScreenShareSource.getSourceId(),
|
||||
isOwnWindow: ActiveScreenShareSource.isOwnWindow(),
|
||||
sourceDimensions: ActiveScreenShareSource.getSourceDimensions(),
|
||||
target: ActiveScreenShareSource.getTarget(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,9 +399,16 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
}
|
||||
}
|
||||
|
||||
private endScreenShareTracking(): void {
|
||||
ActiveScreenShareSource.clear();
|
||||
this.reconciledCodecPairs.clear();
|
||||
}
|
||||
|
||||
private cleanupScreenShareAudioRoutingState(): void {
|
||||
this.cleanupScreenShareAudioCaptureRouting();
|
||||
ActiveScreenShareSource.clear();
|
||||
if (this.screenShareTrackingHolds === 0) {
|
||||
this.endScreenShareTracking();
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupScreenShareAudioCaptureRouting(): void {
|
||||
@@ -401,10 +440,8 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
}
|
||||
|
||||
cancelEncoderVerificationInternal(): void {
|
||||
if (this.encoderVerificationTimer != null) {
|
||||
clearTimeout(this.encoderVerificationTimer);
|
||||
this.encoderVerificationTimer = null;
|
||||
}
|
||||
this.encoderVerificationTimer?.();
|
||||
this.encoderVerificationTimer = null;
|
||||
this.trackPlumbing.cleanupKeyFrameRequests();
|
||||
this.transitionScreenShareLifecycleInternal({type: 'share.encoderVerification.cleared'});
|
||||
}
|
||||
@@ -439,14 +476,34 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
): Promise<void> {
|
||||
if (!this.isScreenShareTrackPublishedInternal(participant, track)) return;
|
||||
await ScreenShareCodecNegotiation.publishLocalCapabilities(room, 'manual');
|
||||
if (this.isScreenSharePending || !this.isScreenShareTrackPublishedInternal(participant, track)) return;
|
||||
const codec = ScreenShareCodecNegotiation.selectScreenShareCodec(VoiceSettings.getPreferredScreenShareCodec());
|
||||
if (codec !== failedCodec) {
|
||||
const recovered = await this.liveKitFlows.republishActiveShareWithCodec(room, track, codec);
|
||||
if (recovered) return;
|
||||
}
|
||||
if (codec !== failedCodec && (await this.liveKitFlows.republishActiveShareWithCodec(room, track, codec))) return;
|
||||
this.stopScreenShareAfterEncoderFailure(room, participant, track, failedCodec, 'stalled');
|
||||
}
|
||||
|
||||
async reconcileScreenShareCodecInternal(room: Room | null): Promise<void> {
|
||||
const participant = room?.localParticipant;
|
||||
if (!room || !participant) return;
|
||||
if (this.isScreenSharePending || this.isScreenSharePublicationReplaceInFlight()) return;
|
||||
const publication = participant.getTrackPublication(Track.Source.ScreenShare);
|
||||
const track = publication?.videoTrack as LocalVideoTrack | undefined;
|
||||
if (!track || !this.isScreenShareTrackPublishedInternal(participant, track)) return;
|
||||
const published = (publication as {options?: TrackPublishOptions} | undefined)?.options?.videoCodec;
|
||||
const selected = ScreenShareCodecNegotiation.getSelectedCodec();
|
||||
if (!published || !selected || published === selected) return;
|
||||
const pair = `${published}|${selected}`;
|
||||
if (this.reconciledCodecPairs.has(pair)) return;
|
||||
this.reconciledCodecPairs.add(pair);
|
||||
const republished = await this.liveKitFlows.republishActiveShareWithCodec(room, track, selected);
|
||||
if (!republished) {
|
||||
logger.warn('Failed to reconcile the active screen share codec with the negotiated codec', {
|
||||
published,
|
||||
selected,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async republishActiveScreenShareForNegotiatedCodecInternal(room: Room | null, codec: VideoCodec): Promise<void> {
|
||||
const participant = room?.localParticipant;
|
||||
if (!room || !participant) return;
|
||||
@@ -497,14 +554,14 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
codec,
|
||||
});
|
||||
}
|
||||
if (this.endedScreenShareStopInFlight) return;
|
||||
if (!this.isScreenShareTrackPublishedInternal(participant, track)) return;
|
||||
this.showScreenShareEndedModalInternal(
|
||||
i18n._(
|
||||
cause === 'stalled' ? SCREEN_SHARE_ENCODER_FAILED_DESCRIPTOR : SCREEN_SHARE_CODEC_POLICY_FAILED_DESCRIPTOR,
|
||||
),
|
||||
cause === 'stalled' ? 'encoder-failed' : 'codec-policy-failed',
|
||||
);
|
||||
if (this.endedScreenShareStopInFlight) return;
|
||||
if (!this.isScreenShareTrackPublishedInternal(participant, track)) return;
|
||||
recordScreenShareStopped('codec-republish-failed');
|
||||
this.transitionScreenShareLifecycleInternal({type: 'share.endedStop.start'});
|
||||
const stopPromise = this.setScreenShareEnabled(room, false, {sendUpdate: true, playSound: true})
|
||||
@@ -531,63 +588,76 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
recordScreenShareRequestedCodec(codec);
|
||||
const publication = preferredTrack ? undefined : participant.getTrackPublication(Track.Source.ScreenShare);
|
||||
const track = preferredTrack ?? (publication?.videoTrack as LocalVideoTrack | undefined);
|
||||
const sender = track?.sender;
|
||||
if (!track || !sender) {
|
||||
logger.warn('No sender found for screen share encoder verification');
|
||||
if (!track?.sender) {
|
||||
logger.warn('No sender found for screen share encoder monitoring');
|
||||
return;
|
||||
}
|
||||
this.encoderVerificationTimer = scheduleScreenShareEncoderVerification(
|
||||
() => sender.getStats(),
|
||||
this.encoderVerificationTimer = startScreenShareEncoderMonitor({
|
||||
track,
|
||||
codec,
|
||||
(failure) => {
|
||||
const action = resolveScreenShareEncoderVerificationAction(failure);
|
||||
switch (action.kind) {
|
||||
case 'ignore-repeated-stall':
|
||||
recordScreenShareEncoderVerification('ignore-repeated-stall', null);
|
||||
return;
|
||||
case 'recover-stalled':
|
||||
recordScreenShareEncoderVerification('recover-stalled', null);
|
||||
logger.warn('Screen share encoder verification failed', {
|
||||
codec: action.codec,
|
||||
failureReason: 'screen-share-encode-stalled',
|
||||
});
|
||||
void this.recoverActiveScreenShareAfterEncoderFailure(room, participant, track, action.codec).catch(
|
||||
(error) => {
|
||||
logger.warn('Failed to recover screen share after encoder verification failure', {
|
||||
error,
|
||||
codec: action.codec,
|
||||
});
|
||||
},
|
||||
);
|
||||
return;
|
||||
case 'accept-negotiated':
|
||||
recordScreenShareEncoderVerification('accept-negotiated', action.negotiated);
|
||||
logger.info('Screen share publisher negotiated a different codec inside the publish policy', {
|
||||
requested: action.requested,
|
||||
negotiated: action.negotiated,
|
||||
});
|
||||
return;
|
||||
case 'correct-negotiated':
|
||||
recordScreenShareEncoderVerification('correct-negotiated', action.negotiated);
|
||||
logger.warn('Screen share is sending a codec outside the publish policy', {
|
||||
requested: action.requested,
|
||||
negotiated: action.negotiated,
|
||||
alternative: action.alternative,
|
||||
});
|
||||
void this.correctVerifiedScreenShareCodec(room, participant, track, action).catch((error) => {
|
||||
logger.warn('Failed to correct screen share codec after encoder verification', {
|
||||
error,
|
||||
requested: action.requested,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
onEncodeFailure: (failure) =>
|
||||
this.handleScreenShareEncoderVerificationAction(
|
||||
room,
|
||||
participant,
|
||||
track,
|
||||
resolveScreenShareEncoderVerificationAction(failure),
|
||||
),
|
||||
reEnforce: async () => {
|
||||
await this.trackPlumbing.enforceTrackSenderParameters(track, codec);
|
||||
},
|
||||
);
|
||||
});
|
||||
this.trackPlumbing.bindKeyFrameRequests(room, participant, track);
|
||||
this.transitionScreenShareLifecycleInternal({type: 'share.encoderVerification.scheduled'});
|
||||
}
|
||||
|
||||
private handleScreenShareEncoderVerificationAction(
|
||||
room: Room | null,
|
||||
participant: LocalParticipant,
|
||||
track: LocalVideoTrack,
|
||||
action: ScreenShareEncoderVerificationAction,
|
||||
): void {
|
||||
switch (action.kind) {
|
||||
case 'stop-stalled':
|
||||
recordScreenShareEncoderVerification('stop-stalled', null);
|
||||
this.stopScreenShareAfterEncoderFailure(room, participant, track, action.codec, 'stalled');
|
||||
return;
|
||||
case 'recover-stalled':
|
||||
recordScreenShareEncoderVerification('recover-stalled', null);
|
||||
logger.warn('Screen share encoder verification failed', {
|
||||
codec: action.codec,
|
||||
failureReason: 'screen-share-encode-stalled',
|
||||
});
|
||||
void this.recoverActiveScreenShareAfterEncoderFailure(room, participant, track, action.codec).catch((error) => {
|
||||
logger.warn('Failed to recover screen share after encoder verification failure', {
|
||||
error,
|
||||
codec: action.codec,
|
||||
});
|
||||
});
|
||||
return;
|
||||
case 'accept-negotiated':
|
||||
recordScreenShareEncoderVerification('accept-negotiated', action.negotiated);
|
||||
logger.info('Screen share publisher negotiated a different codec inside the publish policy', {
|
||||
requested: action.requested,
|
||||
negotiated: action.negotiated,
|
||||
});
|
||||
return;
|
||||
case 'correct-negotiated':
|
||||
recordScreenShareEncoderVerification('correct-negotiated', action.negotiated);
|
||||
logger.warn('Screen share is sending a codec outside the publish policy', {
|
||||
requested: action.requested,
|
||||
negotiated: action.negotiated,
|
||||
alternative: action.alternative,
|
||||
});
|
||||
void this.correctVerifiedScreenShareCodec(room, participant, track, action).catch((error) => {
|
||||
logger.warn('Failed to correct screen share codec after encoder verification', {
|
||||
error,
|
||||
requested: action.requested,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
monitorActiveScreenShareEndInternal(
|
||||
room: Room | null,
|
||||
participant: LocalParticipant,
|
||||
@@ -649,6 +719,15 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
return this.liveKitFlows.restoreReconnect(room, snapshot, publishOptions);
|
||||
}
|
||||
|
||||
async restoreScreenShareAfterReconnect(
|
||||
room: Room | null,
|
||||
snapshot: ScreenShareReconnectSnapshot | null,
|
||||
): Promise<boolean> {
|
||||
if (snapshot !== null) return this.restoreScreenShareReconnect(room, snapshot);
|
||||
await this.setScreenShareEnabled(room, true, {sendUpdate: false});
|
||||
return LocalVoiceState.getSelfStream();
|
||||
}
|
||||
|
||||
async cleanupLingeringScreenShareTracks(
|
||||
participant: LocalParticipant,
|
||||
snapshot?: ScreenShareCaptureCleanupSnapshot,
|
||||
@@ -881,6 +960,7 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
restartIfEnabled?: boolean;
|
||||
reason?: string;
|
||||
preserveStreamAudioPreferences?: boolean;
|
||||
keepShareTracking?: boolean;
|
||||
},
|
||||
publishOptions?: TrackPublishOptions,
|
||||
): Promise<void> {
|
||||
@@ -888,6 +968,11 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
if (enabled) {
|
||||
recordScreenShareStarted();
|
||||
}
|
||||
const leavesShareRunning = enabled ? options?.restartIfEnabled === true : options?.keepShareTracking === true;
|
||||
const generation = this.screenShareTrackingGeneration;
|
||||
if (leavesShareRunning) {
|
||||
this.screenShareTrackingHolds += 1;
|
||||
}
|
||||
try {
|
||||
await this.controllerRouting.setEnabled(room, enabled, options, publishOptions);
|
||||
} catch (error) {
|
||||
@@ -895,6 +980,19 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
recordScreenShareStartError(error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (leavesShareRunning) {
|
||||
this.screenShareTrackingHolds -= 1;
|
||||
assert.ok(this.screenShareTrackingHolds >= 0, 'screen share tracking holds post-condition: never negative');
|
||||
if (
|
||||
enabled &&
|
||||
this.screenShareTrackingHolds === 0 &&
|
||||
generation === this.screenShareTrackingGeneration &&
|
||||
room?.localParticipant?.isScreenShareEnabled !== true
|
||||
) {
|
||||
this.endScreenShareTracking();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!enabled && options?.preserveStreamAudioPreferences !== true) {
|
||||
VoiceSettingsCommands.update({
|
||||
@@ -1040,7 +1138,7 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
const participant = room?.localParticipant;
|
||||
if (!participant?.isScreenShareEnabled) return false;
|
||||
const screenSharePublication = participant.getTrackPublication(Track.Source.ScreenShare);
|
||||
const screenShareTrack = screenSharePublication?.videoTrack;
|
||||
const screenShareTrack = screenSharePublication?.videoTrack as LocalVideoTrack | undefined;
|
||||
if (!screenShareTrack) {
|
||||
logger.warn('No active screen share track to update');
|
||||
return false;
|
||||
@@ -1054,7 +1152,9 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
if (options && Object.hasOwn(options, 'contentHint')) {
|
||||
screenShareTrack.mediaStreamTrack.contentHint = options.contentHint ?? '';
|
||||
}
|
||||
await screenShareTrack.setDegradationPreference(SCREEN_SHARE_DEGRADATION_PREFERENCE);
|
||||
await screenShareTrack.setDegradationPreference(
|
||||
resolveScreenShareDegradationPreference(ensureCommittedScreenShareTarget()),
|
||||
);
|
||||
await this.enforceScreenShareSenderParametersInternal(participant, publishOptions);
|
||||
this.ensureScreenShareKeepAliveSinkInternal(participant);
|
||||
updateLocalParticipantFromRoom(room);
|
||||
@@ -1087,7 +1187,9 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
this.cleanupActiveScreenShareEndListenerInternal();
|
||||
this.cancelEncoderVerificationInternal();
|
||||
this.endedScreenShareStopInFlight = null;
|
||||
this.cleanupScreenShareAudioRoutingState();
|
||||
this.screenShareTrackingGeneration += 1;
|
||||
this.cleanupScreenShareAudioCaptureRouting();
|
||||
this.endScreenShareTracking();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+49
-19
@@ -2,6 +2,7 @@
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {isDesktop, isNativeMacOS} from '@app/features/ui/utils/NativeUtils';
|
||||
import ScreenShareCodecNegotiation from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import {updateLocalParticipantFromRoom} from '@app/features/voice/engine/VoiceMediaEngineBridge';
|
||||
import {
|
||||
enforceLocalMediaPublicationCap,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
|
||||
import type {
|
||||
ScreenShareReconnectSnapshot,
|
||||
ScreenShareSourceSnapshot,
|
||||
VoiceEngineV2AppScreenShareExecutionAdapter,
|
||||
} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter';
|
||||
import {
|
||||
@@ -37,15 +39,14 @@ import {
|
||||
type CapturedScreenShareTracks,
|
||||
type DeviceScreenShareCaptureOptions,
|
||||
type DisplayScreenShareCaptureContext,
|
||||
getNegotiatedSenderVideoCodec,
|
||||
getReplacementScreenShareSettingsOptions,
|
||||
logger,
|
||||
type ScreenShareCaptureCleanupSnapshot,
|
||||
type SimulcastTrackInfoLike,
|
||||
stopMediaTrack,
|
||||
} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||
import ActiveScreenShareSource, {
|
||||
type PublishedScreenShareSource,
|
||||
} from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import type LocalVoiceState from '@app/features/voice/state/LocalVoiceState';
|
||||
import SoftwareEncoderWarning from '@app/features/voice/state/SoftwareEncoderWarning';
|
||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
@@ -111,7 +112,7 @@ interface EnforcedScreenSharePublish {
|
||||
track: LocalVideoTrack | undefined;
|
||||
}
|
||||
|
||||
interface ScreenShareReplacementSnapshot {
|
||||
interface ScreenShareReplacementSnapshot extends ScreenShareSourceSnapshot {
|
||||
videoTrack: MediaStreamTrack;
|
||||
videoHadProcessor: boolean;
|
||||
audioPublication?: LocalTrackPublication;
|
||||
@@ -119,9 +120,6 @@ interface ScreenShareReplacementSnapshot {
|
||||
audioMediaStreamTrack?: MediaStreamTrack;
|
||||
audioMuted: boolean;
|
||||
contentSource: ScreenShareContentSource;
|
||||
publishedSource: PublishedScreenShareSource | null;
|
||||
sourceId: string | null;
|
||||
isOwnWindow: boolean;
|
||||
publishOptions: TrackPublishOptions;
|
||||
}
|
||||
|
||||
@@ -355,7 +353,12 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
if (enabled) this.adapter.setStreamingPriorityInternal(true);
|
||||
try {
|
||||
const requestedPublishOptions = await this.adapter.getEffectivePublishOptionsInternal(enabled, publishOptions);
|
||||
await participant.setScreenShareEnabled(enabled, restOptions, requestedPublishOptions);
|
||||
await participant.setScreenShareEnabled(
|
||||
enabled,
|
||||
restOptions,
|
||||
requestedPublishOptions,
|
||||
SCREEN_SHARE_AUDIO_PUBLISH_OPTIONS,
|
||||
);
|
||||
const effectivePublishOptions = enabled
|
||||
? (await this.enforcePublishCodecPolicy(participant, requestedPublishOptions)).effectivePublishOptions
|
||||
: requestedPublishOptions;
|
||||
@@ -368,6 +371,7 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
applyState,
|
||||
playSound,
|
||||
);
|
||||
await this.adapter.reconcileScreenShareCodecInternal(room);
|
||||
} catch (error) {
|
||||
await this.handleSetEnabledFailure(room, participant, enabled, stopCleanupSnapshot, applyState, playSound, error);
|
||||
}
|
||||
@@ -445,9 +449,13 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
const track = (publication?.videoTrack ?? publication?.track) as LocalVideoTrack | undefined;
|
||||
const requested = effectivePublishOptions?.videoCodec;
|
||||
if (!publication || !track || !requested) return {effectivePublishOptions, track};
|
||||
const violation = findVideoPublishCodecPolicyViolation(requested, publication.options?.videoCodec ?? track.codec);
|
||||
const violation = findVideoPublishCodecPolicyViolation(
|
||||
requested,
|
||||
publication.options?.videoCodec ?? track.codec,
|
||||
getNegotiatedSenderVideoCodec(track.sender),
|
||||
);
|
||||
if (!violation) return {effectivePublishOptions, track};
|
||||
logger.warn('Screen share published a codec outside the publish policy', {...violation, corrections});
|
||||
logger.warn('Screen share published a codec the connection did not negotiate', {...violation, corrections});
|
||||
const mediaStreamTrack = track.mediaStreamTrack;
|
||||
const replaceAlreadyInFlight = this.adapter.isScreenSharePublicationReplaceInFlight();
|
||||
this.adapter.transitionScreenShareLifecycleInternal({type: 'share.publicationReplace.set', inFlight: true});
|
||||
@@ -624,6 +632,7 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
);
|
||||
}
|
||||
await this.adapter.applyPendingScreenShareRequestsInternal(room, participant);
|
||||
await this.adapter.reconcileScreenShareCodecInternal(room);
|
||||
}
|
||||
|
||||
private emitReplaceShareResult(
|
||||
@@ -797,6 +806,8 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
logger.warn('Screen share source track ended before codec republish', {codec});
|
||||
return false;
|
||||
}
|
||||
const selectedCodecAtEntry = ScreenShareCodecNegotiation.getSelectedCodec();
|
||||
const movedAwayFromSelection = selectedCodecAtEntry !== null && codec !== selectedCodecAtEntry;
|
||||
const previousOptions = ((publication as {options?: TrackPublishOptions}).options ?? {}) as TrackPublishOptions;
|
||||
const nextPublishOptions: TrackPublishOptions = {...previousOptions, videoCodec: codec};
|
||||
delete nextPublishOptions.backupCodec;
|
||||
@@ -880,6 +891,9 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
});
|
||||
} finally {
|
||||
await this.adapter.applyPendingScreenShareRequestsInternal(room, participant);
|
||||
if (!movedAwayFromSelection) {
|
||||
await this.adapter.reconcileScreenShareCodecInternal(room);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,18 +931,22 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
publishedSource: ActiveScreenShareSource.getPublishedSource(),
|
||||
sourceId: ActiveScreenShareSource.getSourceId(),
|
||||
isOwnWindow: ActiveScreenShareSource.isOwnWindow(),
|
||||
sourceDimensions: ActiveScreenShareSource.getSourceDimensions(),
|
||||
target: ActiveScreenShareSource.getTarget(),
|
||||
publishOptions,
|
||||
};
|
||||
}
|
||||
|
||||
private restorePublishedScreenShareSource(snapshot: ScreenShareReplacementSnapshot): void {
|
||||
private restorePublishedScreenShareSource(snapshot: ScreenShareSourceSnapshot): void {
|
||||
if (snapshot.publishedSource === null) {
|
||||
ActiveScreenShareSource.clear();
|
||||
return;
|
||||
} else {
|
||||
ActiveScreenShareSource.setPublishedSource(snapshot.publishedSource, snapshot.sourceId, {
|
||||
isOwnWindow: snapshot.isOwnWindow,
|
||||
});
|
||||
}
|
||||
ActiveScreenShareSource.setPublishedSource(snapshot.publishedSource, snapshot.sourceId, {
|
||||
isOwnWindow: snapshot.isOwnWindow,
|
||||
});
|
||||
ActiveScreenShareSource.setSourceDimensions(snapshot.sourceDimensions);
|
||||
ActiveScreenShareSource.setTarget(snapshot.target);
|
||||
}
|
||||
|
||||
private async restoreScreenShareReplacement(
|
||||
@@ -1631,6 +1649,7 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
actual: participant.isScreenShareEnabled,
|
||||
applyState: (actualNow) => applyScreenShareState(this.adapter, actualNow, false),
|
||||
onInactiveAfterSync: () => {
|
||||
ActiveScreenShareSource.clear();
|
||||
stopMediaTrack(snapshot.videoTrack);
|
||||
stopMediaTrack(snapshot.audioTrack);
|
||||
},
|
||||
@@ -1646,6 +1665,11 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
});
|
||||
}
|
||||
|
||||
private restoreReconnectTracking(snapshot: ScreenShareReconnectSnapshot): void {
|
||||
if (ActiveScreenShareSource.getTarget() !== null) return;
|
||||
this.restorePublishedScreenShareSource(snapshot.source);
|
||||
}
|
||||
|
||||
async restoreReconnect(
|
||||
room: Room | null,
|
||||
snapshot: ScreenShareReconnectSnapshot,
|
||||
@@ -1659,10 +1683,6 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
logger.warn('No participant');
|
||||
return false;
|
||||
}
|
||||
if (participant.isScreenShareEnabled) {
|
||||
await enforceLocalMediaPublicationCap(participant, VoiceTrackSource.ScreenShare);
|
||||
return this.finalizeReconnectAlreadyEnabled(room, participant);
|
||||
}
|
||||
const pendingVerdict = guardScreenShareEntry({
|
||||
pending: {
|
||||
active: this.adapter.isScreenSharePending,
|
||||
@@ -1670,8 +1690,14 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
},
|
||||
});
|
||||
if (pendingVerdict === 'share-pending') {
|
||||
this.restoreReconnectTracking(snapshot);
|
||||
return false;
|
||||
}
|
||||
if (participant.isScreenShareEnabled) {
|
||||
await enforceLocalMediaPublicationCap(participant, VoiceTrackSource.ScreenShare);
|
||||
this.restoreReconnectTracking(snapshot);
|
||||
return this.finalizeReconnectAlreadyEnabled(room, participant);
|
||||
}
|
||||
if (snapshot.videoTrack.readyState === 'ended') {
|
||||
logger.warn('Cannot restore screen share reconnect from ended video track');
|
||||
return false;
|
||||
@@ -1685,6 +1711,7 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
if (getLocalScreenSharePublications(participant).length > 0) {
|
||||
await this.adapter.cleanupLingeringScreenShareTracks(participant);
|
||||
}
|
||||
this.restoreReconnectTracking(snapshot);
|
||||
const requestedPublishOptions = await this.adapter.getEffectivePublishOptionsInternal(true, publishOptions);
|
||||
await participant.publishTrack(snapshot.videoTrack, {
|
||||
...requestedPublishOptions,
|
||||
@@ -1698,6 +1725,9 @@ export class VoiceEngineV2AppScreenShareLiveKitFlows {
|
||||
return true;
|
||||
} catch (error) {
|
||||
return this.handleRestoreReconnectFailure(room, participant, snapshot, videoPublished, error);
|
||||
} finally {
|
||||
await this.adapter.applyPendingScreenShareRequestsInternal(room, participant);
|
||||
await this.adapter.reconcileScreenShareCodecInternal(room);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+66
-64
@@ -2,27 +2,26 @@
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyScreenShareContentHint as applyScreenShareMotionContentHint,
|
||||
enforceScreenShareSenderParameters as enforceScreenShareSenderParametersForSender,
|
||||
getPreferredScreenShareCodec,
|
||||
applyScreenShareContentHint,
|
||||
enforceScreenShareSenderParameters,
|
||||
getScreenShareBackupSenders,
|
||||
logger,
|
||||
type SimulcastTrackInfoLike,
|
||||
resolveActiveScreenShareTarget,
|
||||
} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import {prepareHighFidelityScreenShareAudioTrack} from '@app/features/voice/utils/AudioPublishOptions';
|
||||
import {
|
||||
resolveScreenShareContentHintForContext,
|
||||
type ScreenShareContentSource,
|
||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import type {ScreenShareContentSource} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {
|
||||
type LocalAudioTrack,
|
||||
type LocalParticipant,
|
||||
type LocalVideoTrack,
|
||||
ParticipantEvent,
|
||||
type Room,
|
||||
RoomEvent,
|
||||
type Track as SdkTrack,
|
||||
Track,
|
||||
TrackEvent,
|
||||
type TrackPublishOptions,
|
||||
type VideoCodec,
|
||||
} from 'livekit-client';
|
||||
|
||||
@@ -123,29 +122,15 @@ export class VoiceEngineV2AppScreenShareTrackPlumbing {
|
||||
preferredTrack?: LocalVideoTrack,
|
||||
): void {
|
||||
const publication = preferredTrack ? undefined : participant.getTrackPublication(Track.Source.ScreenShare);
|
||||
const track = preferredTrack ?? publication?.videoTrack;
|
||||
const track = (preferredTrack ?? publication?.videoTrack) as LocalVideoTrack | undefined;
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
this.applyContentHintToMediaTrack(track.mediaStreamTrack, contentSource);
|
||||
}
|
||||
|
||||
applyContentHintToMediaTrack(
|
||||
mediaStreamTrack: MediaStreamTrack | undefined,
|
||||
contentSource: ScreenShareContentSource = this.host.getActiveContentSource(),
|
||||
): void {
|
||||
if (!mediaStreamTrack) return;
|
||||
const hint = resolveScreenShareContentHintForContext(
|
||||
VoiceSettings.getScreenShareContentHintOverride(),
|
||||
getPreferredScreenShareCodec(),
|
||||
contentSource,
|
||||
VoiceSettings.getStreamingMode(),
|
||||
);
|
||||
if (!hint) {
|
||||
mediaStreamTrack.contentHint = '';
|
||||
return;
|
||||
const {contentHint} = resolveActiveScreenShareTarget(contentSource);
|
||||
applyScreenShareContentHint(track.mediaStreamTrack, contentHint);
|
||||
for (const backup of getScreenShareBackupSenders(track)) {
|
||||
applyScreenShareContentHint(backup.sender.track ?? undefined, contentHint);
|
||||
}
|
||||
applyScreenShareMotionContentHint(mediaStreamTrack, hint);
|
||||
}
|
||||
|
||||
applyAudioContentHint(participant: LocalParticipant): void {
|
||||
@@ -156,39 +141,51 @@ export class VoiceEngineV2AppScreenShareTrackPlumbing {
|
||||
prepareHighFidelityScreenShareAudioTrack(track);
|
||||
}
|
||||
|
||||
async enforceSenderParameters(participant: LocalParticipant, publishOptions?: TrackPublishOptions): Promise<void> {
|
||||
const publication = participant.getTrackPublication(Track.Source.ScreenShare);
|
||||
const track = publication?.videoTrack as LocalVideoTrack | undefined;
|
||||
await this.enforceTrackSenderParameters(track, publishOptions);
|
||||
this.bindSenderParameterReapply(participant, publishOptions, track);
|
||||
async enforceSenderParameters(
|
||||
participant: LocalParticipant,
|
||||
publishedCodec: VideoCodec | undefined,
|
||||
preferredTrack?: LocalVideoTrack,
|
||||
): Promise<boolean> {
|
||||
const publication = preferredTrack ? undefined : participant.getTrackPublication(Track.Source.ScreenShare);
|
||||
const track = preferredTrack ?? (publication?.videoTrack as LocalVideoTrack | undefined);
|
||||
if (!track) {
|
||||
logger.warn('No screen share track found for sender parameter enforcement');
|
||||
return false;
|
||||
}
|
||||
const applied = await this.enforceTrackSenderParameters(track, publishedCodec);
|
||||
this.bindSenderParameterReapply(participant, track, publishedCodec);
|
||||
return applied;
|
||||
}
|
||||
|
||||
async enforceTrackSenderParameters(
|
||||
track: LocalVideoTrack | undefined,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
async enforceTrackSenderParameters(track: LocalVideoTrack, publishedCodec: VideoCodec | undefined): Promise<boolean> {
|
||||
const target = ActiveScreenShareSource.getTarget();
|
||||
if (target === null) return false;
|
||||
let applied = track.sender
|
||||
? (await enforceScreenShareSenderParameters(track.sender, target, {publishedCodec})).applied
|
||||
: false;
|
||||
for (const backup of getScreenShareBackupSenders(track)) {
|
||||
const codecOverride = isVideoCodecValue(backup.codec) ? backup.codec : undefined;
|
||||
const backupApplied = await enforceScreenShareSenderParameters(backup.sender, target, {
|
||||
codecOverride,
|
||||
publishedCodec,
|
||||
});
|
||||
applied = applied && backupApplied.applied;
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
private async enforceBackupSenderParameters(
|
||||
sender: RTCRtpSender,
|
||||
codecOverride: VideoCodec | undefined,
|
||||
publishedCodec: VideoCodec | undefined,
|
||||
): Promise<void> {
|
||||
let applied = await enforceScreenShareSenderParametersForSender(track?.sender, publishOptions);
|
||||
const simulcastCodecs = (
|
||||
track as
|
||||
| (LocalVideoTrack & {
|
||||
simulcastCodecs?: Map<unknown, SimulcastTrackInfoLike>;
|
||||
})
|
||||
| undefined
|
||||
)?.simulcastCodecs;
|
||||
if (simulcastCodecs?.size) {
|
||||
for (const [codec, simulcastTrackInfo] of simulcastCodecs) {
|
||||
const codecOverride = isVideoCodecValue(codec) ? codec : undefined;
|
||||
const backupApplied = await enforceScreenShareSenderParametersForSender(
|
||||
simulcastTrackInfo.sender,
|
||||
publishOptions,
|
||||
codecOverride,
|
||||
);
|
||||
applied = applied || backupApplied;
|
||||
}
|
||||
}
|
||||
if (!applied) {
|
||||
logger.warn('No sender found for screen share sender parameter enforcement');
|
||||
}
|
||||
applyScreenShareContentHint(
|
||||
sender.track ?? undefined,
|
||||
resolveActiveScreenShareTarget(this.host.getActiveContentSource()).contentHint,
|
||||
);
|
||||
const target = ActiveScreenShareSource.getTarget();
|
||||
if (target === null) return;
|
||||
await enforceScreenShareSenderParameters(sender, target, {codecOverride, publishedCodec});
|
||||
}
|
||||
|
||||
cleanupSenderParameterReapply(): void {
|
||||
@@ -198,21 +195,26 @@ export class VoiceEngineV2AppScreenShareTrackPlumbing {
|
||||
|
||||
bindSenderParameterReapply(
|
||||
participant: LocalParticipant,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
preferredTrack?: LocalVideoTrack,
|
||||
track: LocalVideoTrack,
|
||||
publishedCodec: VideoCodec | undefined,
|
||||
): void {
|
||||
this.cleanupSenderParameterReapply();
|
||||
const publication = preferredTrack ? undefined : participant.getTrackPublication(Track.Source.ScreenShare);
|
||||
const track = preferredTrack ?? (publication?.videoTrack as LocalVideoTrack | undefined);
|
||||
if (!track) return;
|
||||
const reapply = (): void => {
|
||||
void this.enforceTrackSenderParameters(track, publishOptions).catch((error) => {
|
||||
void this.enforceTrackSenderParameters(track, publishedCodec).catch((error) => {
|
||||
logger.warn('Failed to reapply screen share sender parameters after track restart', {error});
|
||||
});
|
||||
};
|
||||
const onLocalSenderCreated = (sender: RTCRtpSender, senderTrack: SdkTrack, codec?: VideoCodec): void => {
|
||||
if (senderTrack !== track || sender === track.sender) return;
|
||||
void this.enforceBackupSenderParameters(sender, codec, publishedCodec).catch((error) => {
|
||||
logger.warn('Failed to apply screen share sender parameters to a backup sender', {error, codec});
|
||||
});
|
||||
};
|
||||
track.on(TrackEvent.Restarted, reapply);
|
||||
participant.on(ParticipantEvent.LocalSenderCreated, onLocalSenderCreated);
|
||||
this.senderParameterDisposer = () => {
|
||||
track.off(TrackEvent.Restarted, reapply);
|
||||
participant.off(ParticipantEvent.LocalSenderCreated, onLocalSenderCreated);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import {
|
||||
stopMediaTrack,
|
||||
stopUnselectedStreamTracks,
|
||||
} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||
import {rememberCapturedDisplayAudioTrack} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
||||
import {ScreenShareAudioCaptureError} from '@app/features/voice/utils/ScreenShareAudioCaptureError';
|
||||
import type {ScreenShareCaptureOptions} from 'livekit-client';
|
||||
|
||||
@@ -131,6 +132,7 @@ export async function createDisplayScreenShareTracks(
|
||||
.catch(() => undefined);
|
||||
const capturedAudioTrack = stream.getAudioTracks()[0];
|
||||
const audioTrack = capturedAudioTrack?.readyState === 'live' ? capturedAudioTrack : undefined;
|
||||
rememberCapturedDisplayAudioTrack(capturedAudioTrack, Boolean(capturedAudioTrack) && !audioTrack);
|
||||
if (captureContext?.requireAudio && !audioTrack) {
|
||||
throw new ScreenShareAudioCaptureError({
|
||||
sourceId: captureContext.sourceId,
|
||||
|
||||
@@ -3,24 +3,28 @@
|
||||
import {getDesktopTroubleshootingSettings} from '@app/features/devtools/utils/DesktopTroubleshootingUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import ScreenShareCodecNegotiation from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import {noteAppliedScreenShareFrameRate} from '@app/features/voice/engine/ScreenShareUnderperformance';
|
||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import SoftwareEncoderWarning from '@app/features/voice/state/SoftwareEncoderWarning';
|
||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
adjustScreenShareEncodingForCodec,
|
||||
getCodecCapabilityReport,
|
||||
LIVEKIT_SUPPORTED_CODECS,
|
||||
markScreenShareCodecSoftwareEncodeObserved,
|
||||
resolveVideoPublishCodecPolicy,
|
||||
type VideoPublishCodecPolicy,
|
||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {getGpuEncoderReportSync, loadGpuEncoderReport} from '@app/features/voice/utils/GpuEncoderCapabilities';
|
||||
import {loadGpuEncoderReport} from '@app/features/voice/utils/GpuEncoderCapabilities';
|
||||
import {loadNativeHardwareEncoderCapabilities} from '@app/features/voice/utils/NativeHardwareEncoderCapabilities';
|
||||
import {
|
||||
capScreenShareEncodingToDimensions,
|
||||
getScreenShareEncoding,
|
||||
resolveStreamingModeSettings,
|
||||
SCREEN_SHARE_DEGRADATION_PREFERENCE,
|
||||
buildScreenShareSenderParameters,
|
||||
resolveScreenShareDegradationPreference,
|
||||
resolveScreenShareLayering,
|
||||
resolveScreenShareSenderCodec,
|
||||
resolveScreenShareTarget,
|
||||
SCREEN_SHARE_MAX_VIDEO_BITRATE_BPS,
|
||||
type ScreenShareContext,
|
||||
type ScreenShareLayering,
|
||||
type ScreenShareTarget,
|
||||
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {ScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError';
|
||||
import {classifyVideoEncoderAcceleration} from '@app/features/voice/utils/VideoAccelerationClassification';
|
||||
@@ -29,6 +33,7 @@ import {
|
||||
BackupCodecPolicy,
|
||||
type LocalParticipant,
|
||||
type LocalTrackPublication,
|
||||
type LocalVideoTrack,
|
||||
type ScreenShareCaptureOptions,
|
||||
Track,
|
||||
type TrackPublishOptions,
|
||||
@@ -38,7 +43,8 @@ import {
|
||||
|
||||
export const logger = new Logger('VoiceEngineV2ScreenShareSupport');
|
||||
const GPU_ENCODER_REPORT_START_TIMEOUT_MS = 500;
|
||||
const SCREEN_SHARE_FULL_RESOLUTION_SCALE = 1;
|
||||
const SCREEN_SHARE_MONITOR_FIRST_TICK_MS = 2500;
|
||||
const SCREEN_SHARE_MONITOR_TICK_MS = 2000;
|
||||
|
||||
export interface DeviceScreenShareCaptureOptions {
|
||||
videoDeviceId?: string;
|
||||
@@ -185,8 +191,7 @@ export async function releaseScreenShareCaptureCleanup(snapshot: ScreenShareCapt
|
||||
}
|
||||
}
|
||||
|
||||
function clampScreenShareEncoding(encoding: VideoEncoding | undefined): VideoEncoding | undefined {
|
||||
if (!encoding) return undefined;
|
||||
function clampScreenShareEncoding(encoding: VideoEncoding): VideoEncoding {
|
||||
return {
|
||||
...encoding,
|
||||
maxBitrate:
|
||||
@@ -197,54 +202,59 @@ function clampScreenShareEncoding(encoding: VideoEncoding | undefined): VideoEnc
|
||||
};
|
||||
}
|
||||
|
||||
export function getEffectiveScreenShareEncoding(publishOptions?: TrackPublishOptions): VideoEncoding | undefined {
|
||||
const preferredVideoCodec = publishOptions?.videoCodec ?? getPreferredScreenShareCodec();
|
||||
let screenShareEncoding = publishOptions?.screenShareEncoding;
|
||||
if (!screenShareEncoding) {
|
||||
const settings = resolveStreamingModeSettings(
|
||||
VoiceSettings.getStreamingMode(),
|
||||
VoiceSettings.getScreenshareResolution(),
|
||||
VoiceSettings.getVideoFrameRate(),
|
||||
hasHigherVideoQuality(),
|
||||
);
|
||||
screenShareEncoding = getScreenShareEncoding(settings.resolution, settings.frameRate);
|
||||
}
|
||||
if (!screenShareEncoding) return undefined;
|
||||
return clampScreenShareEncoding(adjustScreenShareEncodingForCodec(screenShareEncoding, preferredVideoCodec));
|
||||
export function resolveActiveScreenShareContext(): ScreenShareContext {
|
||||
return ActiveScreenShareSource.getShareContext() ?? 'display';
|
||||
}
|
||||
|
||||
function getScreenShareScalabilityModeForCodec(codec: VideoCodec): TrackPublishOptions['scalabilityMode'] | undefined {
|
||||
if (codec !== 'av1' && codec !== 'vp9') return undefined;
|
||||
const preference = VoiceSettings.getScreenShareScalabilityModeOverride();
|
||||
if (preference === 'single_layer') return 'L1T1';
|
||||
if (preference === 'temporal') return 'L1T3';
|
||||
if (preference === 'spatial') return 'L3T3_KEY';
|
||||
const streamingMode = VoiceSettings.getStreamingMode();
|
||||
if (streamingMode === 'gaming') return 'L1T3';
|
||||
if (streamingMode === 'screenshare') return 'L3T3_KEY';
|
||||
const quality = VoiceSettings.getScreenShareSoftwareQualityOverride();
|
||||
if (!quality) return undefined;
|
||||
const gpuReport = getGpuEncoderReportSync();
|
||||
const softwareBiased = VoiceSettings.getScreenShareEncoderMode() === 'software' || gpuReport?.[codec] === 'software';
|
||||
if (!softwareBiased) return undefined;
|
||||
if (quality === 'realtime') return 'L1T1';
|
||||
if (quality === 'balanced') return 'L1T3';
|
||||
return 'L3T3_KEY';
|
||||
export function resolveConfiguredScreenShareTarget(
|
||||
context: ScreenShareContext,
|
||||
sourceDimensions: {width: number; height: number} | null,
|
||||
): ScreenShareTarget {
|
||||
return resolveScreenShareTarget({
|
||||
mode: VoiceSettings.getStreamingMode(),
|
||||
storedResolution: VoiceSettings.getScreenshareResolution(),
|
||||
storedFrameRate: VoiceSettings.getVideoFrameRate(),
|
||||
entitled: hasHigherVideoQuality(),
|
||||
context,
|
||||
sourceDimensions,
|
||||
hintSetting: VoiceSettings.getScreenShareContentHint(),
|
||||
});
|
||||
}
|
||||
|
||||
function isSvcScreenShareCodec(codec: VideoCodec): boolean {
|
||||
return codec === 'av1' || codec === 'vp9';
|
||||
export function resolveActiveScreenShareTarget(
|
||||
context: ScreenShareContext = resolveActiveScreenShareContext(),
|
||||
): ScreenShareTarget {
|
||||
return resolveConfiguredScreenShareTarget(context, ActiveScreenShareSource.getSourceDimensions());
|
||||
}
|
||||
|
||||
function getConfiguredBackupCodecForPrimary(primaryCodec: VideoCodec):
|
||||
| false
|
||||
| {
|
||||
codec: 'h264';
|
||||
}
|
||||
| undefined {
|
||||
if (VoiceSettings.getScreenShareBackupCodecModeOverride() !== 'h264_simulcast') return undefined;
|
||||
if (primaryCodec === 'h264' || primaryCodec === 'vp8') return false;
|
||||
return {codec: 'h264'};
|
||||
export function ensureCommittedScreenShareTarget(): ScreenShareTarget {
|
||||
const committed = ActiveScreenShareSource.getTarget();
|
||||
if (committed !== null) return committed;
|
||||
const target = resolveActiveScreenShareTarget();
|
||||
ActiveScreenShareSource.setTarget(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function getStatsKind(
|
||||
report: {kind?: string; mediaType?: string; codecId?: string},
|
||||
reportsById: ReadonlyMap<string, {mimeType?: string}>,
|
||||
): string | undefined {
|
||||
if (report.kind || report.mediaType) return report.kind ?? report.mediaType;
|
||||
if (!report.codecId) return undefined;
|
||||
const codec = reportsById.get(report.codecId);
|
||||
return codec?.mimeType?.startsWith('video/') ? 'video' : undefined;
|
||||
}
|
||||
|
||||
function resolveScreenShareEncoding(target: ScreenShareTarget, publishOptions?: TrackPublishOptions): VideoEncoding {
|
||||
if (publishOptions?.screenShareEncoding) return clampScreenShareEncoding(publishOptions.screenShareEncoding);
|
||||
return {maxBitrate: target.maxBitrate, maxFramerate: target.frameRate, priority: 'high'};
|
||||
}
|
||||
|
||||
function getScreenShareLayeringForCodec(codec: VideoCodec | undefined): ScreenShareLayering {
|
||||
return resolveScreenShareLayering({
|
||||
codec,
|
||||
svcSetting: VoiceSettings.getScreenShareScalabilityModeOverride(),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBackupCodecWithinPolicy(
|
||||
@@ -256,23 +266,13 @@ function resolveBackupCodecWithinPolicy(
|
||||
return configured.codec !== policy.primary && policy.allowed.includes(configured.codec) ? configured : false;
|
||||
}
|
||||
|
||||
function getEffectiveScreenShareScalabilityMode(
|
||||
codec: VideoCodec,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
options: {respectExplicit?: boolean} = {},
|
||||
): TrackPublishOptions['scalabilityMode'] | undefined {
|
||||
if (options.respectExplicit !== false && publishOptions?.scalabilityMode) return publishOptions.scalabilityMode;
|
||||
return getScreenShareScalabilityModeForCodec(codec);
|
||||
}
|
||||
|
||||
async function waitForGpuEncoderReportForPublish(options?: ScreenSharePublishOptionsResolutionOptions): Promise<void> {
|
||||
async function waitForScreenShareCapabilityLoads(): Promise<'loaded' | 'timeout'> {
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
options?.onCodecReadiness?.('loading');
|
||||
const timeout = new Promise<'timeout'>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve('timeout'), GPU_ENCODER_REPORT_START_TIMEOUT_MS);
|
||||
});
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
return await Promise.race([
|
||||
Promise.all([
|
||||
loadGpuEncoderReport(),
|
||||
loadNativeHardwareEncoderCapabilities(),
|
||||
@@ -280,14 +280,6 @@ async function waitForGpuEncoderReportForPublish(options?: ScreenSharePublishOpt
|
||||
]).then(() => 'loaded' as const),
|
||||
timeout,
|
||||
]);
|
||||
if (result === 'timeout') {
|
||||
options?.onCodecReadiness?.('timeout');
|
||||
logger.warn('GPU encoder report timed out before screen share publish; using cached codec capabilities', {
|
||||
timeoutMs: GPU_ENCODER_REPORT_START_TIMEOUT_MS,
|
||||
});
|
||||
} else {
|
||||
options?.onCodecReadiness?.('ready');
|
||||
}
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -295,6 +287,19 @@ async function waitForGpuEncoderReportForPublish(options?: ScreenSharePublishOpt
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForGpuEncoderReportForPublish(options?: ScreenSharePublishOptionsResolutionOptions): Promise<void> {
|
||||
options?.onCodecReadiness?.('loading');
|
||||
const result = await waitForScreenShareCapabilityLoads();
|
||||
if (result === 'timeout') {
|
||||
options?.onCodecReadiness?.('timeout');
|
||||
logger.warn('GPU encoder report timed out before screen share publish; using cached codec capabilities', {
|
||||
timeoutMs: GPU_ENCODER_REPORT_START_TIMEOUT_MS,
|
||||
});
|
||||
} else {
|
||||
options?.onCodecReadiness?.('ready');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEffectivePublishOptions(
|
||||
enabled: boolean,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
@@ -303,25 +308,21 @@ export async function getEffectivePublishOptions(
|
||||
if (!enabled) {
|
||||
return publishOptions;
|
||||
}
|
||||
const target = ensureCommittedScreenShareTarget();
|
||||
await waitForGpuEncoderReportForPublish(options);
|
||||
const policy = resolveVideoPublishCodecPolicy(publishOptions?.videoCodec ?? getPreferredScreenShareCodec());
|
||||
const preferredVideoCodec = policy.primary;
|
||||
const backupCodec = resolveBackupCodecWithinPolicy(
|
||||
publishOptions?.backupCodec ?? getConfiguredBackupCodecForPrimary(preferredVideoCodec),
|
||||
policy,
|
||||
);
|
||||
const backupCodec = resolveBackupCodecWithinPolicy(publishOptions?.backupCodec, policy);
|
||||
const backupCodecPolicy =
|
||||
publishOptions?.backupCodecPolicy ?? (backupCodec ? BackupCodecPolicy.SIMULCAST : undefined);
|
||||
const scalabilityMode = getEffectiveScreenShareScalabilityMode(preferredVideoCodec, publishOptions, {
|
||||
respectExplicit: true,
|
||||
});
|
||||
const layering = getScreenShareLayeringForCodec(preferredVideoCodec);
|
||||
return {
|
||||
...publishOptions,
|
||||
videoCodec: preferredVideoCodec,
|
||||
screenShareEncoding: getEffectiveScreenShareEncoding({...publishOptions, videoCodec: preferredVideoCodec}),
|
||||
degradationPreference: SCREEN_SHARE_DEGRADATION_PREFERENCE,
|
||||
simulcast: isSvcScreenShareCodec(preferredVideoCodec) ? false : (publishOptions?.simulcast ?? false),
|
||||
scalabilityMode,
|
||||
screenShareEncoding: resolveScreenShareEncoding(target, publishOptions),
|
||||
degradationPreference: resolveScreenShareDegradationPreference(target),
|
||||
simulcast: layering.simulcast,
|
||||
scalabilityMode: layering.scalabilityMode,
|
||||
backupCodec,
|
||||
...(backupCodecPolicy !== undefined ? {backupCodecPolicy} : {}),
|
||||
};
|
||||
@@ -339,19 +340,12 @@ export function applyScreenShareContentHint(
|
||||
}
|
||||
}
|
||||
|
||||
function distributeScreenShareBitrate(
|
||||
encodings: ReadonlyArray<RTCRtpEncodingParameters>,
|
||||
maxBitrate: number,
|
||||
): Array<number> {
|
||||
if (encodings.length === 1) return [maxBitrate];
|
||||
const weights = encodings.map((encoding) =>
|
||||
typeof encoding.maxBitrate === 'number' && encoding.maxBitrate > 0 ? encoding.maxBitrate : 0,
|
||||
);
|
||||
if (weights.some((weight) => weight <= 0)) {
|
||||
return encodings.map(() => Math.floor(maxBitrate / encodings.length));
|
||||
}
|
||||
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
return weights.map((weight) => Math.floor((weight / total) * maxBitrate));
|
||||
export function getNegotiatedSenderVideoCodec(sender: RTCRtpSender | undefined): VideoCodec | undefined {
|
||||
const mimeType = sender?.getParameters().codecs?.[0]?.mimeType?.toLowerCase();
|
||||
if (!mimeType) return undefined;
|
||||
const codec = mimeType.replace('video/', '');
|
||||
const normalized = codec === 'av1x' ? 'av1' : codec;
|
||||
return LIVEKIT_SUPPORTED_CODECS.includes(normalized as VideoCodec) ? (normalized as VideoCodec) : undefined;
|
||||
}
|
||||
|
||||
export function getPublishedScreenShareMaxBitrateBps(
|
||||
@@ -367,55 +361,66 @@ export function getPublishedScreenShareMaxBitrateBps(
|
||||
return total > 0 ? total : undefined;
|
||||
}
|
||||
|
||||
function resolveScreenShareScaleResolutionDownBy(
|
||||
encodings: ReadonlyArray<RTCRtpEncodingParameters>,
|
||||
): number | undefined {
|
||||
const simulcastLadderOwnsScale = encodings.length > 1;
|
||||
if (simulcastLadderOwnsScale) return undefined;
|
||||
return SCREEN_SHARE_FULL_RESOLUTION_SCALE;
|
||||
export interface ScreenShareSenderCodecOptions {
|
||||
codecOverride?: VideoCodec;
|
||||
publishedCodec?: VideoCodec;
|
||||
}
|
||||
|
||||
function readScreenShareCaptureSize(sender: RTCRtpSender): {width: number; height: number} | null {
|
||||
const settings = sender.track?.getSettings();
|
||||
if (!settings?.width || !settings.height) return null;
|
||||
return {width: settings.width, height: settings.height};
|
||||
}
|
||||
|
||||
function isSameScreenShareEncoding(current: RTCRtpEncodingParameters, next: RTCRtpEncodingParameters): boolean {
|
||||
return (
|
||||
current.maxBitrate === next.maxBitrate &&
|
||||
current.maxFramerate === next.maxFramerate &&
|
||||
current.scaleResolutionDownBy === next.scaleResolutionDownBy &&
|
||||
current.priority === next.priority &&
|
||||
current.networkPriority === next.networkPriority &&
|
||||
current.scalabilityMode === next.scalabilityMode
|
||||
);
|
||||
}
|
||||
|
||||
interface ScreenShareSenderEnforcement {
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
export async function enforceScreenShareSenderParameters(
|
||||
sender: RTCRtpSender | undefined,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
codecOverride?: VideoCodec,
|
||||
): Promise<boolean> {
|
||||
if (!sender) return false;
|
||||
const preferredVideoCodec = codecOverride ?? publishOptions?.videoCodec ?? getPreferredScreenShareCodec();
|
||||
const effectiveEncoding = getEffectiveScreenShareEncoding({...publishOptions, videoCodec: preferredVideoCodec});
|
||||
const screenShareEncoding = effectiveEncoding
|
||||
? capScreenShareEncodingToDimensions(effectiveEncoding, sender.track?.getSettings())
|
||||
: undefined;
|
||||
const scalabilityMode = getEffectiveScreenShareScalabilityMode(preferredVideoCodec, publishOptions, {
|
||||
respectExplicit: !codecOverride || codecOverride === publishOptions?.videoCodec,
|
||||
});
|
||||
const senderTrackId = sender.track?.id;
|
||||
if (senderTrackId && screenShareEncoding?.maxFramerate !== undefined) {
|
||||
noteAppliedScreenShareFrameRate(senderTrackId, screenShareEncoding.maxFramerate);
|
||||
}
|
||||
sender: RTCRtpSender,
|
||||
target: ScreenShareTarget,
|
||||
options: ScreenShareSenderCodecOptions = {},
|
||||
): Promise<ScreenShareSenderEnforcement> {
|
||||
try {
|
||||
const codec = resolveScreenShareSenderCodec(
|
||||
options.codecOverride,
|
||||
getNegotiatedSenderVideoCodec(sender),
|
||||
options.publishedCodec,
|
||||
);
|
||||
const params = sender.getParameters();
|
||||
const encodings = params.encodings?.length ? params.encodings : [{}];
|
||||
const bitrates =
|
||||
screenShareEncoding?.maxBitrate !== undefined
|
||||
? distributeScreenShareBitrate(encodings, screenShareEncoding.maxBitrate)
|
||||
: undefined;
|
||||
const scaleResolutionDownBy = resolveScreenShareScaleResolutionDownBy(encodings);
|
||||
params.degradationPreference = SCREEN_SHARE_DEGRADATION_PREFERENCE;
|
||||
params.encodings = encodings.map((encoding, index) => ({
|
||||
...encoding,
|
||||
...(bitrates ? {maxBitrate: bitrates[index]} : {}),
|
||||
...(screenShareEncoding?.maxFramerate !== undefined ? {maxFramerate: screenShareEncoding.maxFramerate} : {}),
|
||||
...(scaleResolutionDownBy !== undefined ? {scaleResolutionDownBy} : {}),
|
||||
priority: screenShareEncoding?.priority ?? encoding.priority ?? 'high',
|
||||
networkPriority: screenShareEncoding?.priority ?? encoding.networkPriority ?? 'high',
|
||||
...(scalabilityMode ? {scalabilityMode} : {}),
|
||||
}));
|
||||
const next = buildScreenShareSenderParameters({
|
||||
encodings,
|
||||
target,
|
||||
capture: readScreenShareCaptureSize(sender),
|
||||
scalabilityMode: getScreenShareLayeringForCodec(codec).scalabilityMode,
|
||||
});
|
||||
const applied = next.encodings.map((encoding, index) =>
|
||||
encodings[index].active === false ? encodings[index] : encoding,
|
||||
);
|
||||
const inSync =
|
||||
params.degradationPreference === next.degradationPreference &&
|
||||
params.encodings?.length === applied.length &&
|
||||
applied.every((encoding, index) => isSameScreenShareEncoding(encodings[index], encoding));
|
||||
if (inSync) return {applied: true};
|
||||
params.degradationPreference = next.degradationPreference;
|
||||
params.encodings = applied;
|
||||
await sender.setParameters(params);
|
||||
return true;
|
||||
return {applied: true};
|
||||
} catch (error) {
|
||||
logger.warn('Failed to enforce screen share sender parameters', {error, screenShareEncoding, scalabilityMode});
|
||||
return false;
|
||||
logger.warn('Failed to enforce screen share sender parameters', {error, target});
|
||||
return {applied: false};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +460,6 @@ export function getReplacementScreenShareSettingsOptions(
|
||||
};
|
||||
}
|
||||
|
||||
const ENCODER_VERIFICATION_DELAY_MS = 2500;
|
||||
const UNKNOWN_ENCODER_IMPLEMENTATION = 'software encoder';
|
||||
|
||||
interface CodecStatsEntry {
|
||||
@@ -509,13 +513,6 @@ export type ScreenShareEncoderVerificationFailure =
|
||||
| (StalledVideoEncoderInfo & {reason: 'stalled'})
|
||||
| MissingExpectedVideoEncoderInfo;
|
||||
|
||||
function getStatsKind(report: OutboundVideoStatsEntry, codecs: Map<string, CodecStatsEntry>): string | undefined {
|
||||
if (report.kind || report.mediaType) return report.kind ?? report.mediaType;
|
||||
if (!report.codecId) return undefined;
|
||||
const codec = codecs.get(report.codecId);
|
||||
return codec?.mimeType?.startsWith('video/') ? 'video' : undefined;
|
||||
}
|
||||
|
||||
function codecMatchesTarget(mimeType: string | undefined, codec: VideoCodec | undefined): boolean {
|
||||
if (!codec) return true;
|
||||
if (!mimeType) return false;
|
||||
@@ -576,7 +573,9 @@ export function findSoftwareVideoEncoder(stats: RTCStatsReport, codec?: VideoCod
|
||||
}
|
||||
|
||||
export function shouldTriggerSoftwareEncoderWarning(codec: VideoCodec): boolean {
|
||||
if (VoiceSettings.getScreenShareEncoderMode() === 'software') return false;
|
||||
const encoderMode = VoiceSettings.getScreenShareEncoderMode();
|
||||
if (encoderMode === 'software') return false;
|
||||
if (encoderMode === 'hardware') return true;
|
||||
return getCodecCapabilityReport()[codec].hardwareAccelerated === 'hardware';
|
||||
}
|
||||
|
||||
@@ -653,47 +652,121 @@ export function findMissingExpectedVideoEncoder(
|
||||
};
|
||||
}
|
||||
|
||||
export function scheduleScreenShareEncoderVerification(
|
||||
getStats: () => Promise<RTCStatsReport>,
|
||||
codec: VideoCodec,
|
||||
onEncodeFailure?: (failure: ScreenShareEncoderVerificationFailure) => void,
|
||||
): NodeJS.Timeout {
|
||||
return setTimeout(async () => {
|
||||
try {
|
||||
const expectedHardware = getCodecCapabilityReport()[codec].hardwareAccelerated;
|
||||
const stats = await getStats();
|
||||
const stalledEncoder = findStalledVideoEncoder(stats, codec);
|
||||
if (stalledEncoder) {
|
||||
logger.warn('Screen share video encode is stalled', stalledEncoder);
|
||||
onEncodeFailure?.({...stalledEncoder, reason: 'stalled'});
|
||||
return;
|
||||
}
|
||||
const missingExpectedEncoder = findMissingExpectedVideoEncoder(stats, codec);
|
||||
if (missingExpectedEncoder) {
|
||||
logger.warn('Screen share video encoder is using a different codec than requested', missingExpectedEncoder);
|
||||
onEncodeFailure?.(missingExpectedEncoder);
|
||||
return;
|
||||
}
|
||||
const encoder = findSoftwareVideoEncoder(stats, codec);
|
||||
if (encoder) {
|
||||
logger.warn('Screen share is using a software encoder', {
|
||||
codec,
|
||||
encoderImplementation: encoder.implementation,
|
||||
powerEfficientEncoder: encoder.powerEfficientEncoder,
|
||||
expectedHardware,
|
||||
});
|
||||
const warnAboutSoftwareEncoder = shouldTriggerSoftwareEncoderWarning(codec);
|
||||
if (VoiceSettings.getScreenShareEncoderMode() !== 'software') {
|
||||
markScreenShareCodecSoftwareEncodeObserved(codec);
|
||||
}
|
||||
if (warnAboutSoftwareEncoder) {
|
||||
SoftwareEncoderWarning.triggerWarning(codec, encoder.implementation);
|
||||
}
|
||||
} else {
|
||||
logger.info('Screen share encoder verified', {codec});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to verify screen share encoder', {error});
|
||||
function countEncodedVideoFrames(stats: RTCStatsReport): number | null {
|
||||
const reportsById = new Map<string, CodecStatsEntry>();
|
||||
const reports: Array<OutboundVideoStatsEntry> = [];
|
||||
for (const raw of stats.values()) {
|
||||
const report = raw as CodecStatsEntry & OutboundVideoStatsEntry;
|
||||
if (typeof report.id === 'string') {
|
||||
reportsById.set(report.id, report);
|
||||
}
|
||||
}, ENCODER_VERIFICATION_DELAY_MS);
|
||||
if (report.type === 'outbound-rtp') {
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
let total: number | null = null;
|
||||
for (const report of reports) {
|
||||
if (getStatsKind(report, reportsById) !== 'video') continue;
|
||||
const framesEncoded = finiteNumber(report.framesEncoded);
|
||||
if (framesEncoded === null) continue;
|
||||
total = (total ?? 0) + framesEncoded;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function verifyScreenShareEncoderStart(
|
||||
stats: RTCStatsReport,
|
||||
codec: VideoCodec,
|
||||
onEncodeFailure: (failure: ScreenShareEncoderVerificationFailure) => void,
|
||||
): ScreenShareEncoderVerification {
|
||||
const expectedHardware = getCodecCapabilityReport()[codec].hardwareAccelerated;
|
||||
const stalledEncoder = findStalledVideoEncoder(stats, codec);
|
||||
if (stalledEncoder) {
|
||||
logger.warn('Screen share video encode is stalled', stalledEncoder);
|
||||
onEncodeFailure({...stalledEncoder, reason: 'stalled'});
|
||||
return 'failed';
|
||||
}
|
||||
const missingExpectedEncoder = findMissingExpectedVideoEncoder(stats, codec);
|
||||
if (missingExpectedEncoder) {
|
||||
logger.warn('Screen share video encoder is using a different codec than requested', missingExpectedEncoder);
|
||||
onEncodeFailure(missingExpectedEncoder);
|
||||
return 'failed';
|
||||
}
|
||||
if ((countEncodedVideoFrames(stats) ?? 0) === 0) return 'inconclusive';
|
||||
const encoder = findSoftwareVideoEncoder(stats, codec);
|
||||
if (!encoder) {
|
||||
logger.info('Screen share encoder verified', {codec});
|
||||
return 'verified';
|
||||
}
|
||||
logger.warn('Screen share is using a software encoder', {
|
||||
codec,
|
||||
encoderImplementation: encoder.implementation,
|
||||
powerEfficientEncoder: encoder.powerEfficientEncoder,
|
||||
expectedHardware,
|
||||
});
|
||||
const warnAboutSoftwareEncoder = shouldTriggerSoftwareEncoderWarning(codec);
|
||||
if (VoiceSettings.getScreenShareEncoderMode() !== 'software') {
|
||||
markScreenShareCodecSoftwareEncodeObserved(codec);
|
||||
}
|
||||
if (warnAboutSoftwareEncoder) {
|
||||
SoftwareEncoderWarning.triggerWarning(codec, encoder.implementation);
|
||||
}
|
||||
return 'verified';
|
||||
}
|
||||
|
||||
export function getScreenShareBackupSenders(track: LocalVideoTrack): Array<{sender: RTCRtpSender; codec: unknown}> {
|
||||
const simulcastCodecs = (track as LocalVideoTrack & {simulcastCodecs?: Map<unknown, SimulcastTrackInfoLike>})
|
||||
.simulcastCodecs;
|
||||
const senders: Array<{sender: RTCRtpSender; codec: unknown}> = [];
|
||||
for (const [codec, simulcastTrackInfo] of simulcastCodecs ?? []) {
|
||||
if (simulcastTrackInfo.sender) senders.push({sender: simulcastTrackInfo.sender, codec});
|
||||
}
|
||||
return senders;
|
||||
}
|
||||
|
||||
type ScreenShareEncoderVerification = 'failed' | 'verified' | 'inconclusive';
|
||||
|
||||
export interface ScreenShareEncoderMonitorOptions {
|
||||
track: LocalVideoTrack;
|
||||
codec: VideoCodec;
|
||||
onEncodeFailure: (failure: ScreenShareEncoderVerificationFailure) => void;
|
||||
reEnforce: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function startScreenShareEncoderMonitor(options: ScreenShareEncoderMonitorOptions): () => void {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let verified = false;
|
||||
let framesEncoded: number | null = null;
|
||||
const tick = async (): Promise<void> => {
|
||||
const sender = options.track.sender;
|
||||
if (!sender) return;
|
||||
const stats = await sender.getStats();
|
||||
const encoded = countEncodedVideoFrames(stats);
|
||||
ActiveScreenShareSource.setEncoding(encoded !== null && framesEncoded !== null && encoded > framesEncoded);
|
||||
framesEncoded = encoded;
|
||||
if (!verified) {
|
||||
const verification = verifyScreenShareEncoderStart(stats, options.codec, options.onEncodeFailure);
|
||||
if (verification === 'failed') return;
|
||||
verified = verification === 'verified';
|
||||
}
|
||||
await options.reEnforce();
|
||||
};
|
||||
const schedule = (delayMs: number): void => {
|
||||
timer = setTimeout(() => {
|
||||
void tick()
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to verify the screen share encoder', {error});
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) schedule(SCREEN_SHARE_MONITOR_TICK_MS);
|
||||
});
|
||||
}, delayMs);
|
||||
};
|
||||
schedule(SCREEN_SHARE_MONITOR_FIRST_TICK_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
ActiveScreenShareSource.setEncoding(false);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ScreenShareTarget} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import type {
|
||||
StreamSettingsShareContext,
|
||||
WindowShareAudioScope,
|
||||
@@ -18,6 +19,9 @@ class ActiveScreenShareSource {
|
||||
publishedSource: PublishedScreenShareSource | null = null;
|
||||
windowAudioScope: WindowShareAudioScope = 'window';
|
||||
pendingWindowAudioScope: WindowShareAudioScope | null = null;
|
||||
sourceDimensions: {width: number; height: number} | null = null;
|
||||
target: ScreenShareTarget | null = null;
|
||||
encoding = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -45,6 +49,27 @@ class ActiveScreenShareSource {
|
||||
return this.publishedSource;
|
||||
}
|
||||
|
||||
setSourceDimensions(dimensions: {width: number; height: number} | null): void {
|
||||
this.sourceDimensions = dimensions;
|
||||
}
|
||||
|
||||
getSourceDimensions(): {width: number; height: number} | null {
|
||||
return this.sourceDimensions;
|
||||
}
|
||||
|
||||
setTarget(target: ScreenShareTarget | null): void {
|
||||
this.target = target;
|
||||
this.encoding = false;
|
||||
}
|
||||
|
||||
setEncoding(encoding: boolean): void {
|
||||
this.encoding = encoding;
|
||||
}
|
||||
|
||||
getTarget(): ScreenShareTarget | null {
|
||||
return this.target;
|
||||
}
|
||||
|
||||
getWindowAudioScope(): WindowShareAudioScope {
|
||||
return this.windowAudioScope;
|
||||
}
|
||||
@@ -78,6 +103,9 @@ class ActiveScreenShareSource {
|
||||
this.publishedSource = null;
|
||||
this.windowAudioScope = 'window';
|
||||
this.pendingWindowAudioScope = null;
|
||||
this.sourceDimensions = null;
|
||||
this.target = null;
|
||||
this.encoding = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,9 @@ import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {makePersistent} from '@app/features/platform/utils/MobXPersistence';
|
||||
import type {
|
||||
CodecPreference,
|
||||
ScreenShareBackupCodecMode,
|
||||
ScreenShareContentHint,
|
||||
ScreenShareEncoderMode,
|
||||
ScreenShareScalabilityModePreference,
|
||||
ScreenShareSoftwareQuality,
|
||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {hasHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
||||
import {areVoiceBackgroundsAvailable} from '@app/features/voice/utils/VoiceBackgroundAvailability';
|
||||
@@ -60,9 +58,7 @@ export const CAMERA_EFFECT_STRENGTH_MAX = 100;
|
||||
export const CAMERA_EFFECT_STRENGTH_DEFAULT = 50;
|
||||
export const DEFAULT_SCREEN_SHARE_CONTENT_HINT: ScreenShareContentHint = 'text';
|
||||
export const DEFAULT_SCREEN_SHARE_ENCODER_MODE: ScreenShareEncoderMode = 'auto';
|
||||
export const DEFAULT_SCREEN_SHARE_SOFTWARE_QUALITY: ScreenShareSoftwareQuality = 'balanced';
|
||||
export const DEFAULT_SCREEN_SHARE_SCALABILITY_MODE: ScreenShareScalabilityModePreference = 'auto';
|
||||
export const DEFAULT_SCREEN_SHARE_BACKUP_CODEC_MODE: ScreenShareBackupCodecMode = 'off';
|
||||
|
||||
type VoiceSettingsUpdate = Partial<{
|
||||
inputDeviceId: string;
|
||||
@@ -106,9 +102,7 @@ type VoiceSettingsUpdate = Partial<{
|
||||
screenShareHevcOptIn: boolean;
|
||||
screenShareContentHint: ScreenShareContentHint;
|
||||
screenShareEncoderMode: ScreenShareEncoderMode;
|
||||
screenShareSoftwareQuality: ScreenShareSoftwareQuality;
|
||||
screenShareScalabilityMode: ScreenShareScalabilityModePreference;
|
||||
screenShareBackupCodecMode: ScreenShareBackupCodecMode;
|
||||
vadThreshold: number;
|
||||
vadAutoSensitivity: boolean;
|
||||
vadEnhanced: boolean;
|
||||
@@ -312,6 +306,27 @@ function applyScreenShareHevcOptOutMigrationV1(parsed: Record<string, unknown>):
|
||||
return true;
|
||||
}
|
||||
|
||||
export function applyScreenShareSoftwareQualityRetiredMigrationV1(parsed: Record<string, unknown>): boolean {
|
||||
const stored = parsed.screenShareSoftwareQualityPrefV2;
|
||||
delete parsed.screenShareSoftwareQualityPrefV2;
|
||||
if (parsed.screenShareSoftwareQualityRetiredV1 === true) {
|
||||
return stored !== undefined;
|
||||
}
|
||||
if (stored === 'realtime' && validateScreenShareScalabilityMode(parsed.screenShareScalabilityModePrefV2) === 'auto') {
|
||||
parsed.screenShareScalabilityModePrefV2 = 'single_layer';
|
||||
}
|
||||
parsed.screenShareSoftwareQualityRetiredV1 = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function applyScreenShareBackupCodecModeRetiredMigrationV1(parsed: Record<string, unknown>): boolean {
|
||||
if (parsed.screenShareBackupCodecModePrefV2 === undefined) {
|
||||
return false;
|
||||
}
|
||||
delete parsed.screenShareBackupCodecModePrefV2;
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyVideoQualityNormalisationMigration(parsed: Record<string, unknown>): boolean {
|
||||
let changed = false;
|
||||
if (parsed.screenshareResolution !== undefined) {
|
||||
@@ -419,10 +434,9 @@ class VoiceSettings {
|
||||
screenShareHevcOptOutMigratedV1 = false;
|
||||
screenShareContentHintPrefV2: ScreenShareContentHint = DEFAULT_SCREEN_SHARE_CONTENT_HINT;
|
||||
screenShareContentHintDefaultMigratedV1 = false;
|
||||
screenShareSoftwareQualityRetiredV1 = false;
|
||||
screenShareEncoderModePrefV2: ScreenShareEncoderMode = DEFAULT_SCREEN_SHARE_ENCODER_MODE;
|
||||
screenShareSoftwareQualityPrefV2: ScreenShareSoftwareQuality = DEFAULT_SCREEN_SHARE_SOFTWARE_QUALITY;
|
||||
screenShareScalabilityModePrefV2: ScreenShareScalabilityModePreference = DEFAULT_SCREEN_SHARE_SCALABILITY_MODE;
|
||||
screenShareBackupCodecModePrefV2: ScreenShareBackupCodecMode = DEFAULT_SCREEN_SHARE_BACKUP_CODEC_MODE;
|
||||
vadThreshold = 50;
|
||||
vadAutoSensitivity = true;
|
||||
vadEnhanced = true;
|
||||
@@ -492,12 +506,8 @@ class VoiceSettings {
|
||||
getScreenShareContentHint: false,
|
||||
getScreenShareContentHintOverride: false,
|
||||
getScreenShareEncoderMode: false,
|
||||
getScreenShareSoftwareQuality: false,
|
||||
getScreenShareSoftwareQualityOverride: false,
|
||||
getScreenShareScalabilityMode: false,
|
||||
getScreenShareScalabilityModeOverride: false,
|
||||
getScreenShareBackupCodecMode: false,
|
||||
getScreenShareBackupCodecModeOverride: false,
|
||||
getVadThreshold: false,
|
||||
getVadAutoSensitivity: false,
|
||||
getVadEnhanced: false,
|
||||
@@ -547,6 +557,8 @@ class VoiceSettings {
|
||||
changed = applyVideoQualityNormalisationMigration(parsed) || changed;
|
||||
changed = applyScreenShareAv1OptOutMigrationV1(parsed) || changed;
|
||||
changed = applyScreenShareHevcOptOutMigrationV1(parsed) || changed;
|
||||
changed = applyScreenShareSoftwareQualityRetiredMigrationV1(parsed) || changed;
|
||||
changed = applyScreenShareBackupCodecModeRetiredMigrationV1(parsed) || changed;
|
||||
if (changed) {
|
||||
AppStorage.setItem('VoiceSettings', JSON.stringify(parsed));
|
||||
}
|
||||
@@ -612,10 +624,9 @@ class VoiceSettings {
|
||||
'screenShareHevcOptOutMigratedV1',
|
||||
'screenShareContentHintPrefV2',
|
||||
'screenShareContentHintDefaultMigratedV1',
|
||||
'screenShareSoftwareQualityRetiredV1',
|
||||
'screenShareEncoderModePrefV2',
|
||||
'screenShareSoftwareQualityPrefV2',
|
||||
'screenShareScalabilityModePrefV2',
|
||||
'screenShareBackupCodecModePrefV2',
|
||||
'vadThreshold',
|
||||
'vadAutoSensitivity',
|
||||
'vadEnhanced',
|
||||
@@ -710,14 +721,6 @@ class VoiceSettings {
|
||||
this.screenShareEncoderModePrefV2 = value;
|
||||
}
|
||||
|
||||
get screenShareSoftwareQuality(): ScreenShareSoftwareQuality {
|
||||
return this.screenShareSoftwareQualityPrefV2;
|
||||
}
|
||||
|
||||
set screenShareSoftwareQuality(value: ScreenShareSoftwareQuality) {
|
||||
this.screenShareSoftwareQualityPrefV2 = value;
|
||||
}
|
||||
|
||||
get screenShareScalabilityMode(): ScreenShareScalabilityModePreference {
|
||||
return this.screenShareScalabilityModePrefV2;
|
||||
}
|
||||
@@ -726,14 +729,6 @@ class VoiceSettings {
|
||||
this.screenShareScalabilityModePrefV2 = value;
|
||||
}
|
||||
|
||||
get screenShareBackupCodecMode(): ScreenShareBackupCodecMode {
|
||||
return this.screenShareBackupCodecModePrefV2;
|
||||
}
|
||||
|
||||
set screenShareBackupCodecMode(value: ScreenShareBackupCodecMode) {
|
||||
this.screenShareBackupCodecModePrefV2 = value;
|
||||
}
|
||||
|
||||
getInputDeviceId(): string {
|
||||
return this.inputDeviceId;
|
||||
}
|
||||
@@ -957,16 +952,6 @@ class VoiceSettings {
|
||||
return this.screenShareEncoderMode;
|
||||
}
|
||||
|
||||
getScreenShareSoftwareQuality(): ScreenShareSoftwareQuality {
|
||||
return this.screenShareSoftwareQuality;
|
||||
}
|
||||
|
||||
getScreenShareSoftwareQualityOverride(): ScreenShareSoftwareQuality | undefined {
|
||||
return this.screenShareSoftwareQuality === DEFAULT_SCREEN_SHARE_SOFTWARE_QUALITY
|
||||
? undefined
|
||||
: this.screenShareSoftwareQuality;
|
||||
}
|
||||
|
||||
getScreenShareScalabilityMode(): ScreenShareScalabilityModePreference {
|
||||
return this.screenShareScalabilityMode;
|
||||
}
|
||||
@@ -976,15 +961,6 @@ class VoiceSettings {
|
||||
return mode === 'auto' ? undefined : mode;
|
||||
}
|
||||
|
||||
getScreenShareBackupCodecMode(): ScreenShareBackupCodecMode {
|
||||
return this.screenShareBackupCodecMode;
|
||||
}
|
||||
|
||||
getScreenShareBackupCodecModeOverride(): Exclude<ScreenShareBackupCodecMode, 'off'> | undefined {
|
||||
const mode = this.screenShareBackupCodecMode;
|
||||
return mode === 'off' ? undefined : mode;
|
||||
}
|
||||
|
||||
getVadThreshold(): number {
|
||||
return this.vadThreshold;
|
||||
}
|
||||
@@ -1132,12 +1108,8 @@ class VoiceSettings {
|
||||
if (validated.screenShareHevcOptIn !== undefined) this.screenShareHevcOptIn = validated.screenShareHevcOptIn;
|
||||
if (validated.screenShareContentHint !== undefined) this.screenShareContentHint = validated.screenShareContentHint;
|
||||
if (validated.screenShareEncoderMode !== undefined) this.screenShareEncoderMode = validated.screenShareEncoderMode;
|
||||
if (validated.screenShareSoftwareQuality !== undefined)
|
||||
this.screenShareSoftwareQuality = validated.screenShareSoftwareQuality;
|
||||
if (validated.screenShareScalabilityMode !== undefined)
|
||||
this.screenShareScalabilityMode = validated.screenShareScalabilityMode;
|
||||
if (validated.screenShareBackupCodecMode !== undefined)
|
||||
this.screenShareBackupCodecMode = validated.screenShareBackupCodecMode;
|
||||
if (validated.vadThreshold !== undefined) this.vadThreshold = validated.vadThreshold;
|
||||
if (validated.vadAutoSensitivity !== undefined) this.vadAutoSensitivity = validated.vadAutoSensitivity;
|
||||
if (validated.vadEnhanced !== undefined) this.vadEnhanced = validated.vadEnhanced;
|
||||
@@ -1200,15 +1172,9 @@ class VoiceSettings {
|
||||
const screenShareEncoderMode = validateScreenShareEncoderMode(
|
||||
data.screenShareEncoderMode ?? this.screenShareEncoderMode,
|
||||
);
|
||||
const screenShareSoftwareQuality = validateScreenShareSoftwareQuality(
|
||||
data.screenShareSoftwareQuality ?? this.screenShareSoftwareQuality,
|
||||
);
|
||||
const screenShareScalabilityMode = validateScreenShareScalabilityMode(
|
||||
data.screenShareScalabilityMode ?? this.screenShareScalabilityMode,
|
||||
);
|
||||
const screenShareBackupCodecMode = validateScreenShareBackupCodecMode(
|
||||
data.screenShareBackupCodecMode ?? this.screenShareBackupCodecMode,
|
||||
);
|
||||
const screenShareAv1OptIn = data.screenShareAv1OptIn ?? this.screenShareAv1OptIn;
|
||||
const screenShareHevcOptIn = data.screenShareHevcOptIn ?? this.screenShareHevcOptIn;
|
||||
if (backgroundImageId !== NONE_BACKGROUND_ID && backgroundImageId !== BLUR_BACKGROUND_ID) {
|
||||
@@ -1273,9 +1239,7 @@ class VoiceSettings {
|
||||
data.screenShareContentHint ?? this.screenShareContentHint,
|
||||
),
|
||||
screenShareEncoderMode,
|
||||
screenShareSoftwareQuality,
|
||||
screenShareScalabilityMode,
|
||||
screenShareBackupCodecMode,
|
||||
vadThreshold: Math.max(0, Math.min(100, data.vadThreshold ?? this.vadThreshold)),
|
||||
vadAutoSensitivity: data.vadAutoSensitivity ?? this.vadAutoSensitivity,
|
||||
vadEnhanced: data.vadEnhanced ?? this.vadEnhanced,
|
||||
@@ -1336,22 +1300,12 @@ function validateScreenShareEncoderMode(mode: unknown): ScreenShareEncoderMode {
|
||||
return mode === 'hardware' || mode === 'software' || mode === 'auto' ? mode : DEFAULT_SCREEN_SHARE_ENCODER_MODE;
|
||||
}
|
||||
|
||||
function validateScreenShareSoftwareQuality(mode: unknown): ScreenShareSoftwareQuality {
|
||||
return mode === 'realtime' || mode === 'quality' || mode === 'balanced'
|
||||
? mode
|
||||
: DEFAULT_SCREEN_SHARE_SOFTWARE_QUALITY;
|
||||
}
|
||||
|
||||
function validateScreenShareScalabilityMode(mode: unknown): ScreenShareScalabilityModePreference {
|
||||
return mode === 'single_layer' || mode === 'temporal' || mode === 'spatial' || mode === 'auto'
|
||||
return mode === 'single_layer' || mode === 'temporal' || mode === 'auto'
|
||||
? mode
|
||||
: DEFAULT_SCREEN_SHARE_SCALABILITY_MODE;
|
||||
}
|
||||
|
||||
function validateScreenShareBackupCodecMode(mode: unknown): ScreenShareBackupCodecMode {
|
||||
return mode === 'h264_simulcast' || mode === 'off' ? mode : DEFAULT_SCREEN_SHARE_BACKUP_CODEC_MODE;
|
||||
}
|
||||
|
||||
function validateScreenShareContentHint(hint: unknown): ScreenShareContentHint {
|
||||
return hint === 'detail' || hint === 'motion' || hint === 'text' || hint === 'auto'
|
||||
? hint
|
||||
|
||||
@@ -17,13 +17,22 @@ import {
|
||||
resetNativeHardwareEncoderCapabilities,
|
||||
} from '@app/features/voice/utils/NativeHardwareEncoderCapabilities';
|
||||
import {getOpenH264StatusSync, resetOpenH264Status} from '@app/features/voice/utils/OpenH264Status';
|
||||
import {
|
||||
LAST_RESORT_VIDEO_CODEC,
|
||||
rankScreenShareCodecs,
|
||||
type ScreenShareCodecBrowser,
|
||||
type ScreenShareCodecProfile,
|
||||
type ScreenShareCodecProfileEntry,
|
||||
type ScreenShareCodecRanking,
|
||||
} from '@app/features/voice/utils/ScreenShareCodecSelection';
|
||||
import {normaliseStreamingModeForContext} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {isVideoCodecDecodeExcluded} from '@app/features/voice/utils/VideoDecoderCapabilities';
|
||||
import type {TrackPublishDefaults, TrackPublishOptions} from 'livekit-client';
|
||||
import {BackupCodecPolicy, supportsVideoCodec, type VideoCodec, type VideoEncoding} from 'livekit-client';
|
||||
|
||||
const logger = new Logger('CodecCapabilityDetector');
|
||||
export const LIVEKIT_SUPPORTED_CODECS: ReadonlyArray<VideoCodec> = ['vp8', 'h264', 'vp9', 'av1', 'h265'];
|
||||
const PUBLISH_CODEC_FALLBACK_ORDER: ReadonlyArray<VideoCodec> = ['h264', 'vp9', 'vp8', 'av1', 'h265'];
|
||||
const LAST_RESORT_PUBLISH_CODEC: VideoCodec = 'vp8';
|
||||
|
||||
export interface CodecCapabilities {
|
||||
vp8: boolean;
|
||||
@@ -59,29 +68,24 @@ export interface CodecCapabilityReport {
|
||||
|
||||
export type CodecPreference = 'auto' | VideoCodec;
|
||||
export type ScreenShareEncoderMode = 'auto' | 'hardware' | 'software';
|
||||
export type ScreenShareSoftwareQuality = 'realtime' | 'balanced' | 'quality';
|
||||
export type ScreenShareScalabilityModePreference = 'auto' | 'single_layer' | 'temporal' | 'spatial';
|
||||
export type ScreenShareBackupCodecMode = 'off' | 'h264_simulcast';
|
||||
export type ScreenShareScalabilityModePreference = 'auto' | 'single_layer' | 'temporal';
|
||||
export type AutomaticScreenShareCodecReason =
|
||||
| 'firefox-vp8'
|
||||
| 'non-chromium-h264'
|
||||
| 'non-chromium-vp8'
|
||||
| 'hardware-av1'
|
||||
| 'hardware-h265'
|
||||
| 'hardware-h264'
|
||||
| 'hardware-vp9'
|
||||
| 'software-av1'
|
||||
| 'software-h265'
|
||||
| 'software-vp9'
|
||||
| 'software-h264'
|
||||
| 'software-vp8'
|
||||
| 'openh264-required';
|
||||
| `hardware-${VideoCodec}`
|
||||
| `software-${VideoCodec}`;
|
||||
|
||||
export interface AutomaticScreenShareCodecSelection {
|
||||
codec: VideoCodec;
|
||||
reason: AutomaticScreenShareCodecReason;
|
||||
}
|
||||
|
||||
export interface ScreenShareEncoderPath {
|
||||
codec: VideoCodec;
|
||||
hardwareUnavailable: boolean;
|
||||
}
|
||||
|
||||
export type ScreenShareContentHint = 'auto' | 'detail' | 'motion' | 'text';
|
||||
|
||||
let cachedCapabilities: CodecCapabilities | null = null;
|
||||
@@ -211,22 +215,10 @@ function getScreenShareCodecPolicyUnsupported(
|
||||
return null;
|
||||
}
|
||||
|
||||
function getEffectiveScreenShareCapabilities(caps: CodecCapabilities): CodecCapabilities {
|
||||
const context = buildScreenShareCodecPolicyContext();
|
||||
return {
|
||||
vp8: caps.vp8 && !runtimeEncodeFailureCodecs.has('vp8') && !getScreenShareCodecPolicyUnsupported('vp8', context),
|
||||
vp9: caps.vp9 && !runtimeEncodeFailureCodecs.has('vp9') && !getScreenShareCodecPolicyUnsupported('vp9', context),
|
||||
h264:
|
||||
caps.h264 && !runtimeEncodeFailureCodecs.has('h264') && !getScreenShareCodecPolicyUnsupported('h264', context),
|
||||
h265:
|
||||
caps.h265 && !runtimeEncodeFailureCodecs.has('h265') && !getScreenShareCodecPolicyUnsupported('h265', context),
|
||||
av1: caps.av1 && !runtimeEncodeFailureCodecs.has('av1') && !getScreenShareCodecPolicyUnsupported('av1', context),
|
||||
};
|
||||
}
|
||||
|
||||
function hasPublishPathNativeHardwareEncoder(codec: VideoCodec): boolean {
|
||||
if (!hasNativeHardwareEncoder(codec)) return false;
|
||||
return getNativeHardwareEncoderCapabilitiesSync()?.backend !== 'videotoolbox';
|
||||
const backend = getNativeHardwareEncoderCapabilitiesSync()?.backend;
|
||||
return backend !== 'videotoolbox' && backend !== 'nvenc';
|
||||
}
|
||||
|
||||
function buildReport(): CodecCapabilityReport {
|
||||
@@ -247,11 +239,7 @@ function buildReport(): CodecCapabilityReport {
|
||||
if (hasPublishPathNativeHardwareEncoder(codec)) {
|
||||
return 'hardware';
|
||||
}
|
||||
const gpu = gpuReport ? gpuReport[codec] : 'unknown';
|
||||
if (codec === 'h264') {
|
||||
return gpu === 'hardware' ? 'hardware' : 'software';
|
||||
}
|
||||
return gpu;
|
||||
return gpuReport ? gpuReport[codec] : 'unknown';
|
||||
}
|
||||
type DescribedUnsupported = Omit<CodecSupportInfo, 'hardwareAccelerated'>;
|
||||
function unsupported(codec: keyof CodecCapabilities, info: DescribedUnsupported): CodecSupportInfo {
|
||||
@@ -269,17 +257,13 @@ function buildReport(): CodecCapabilityReport {
|
||||
detail: 'This codec failed while publishing during the current session.',
|
||||
});
|
||||
}
|
||||
const supportedByNativeHardware = hasNativeHardwareEncoder(codec);
|
||||
const supportedByNativeHardware = hasPublishPathNativeHardwareEncoder(codec);
|
||||
if (caps[codec] || supportedByNativeHardware) {
|
||||
return {
|
||||
supported: true,
|
||||
reason: 'supported',
|
||||
detail: supportedByNativeHardware
|
||||
? nativeHardwareEncoder?.backend === 'nvenc'
|
||||
? 'Available through the native NVIDIA NVENC WebRTC path.'
|
||||
: nativeHardwareEncoder?.backend === 'videotoolbox'
|
||||
? 'Available through the native Apple VideoToolbox hardware encoder.'
|
||||
: 'Available through the native hardware encoder.'
|
||||
? 'Available through the native hardware encoder.'
|
||||
: linuxNvidiaWebRtcEncodeLimited && hwAccel(codec) === 'software'
|
||||
? 'Available through Chromium software encoding. NVIDIA NVENC is not exposed to WebRTC on Linux.'
|
||||
: 'Available on your system.',
|
||||
@@ -421,48 +405,59 @@ export function resolveEffectiveScreenShareEncoderMode(mode: ScreenShareEncoderM
|
||||
return codecs.some((codec) => capabilityReport[codec].hardwareAccelerated === 'hardware') ? 'hardware' : 'auto';
|
||||
}
|
||||
|
||||
export function selectAutomaticScreenShareCodec(
|
||||
requestedEncoderMode: ScreenShareEncoderMode = 'auto',
|
||||
): AutomaticScreenShareCodecSelection {
|
||||
const encoderMode = resolveEffectiveScreenShareEncoderMode(requestedEncoderMode);
|
||||
const caps = getEffectiveScreenShareCapabilities(probeEncodingCapabilities());
|
||||
export function buildScreenShareCodecProfile(): ScreenShareCodecProfile {
|
||||
const report = getCodecCapabilityReport();
|
||||
const hardwareAccelerationDisabled = isDesktopHardwareAccelerationDisabled();
|
||||
const isSupported = (codec: VideoCodec): boolean => report[codec].supported && caps[codec];
|
||||
const isHardware = (codec: VideoCodec): boolean =>
|
||||
report[codec].supported && report[codec].hardwareAccelerated === 'hardware';
|
||||
if (isFirefoxBrowser()) {
|
||||
return {codec: 'vp8', reason: 'firefox-vp8'};
|
||||
const browser: ScreenShareCodecBrowser = isFirefoxBrowser() ? 'firefox' : isChromiumBrowser() ? 'chromium' : 'other';
|
||||
const entry = (codec: VideoCodec): ScreenShareCodecProfileEntry => ({
|
||||
allowed: isVideoCodecAllowedForPublish(codec),
|
||||
supported: report[codec].supported,
|
||||
hardware: report[codec].hardwareAccelerated === 'hardware',
|
||||
});
|
||||
return {
|
||||
browser,
|
||||
desktop: isDesktop(),
|
||||
codecs: {
|
||||
vp8: entry('vp8'),
|
||||
vp9: entry('vp9'),
|
||||
h264: entry('h264'),
|
||||
h265: entry('h265'),
|
||||
av1: entry('av1'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rankAutomaticScreenShareCodecs(
|
||||
profile: ScreenShareCodecProfile,
|
||||
encoderModeSetting: ScreenShareEncoderMode,
|
||||
): ScreenShareCodecRanking {
|
||||
return rankScreenShareCodecs({profile, encoderModeSetting, pin: 'auto'});
|
||||
}
|
||||
|
||||
function describeAutomaticScreenShareCodecReason(
|
||||
profile: ScreenShareCodecProfile,
|
||||
codec: VideoCodec,
|
||||
): AutomaticScreenShareCodecReason {
|
||||
if (profile.browser === 'firefox' && codec === 'vp8') return 'firefox-vp8';
|
||||
if (profile.browser === 'other' && !profile.desktop) {
|
||||
return codec === 'h264' ? 'non-chromium-h264' : 'non-chromium-vp8';
|
||||
}
|
||||
if (!isChromiumBrowser() && !isDesktop()) {
|
||||
if (caps.h264) return {codec: 'h264', reason: 'non-chromium-h264'};
|
||||
return {codec: 'vp8', reason: 'non-chromium-vp8'};
|
||||
}
|
||||
if (encoderMode === 'software') {
|
||||
if (caps.av1) return {codec: 'av1', reason: 'software-av1'};
|
||||
if (caps.vp9) return {codec: 'vp9', reason: 'software-vp9'};
|
||||
if (caps.h264) return {codec: 'h264', reason: 'software-h264'};
|
||||
if (caps.vp8) return {codec: 'vp8', reason: 'software-vp8'};
|
||||
return {codec: 'h264', reason: 'openh264-required'};
|
||||
}
|
||||
if (isDesktop() && !hardwareAccelerationDisabled && isSupported('av1') && isHardware('av1')) {
|
||||
return {codec: 'av1', reason: 'hardware-av1'};
|
||||
}
|
||||
if (isDesktop() && !hardwareAccelerationDisabled && report.h265.supported && isHardware('h265')) {
|
||||
return {codec: 'h265', reason: 'hardware-h265'};
|
||||
}
|
||||
if (isDesktop() && !hardwareAccelerationDisabled && report.h264.supported && isHardware('h264')) {
|
||||
return {codec: 'h264', reason: 'hardware-h264'};
|
||||
}
|
||||
if (isDesktop() && !hardwareAccelerationDisabled && isSupported('vp9') && isHardware('vp9')) {
|
||||
return {codec: 'vp9', reason: 'hardware-vp9'};
|
||||
}
|
||||
if (caps.av1) return {codec: 'av1', reason: 'software-av1'};
|
||||
if (caps.h265) return {codec: 'h265', reason: 'software-h265'};
|
||||
if (caps.vp9) return {codec: 'vp9', reason: 'software-vp9'};
|
||||
if (caps.h264) return {codec: 'h264', reason: 'software-h264'};
|
||||
if (caps.vp8) return {codec: 'vp8', reason: 'software-vp8'};
|
||||
return {codec: 'h264', reason: 'openh264-required'};
|
||||
return profile.codecs[codec].hardware ? `hardware-${codec}` : `software-${codec}`;
|
||||
}
|
||||
|
||||
export function selectAutomaticScreenShareCodec(
|
||||
encoderModeSetting: ScreenShareEncoderMode = 'auto',
|
||||
): AutomaticScreenShareCodecSelection {
|
||||
const profile = buildScreenShareCodecProfile();
|
||||
const codec = rankAutomaticScreenShareCodecs(profile, encoderModeSetting).order[0];
|
||||
return {codec, reason: describeAutomaticScreenShareCodecReason(profile, codec)};
|
||||
}
|
||||
|
||||
export function describeScreenShareEncoderPath(encoderModeSetting: ScreenShareEncoderMode): ScreenShareEncoderPath {
|
||||
const {order, hardwareUnavailable} = rankAutomaticScreenShareCodecs(
|
||||
buildScreenShareCodecProfile(),
|
||||
encoderModeSetting,
|
||||
);
|
||||
return {codec: order[0], hardwareUnavailable};
|
||||
}
|
||||
|
||||
export function selectOptimalScreenShareCodec(
|
||||
@@ -496,7 +491,7 @@ export function markScreenShareCodecSoftwareEncodeObserved(codec: VideoCodec): b
|
||||
return true;
|
||||
}
|
||||
|
||||
export type VideoPublishCodecDenial = 'sender-cannot-encode' | 'policy' | 'runtime-failed';
|
||||
export type VideoPublishCodecDenial = 'sender-cannot-encode' | 'policy' | 'runtime-failed' | 'decoder-excluded';
|
||||
|
||||
export interface VideoPublishCodecPolicy {
|
||||
allowed: ReadonlyArray<VideoCodec>;
|
||||
@@ -513,7 +508,7 @@ export interface VideoPublishCodecPolicyViolation {
|
||||
|
||||
export type ScreenShareEncoderVerificationAction =
|
||||
| {kind: 'recover-stalled'; codec: VideoCodec}
|
||||
| {kind: 'ignore-repeated-stall'; codec: VideoCodec}
|
||||
| {kind: 'stop-stalled'; codec: VideoCodec}
|
||||
| {kind: 'accept-negotiated'; requested: VideoCodec; negotiated: ReadonlyArray<VideoCodec>}
|
||||
| {
|
||||
kind: 'correct-negotiated';
|
||||
@@ -526,6 +521,7 @@ export function getVideoPublishCodecDenial(codec: VideoCodec): VideoPublishCodec
|
||||
if (!supportsVideoCodec(codec)) return 'sender-cannot-encode';
|
||||
if (getScreenShareCodecPolicyUnsupported(codec, buildScreenShareCodecPolicyContext())) return 'policy';
|
||||
if (runtimeEncodeFailureCodecs.has(codec)) return 'runtime-failed';
|
||||
if (isVideoCodecDecodeExcluded(codec)) return 'decoder-excluded';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -547,7 +543,7 @@ export function resolveVideoPublishCodecPolicy(requested: VideoCodec): VideoPubl
|
||||
const allowed = getAllowedVideoPublishCodecs();
|
||||
const primary = allowed.includes(requested)
|
||||
? requested
|
||||
: (PUBLISH_CODEC_FALLBACK_ORDER.find((codec) => allowed.includes(codec)) ?? LAST_RESORT_PUBLISH_CODEC);
|
||||
: (PUBLISH_CODEC_FALLBACK_ORDER.find((codec) => allowed.includes(codec)) ?? LAST_RESORT_VIDEO_CODEC);
|
||||
if (primary !== requested) {
|
||||
logger.warn('Requested publish codec is outside the publish policy; substituting', {
|
||||
requested,
|
||||
@@ -584,10 +580,22 @@ export function getRoomVideoPublishDefaults(): Pick<TrackPublishDefaults, 'video
|
||||
|
||||
export function findVideoPublishCodecPolicyViolation(
|
||||
requested: VideoCodec,
|
||||
negotiated: VideoCodec | undefined,
|
||||
published: VideoCodec | undefined,
|
||||
negotiated?: VideoCodec,
|
||||
): VideoPublishCodecPolicyViolation | null {
|
||||
if (!negotiated || isVideoCodecAllowedForPublish(negotiated)) return null;
|
||||
return {requested, negotiated, alternative: selectVideoPublishCodecAlternative(requested)};
|
||||
if (published && !isVideoCodecAllowedForPublish(published)) {
|
||||
return {requested, negotiated: published, alternative: selectVideoPublishCodecAlternative(requested)};
|
||||
}
|
||||
if (negotiated && negotiated !== requested) {
|
||||
return {
|
||||
requested,
|
||||
negotiated,
|
||||
alternative: isVideoCodecAllowedForPublish(negotiated)
|
||||
? negotiated
|
||||
: selectVideoPublishCodecAlternative(requested),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveScreenShareEncoderVerificationAction(
|
||||
@@ -596,19 +604,21 @@ export function resolveScreenShareEncoderVerificationAction(
|
||||
| {reason: 'codec-mismatch'; codec: VideoCodec; activeCodecs: ReadonlyArray<VideoCodec>},
|
||||
): ScreenShareEncoderVerificationAction {
|
||||
if (failure.reason === 'stalled') {
|
||||
if (runtimeEncodeFailureCodecs.has(failure.codec)) return {kind: 'ignore-repeated-stall', codec: failure.codec};
|
||||
if (runtimeEncodeFailureCodecs.has(failure.codec)) return {kind: 'stop-stalled', codec: failure.codec};
|
||||
markScreenShareCodecEncodeRuntimeFailure(failure.codec, 'screen-share-encode-stalled');
|
||||
return {kind: 'recover-stalled', codec: failure.codec};
|
||||
}
|
||||
const disallowed = failure.activeCodecs.filter((codec) => !isVideoCodecAllowedForPublish(codec));
|
||||
if (disallowed.length === 0) {
|
||||
const unexpected = failure.activeCodecs.filter((codec) => codec !== failure.codec);
|
||||
if (unexpected.length === 0) {
|
||||
return {kind: 'accept-negotiated', requested: failure.codec, negotiated: failure.activeCodecs};
|
||||
}
|
||||
return {
|
||||
kind: 'correct-negotiated',
|
||||
requested: failure.codec,
|
||||
negotiated: disallowed,
|
||||
alternative: selectVideoPublishCodecAlternative(failure.codec),
|
||||
negotiated: unexpected,
|
||||
alternative:
|
||||
unexpected.find((codec) => isVideoCodecAllowedForPublish(codec)) ??
|
||||
selectVideoPublishCodecAlternative(failure.codec),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -631,9 +641,12 @@ export function resolveScreenShareContentHint(
|
||||
export function resolveScreenShareContentHintForContext(
|
||||
preference: ScreenShareContentHint | undefined,
|
||||
_codec: VideoCodec,
|
||||
_source: ScreenShareContentSource,
|
||||
_streamingMode: 'custom' | 'gaming' | 'screenshare',
|
||||
source: ScreenShareContentSource,
|
||||
streamingMode: 'custom' | 'gaming' | 'screenshare',
|
||||
): 'detail' | 'text' | 'motion' | undefined {
|
||||
const mode = normaliseStreamingModeForContext(streamingMode, source);
|
||||
if (mode === 'gaming') return 'motion';
|
||||
if (mode === 'screenshare') return 'text';
|
||||
return resolveScreenShareContentHint(preference);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {
|
||||
getElectronAPI,
|
||||
getNativePlatformSync,
|
||||
isDesktop,
|
||||
type NativePlatform,
|
||||
} from '@app/features/ui/utils/NativeUtils';
|
||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
getScreenShareBitrateBps,
|
||||
resolveEffectiveScreenShareDimensions,
|
||||
resolveStreamingModeSettings,
|
||||
SCREEN_SHARE_MAX_VIDEO_BITRATE_BPS,
|
||||
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {hasHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
||||
import {getElectronAPI, isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import type {GpuDeviceInfo, GpuInfo} from '@app/types/electron.d';
|
||||
import type {VideoCodec} from 'livekit-client';
|
||||
|
||||
@@ -244,13 +231,16 @@ export function reportFromGpuInfo(info: GpuInfo): HardwareEncodeReport {
|
||||
};
|
||||
}
|
||||
|
||||
export const NEGOTIABLE_H264_PROBE_CONTENT_TYPE =
|
||||
'video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f';
|
||||
export const H264_PROBE_PROFILE_LEVEL_IDS: ReadonlyArray<string> = ['640028', '4d0028', '420028', '42e028'];
|
||||
|
||||
const WEBRTC_ENCODE_PROBE_CONTENT_TYPES: Record<VideoCodec, ReadonlyArray<string>> = {
|
||||
export const H264_ENCODE_PROBE_CONTENT_TYPES: ReadonlyArray<string> = H264_PROBE_PROFILE_LEVEL_IDS.map(
|
||||
(profileLevelId) => `video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=${profileLevelId}`,
|
||||
);
|
||||
|
||||
export const WEBRTC_ENCODE_PROBE_CONTENT_TYPES: Record<VideoCodec, ReadonlyArray<string>> = {
|
||||
av1: ['video/AV1'],
|
||||
h265: ['video/H265'],
|
||||
h264: [NEGOTIABLE_H264_PROBE_CONTENT_TYPE],
|
||||
h264: H264_ENCODE_PROBE_CONTENT_TYPES,
|
||||
vp9: ['video/VP9'],
|
||||
vp8: ['video/VP8'],
|
||||
};
|
||||
@@ -262,34 +252,13 @@ interface EncodeProbeVideoConfig {
|
||||
framerate: number;
|
||||
}
|
||||
|
||||
const DEFAULT_ENCODE_PROBE_VIDEO_CONFIG: EncodeProbeVideoConfig = {
|
||||
export const ENCODE_PROBE_VIDEO_CONFIG: EncodeProbeVideoConfig = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
bitrate: SCREEN_SHARE_MAX_VIDEO_BITRATE_BPS,
|
||||
framerate: 60,
|
||||
bitrate: 4_500_000,
|
||||
framerate: 30,
|
||||
};
|
||||
|
||||
function resolveEncodeProbeVideoConfig(): EncodeProbeVideoConfig {
|
||||
try {
|
||||
const settings = resolveStreamingModeSettings(
|
||||
VoiceSettings.getStreamingMode(),
|
||||
VoiceSettings.getScreenshareResolution(),
|
||||
VoiceSettings.getVideoFrameRate(),
|
||||
hasHigherVideoQuality(),
|
||||
);
|
||||
const {width, height} = resolveEffectiveScreenShareDimensions(settings.resolution);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
bitrate: getScreenShareBitrateBps(settings.resolution, settings.frameRate),
|
||||
framerate: settings.frameRate,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.debug('Share settings were unavailable, probing at the largest share size instead', {error});
|
||||
return DEFAULT_ENCODE_PROBE_VIDEO_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
interface WebRtcEncodingInfoResult {
|
||||
supported?: boolean;
|
||||
powerEfficient?: boolean;
|
||||
@@ -324,10 +293,13 @@ export async function probeWebRtcEncodeEfficiency(): Promise<Record<VideoCodec,
|
||||
const mediaCapabilities = (navigator as Navigator & {mediaCapabilities?: MediaCapabilitiesLike}).mediaCapabilities;
|
||||
if (!mediaCapabilities?.encodingInfo) return null;
|
||||
const codecs: ReadonlyArray<VideoCodec> = ['av1', 'h265', 'h264', 'vp9', 'vp8'];
|
||||
const video = resolveEncodeProbeVideoConfig();
|
||||
const answers = await Promise.all(
|
||||
codecs.map((codec) =>
|
||||
probeCodecEncodeEfficiency(mediaCapabilities, WEBRTC_ENCODE_PROBE_CONTENT_TYPES[codec], video),
|
||||
probeCodecEncodeEfficiency(
|
||||
mediaCapabilities,
|
||||
WEBRTC_ENCODE_PROBE_CONTENT_TYPES[codec],
|
||||
ENCODE_PROBE_VIDEO_CONFIG,
|
||||
),
|
||||
),
|
||||
);
|
||||
const result = {} as Record<VideoCodec, HardwareEncodeAnswer>;
|
||||
@@ -340,25 +312,18 @@ export async function probeWebRtcEncodeEfficiency(): Promise<Record<VideoCodec,
|
||||
export function reconcileHardwareEncodeReport(
|
||||
report: HardwareEncodeReport,
|
||||
efficiency: Record<VideoCodec, HardwareEncodeAnswer> | null,
|
||||
platform: NativePlatform,
|
||||
vendorId: number,
|
||||
): HardwareEncodeReport {
|
||||
const isNvidiaReport = vendorId === PCI_VENDOR_NVIDIA || report.gpuFamily?.startsWith('nvidia-') === true;
|
||||
const adjust = (codec: VideoCodec): HardwareEncodeAnswer => {
|
||||
if (report[codec] !== 'hardware') return report[codec];
|
||||
return efficiency?.[codec] === 'hardware' ? 'hardware' : 'software';
|
||||
if (!efficiency) return report;
|
||||
const adjust = (codec: VideoCodec): HardwareEncodeAnswer =>
|
||||
efficiency[codec] === 'unknown' ? report[codec] : efficiency[codec];
|
||||
return {
|
||||
...report,
|
||||
av1: adjust('av1'),
|
||||
h265: adjust('h265'),
|
||||
h264: adjust('h264'),
|
||||
vp9: adjust('vp9'),
|
||||
vp8: adjust('vp8'),
|
||||
};
|
||||
if (platform === 'linux' && isNvidiaReport) {
|
||||
return {
|
||||
...report,
|
||||
av1: adjust('av1'),
|
||||
h265: adjust('h265'),
|
||||
h264: adjust('h264'),
|
||||
vp9: adjust('vp9'),
|
||||
vp8: adjust('vp8'),
|
||||
};
|
||||
}
|
||||
return {...report, h264: adjust('h264')};
|
||||
}
|
||||
|
||||
let cachedReport: HardwareEncodeReport | null = null;
|
||||
@@ -376,12 +341,15 @@ function fetchReport(): Promise<HardwareEncodeReport | null> {
|
||||
const info = gpuResult.value;
|
||||
const baseReport = reportFromGpuInfo(info);
|
||||
const efficiency = probeResult.status === 'fulfilled' ? probeResult.value : null;
|
||||
const report = reconcileHardwareEncodeReport(
|
||||
baseReport,
|
||||
efficiency,
|
||||
getNativePlatformSync(),
|
||||
pickPrimaryDevice(info.devices)?.vendorId ?? 0,
|
||||
);
|
||||
const report = reconcileHardwareEncodeReport(baseReport, efficiency);
|
||||
logger.info('Reconciled hardware-encode answers against the WebRTC encode probe', {
|
||||
probe: efficiency,
|
||||
av1: report.av1,
|
||||
h265: report.h265,
|
||||
h264: report.h264,
|
||||
vp9: report.vp9,
|
||||
vp8: report.vp8,
|
||||
});
|
||||
cachedReport = report;
|
||||
return report;
|
||||
});
|
||||
|
||||
@@ -176,6 +176,29 @@ function rememberStartedCapture(capture: NativeAudioStartedCaptureDiagnostic): v
|
||||
};
|
||||
}
|
||||
|
||||
type CapturedDisplayAudioDiagnostic = {
|
||||
readyState: MediaStreamTrackState;
|
||||
label: string;
|
||||
deviceId: string | null;
|
||||
discarded: boolean;
|
||||
};
|
||||
|
||||
let lastCapturedDisplayAudio: CapturedDisplayAudioDiagnostic | null = null;
|
||||
|
||||
export function rememberCapturedDisplayAudioTrack(track: MediaStreamTrack | undefined, discarded: boolean): void {
|
||||
if (!track) {
|
||||
lastCapturedDisplayAudio = null;
|
||||
return;
|
||||
}
|
||||
let deviceId: string | null = null;
|
||||
try {
|
||||
deviceId = track.getSettings().deviceId ?? null;
|
||||
} catch {
|
||||
deviceId = null;
|
||||
}
|
||||
lastCapturedDisplayAudio = {readyState: track.readyState, label: track.label, deviceId, discarded};
|
||||
}
|
||||
|
||||
export function getNativeAudioCaptureDiagnosticState(): Record<string, unknown> {
|
||||
const started = lastStartedCapture
|
||||
? {
|
||||
@@ -192,6 +215,7 @@ export function getNativeAudioCaptureDiagnosticState(): Record<string, unknown>
|
||||
bridgeStats: getNativeAudioBridgeStats(),
|
||||
endedBridgeCaptures: getEndedBridgeCaptures(),
|
||||
lifecycleFaults: lifecycleFaults.map((fault) => ({...fault})),
|
||||
capturedDisplayAudio: lastCapturedDisplayAudio ? {...lastCapturedDisplayAudio} : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,12 +47,59 @@ export const MICROPHONE_WITH_DEVICE_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Screen-share audio summary on a video device share. {deviceLabel} is the name of the selected audio input device.',
|
||||
});
|
||||
export const DEVICE_AUDIO_ONLY_DESCRIPTOR = msg({
|
||||
message: 'Device audio only',
|
||||
comment:
|
||||
'Screen-share audio summary on a video device share that publishes only the audio of the capture device itself.',
|
||||
});
|
||||
export const DEVICE_AUDIO_WITH_DEVICE_DESCRIPTOR = msg({
|
||||
message: 'Device audio ({deviceLabel})',
|
||||
comment:
|
||||
'Screen-share audio summary on a video device share. {deviceLabel} is the name of the audio input paired with the capture device.',
|
||||
});
|
||||
export const NO_DEVICE_AUDIO_DESCRIPTOR = msg({
|
||||
message: 'No audio from this device',
|
||||
comment: 'Screen-share audio summary on a video device share whose capture device has no audio input of its own.',
|
||||
});
|
||||
|
||||
export type DeviceShareAudioPairing =
|
||||
| {readonly kind: 'unknown'}
|
||||
| {readonly kind: 'none'}
|
||||
| {readonly kind: 'paired'; readonly label: string};
|
||||
|
||||
export function findPairedDeviceShareAudioInput(
|
||||
devices: ReadonlyArray<MediaDeviceInfo>,
|
||||
videoDeviceId: string,
|
||||
): MediaDeviceInfo | undefined {
|
||||
if (videoDeviceId === '' || videoDeviceId === 'default') return undefined;
|
||||
const videoDevice = devices.find((device) => device.kind === 'videoinput' && device.deviceId === videoDeviceId);
|
||||
if (!videoDevice?.groupId) return undefined;
|
||||
return devices.find(
|
||||
(device) =>
|
||||
device.kind === 'audioinput' &&
|
||||
device.groupId === videoDevice.groupId &&
|
||||
device.deviceId !== '' &&
|
||||
device.deviceId !== 'default' &&
|
||||
device.deviceId !== 'communications',
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDeviceShareAudioPairing(
|
||||
devices: ReadonlyArray<MediaDeviceInfo>,
|
||||
videoDeviceId: string,
|
||||
): DeviceShareAudioPairing {
|
||||
if (videoDeviceId === '') return {kind: 'unknown'};
|
||||
const pairedInput = findPairedDeviceShareAudioInput(devices, videoDeviceId);
|
||||
return pairedInput === undefined ? {kind: 'none'} : {kind: 'paired', label: pairedInput.label};
|
||||
}
|
||||
|
||||
export interface ScreenShareAudioSummaryInput {
|
||||
sourceMode: ScreenShareAudioSourceMode;
|
||||
includeSources: ReadonlyArray<VirtmicNode>;
|
||||
shareContext: StreamSettingsShareContext;
|
||||
microphoneLabel?: string | null;
|
||||
chosenAudioDeviceId?: string;
|
||||
deviceAudioPairing?: DeviceShareAudioPairing;
|
||||
displayShareEnvironment?: DisplayShareEnvironment;
|
||||
windowAudioScope?: WindowShareAudioScope;
|
||||
usesDeviceMicrophone?: boolean;
|
||||
@@ -81,13 +128,29 @@ function summariseMicrophone(microphoneLabel?: string | null): ScreenShareAudioS
|
||||
return {kind: 'message', descriptor: MICROPHONE_WITH_DEVICE_DESCRIPTOR, values: {deviceLabel: microphoneLabel}};
|
||||
}
|
||||
|
||||
function publishesTheMicrophone(input: ScreenShareAudioSummaryInput): boolean {
|
||||
const chosenAudioDeviceId = input.chosenAudioDeviceId ?? '';
|
||||
if (chosenAudioDeviceId !== '' && chosenAudioDeviceId !== 'default') return true;
|
||||
return input.usesDeviceMicrophone === true;
|
||||
}
|
||||
|
||||
function summariseDeviceShareAudio(input: ScreenShareAudioSummaryInput): ScreenShareAudioSummary {
|
||||
if (publishesTheMicrophone(input)) return summariseMicrophone(input.microphoneLabel);
|
||||
const pairing = input.deviceAudioPairing;
|
||||
if (pairing?.kind === 'none') return {kind: 'message', descriptor: NO_DEVICE_AUDIO_DESCRIPTOR};
|
||||
if (pairing?.kind === 'paired' && pairing.label !== '') {
|
||||
return {kind: 'message', descriptor: DEVICE_AUDIO_WITH_DEVICE_DESCRIPTOR, values: {deviceLabel: pairing.label}};
|
||||
}
|
||||
return {kind: 'message', descriptor: DEVICE_AUDIO_ONLY_DESCRIPTOR};
|
||||
}
|
||||
|
||||
export function resolveScreenShareAudioSummary(input: ScreenShareAudioSummaryInput): ScreenShareAudioSummary {
|
||||
const selected = filterRoutableLinuxAudioSources(input.includeSources);
|
||||
const routesSelectedSources = input.sourceMode === 'specific' && selected.length > 0;
|
||||
if (input.shareContext === 'device') {
|
||||
return routesSelectedSources && input.usesDeviceMicrophone !== true
|
||||
? summariseSelectedSources(selected)
|
||||
: summariseMicrophone(input.microphoneLabel);
|
||||
: summariseDeviceShareAudio(input);
|
||||
}
|
||||
if (supportsWindowShareAudioScope(input)) {
|
||||
const route = selectAppShareAudioRoute({
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CodecPreference, ScreenShareEncoderMode} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import type {VideoCodec} from 'livekit-client';
|
||||
|
||||
export const CODEC_PREFERENCE: ReadonlyArray<VideoCodec> = ['av1', 'h265', 'h264', 'vp9', 'vp8'];
|
||||
const COMPATIBILITY_CODECS: ReadonlySet<VideoCodec> = new Set(['h264', 'vp9', 'vp8']);
|
||||
export const LAST_RESORT_VIDEO_CODEC: VideoCodec = 'vp8';
|
||||
const BASELINE_BROWSER_CODECS: ReadonlySet<VideoCodec> = new Set(['h264', 'vp8']);
|
||||
export const SCREEN_SHARE_CODEC_ADVERTISEMENT_GRACE_MS = 3_000;
|
||||
export const VIDEO_CODEC_NAMES: Record<VideoCodec, FluxerVideoCodecName> = {
|
||||
av1: 'AV1',
|
||||
h265: 'H265',
|
||||
h264: 'H264',
|
||||
vp9: 'VP9',
|
||||
vp8: 'VP8',
|
||||
};
|
||||
export const NAME_TO_VIDEO_CODEC: Record<FluxerVideoCodecName, VideoCodec> = {
|
||||
AV1: 'av1',
|
||||
H265: 'h265',
|
||||
H264: 'h264',
|
||||
VP9: 'vp9',
|
||||
VP8: 'vp8',
|
||||
};
|
||||
const VIDEO_CODEC_PROTOCOL_TABLE: Record<
|
||||
VideoCodec,
|
||||
{
|
||||
payloadType: number;
|
||||
rtxPayloadType: number;
|
||||
priority: number;
|
||||
}
|
||||
> = {
|
||||
av1: {payloadType: 101, rtxPayloadType: 102, priority: 5000},
|
||||
h265: {payloadType: 105, rtxPayloadType: 106, priority: 4000},
|
||||
h264: {payloadType: 103, rtxPayloadType: 104, priority: 3000},
|
||||
vp9: {payloadType: 109, rtxPayloadType: 110, priority: 2000},
|
||||
vp8: {payloadType: 107, rtxPayloadType: 108, priority: 1000},
|
||||
};
|
||||
|
||||
export type FluxerVideoCodecName = 'AV1' | 'H265' | 'H264' | 'VP9' | 'VP8';
|
||||
export type FluxerCodecName = 'opus' | FluxerVideoCodecName;
|
||||
export type FluxerCodecType = 'audio' | 'video';
|
||||
export type NegotiationReason =
|
||||
| 'connected'
|
||||
| 'data'
|
||||
| 'participant-connected'
|
||||
| 'participant-disconnected'
|
||||
| 'reconnected'
|
||||
| 'manual';
|
||||
export type ScreenShareCodecBrowser = 'chromium' | 'firefox' | 'other';
|
||||
|
||||
export interface FluxerCodecAdvertisement {
|
||||
name: FluxerCodecName;
|
||||
type: FluxerCodecType;
|
||||
payload_type: number;
|
||||
rtx_payload_type?: number;
|
||||
priority: number;
|
||||
encode?: boolean;
|
||||
decode?: boolean;
|
||||
}
|
||||
|
||||
export interface CodecNegotiationSelection {
|
||||
codec: VideoCodec;
|
||||
reason: NegotiationReason;
|
||||
candidates: Array<VideoCodec>;
|
||||
unknownParticipants: number;
|
||||
}
|
||||
|
||||
export interface ScreenShareCodecProfileEntry {
|
||||
allowed: boolean;
|
||||
supported: boolean;
|
||||
hardware: boolean;
|
||||
}
|
||||
|
||||
export interface ScreenShareCodecProfile {
|
||||
browser: ScreenShareCodecBrowser;
|
||||
desktop: boolean;
|
||||
codecs: Record<VideoCodec, ScreenShareCodecProfileEntry>;
|
||||
}
|
||||
|
||||
export interface ScreenShareCodecRankingInput {
|
||||
profile: ScreenShareCodecProfile;
|
||||
encoderModeSetting: ScreenShareEncoderMode;
|
||||
pin: CodecPreference;
|
||||
}
|
||||
|
||||
export interface ScreenShareCodecRanking {
|
||||
order: ReadonlyArray<VideoCodec>;
|
||||
hardwareUnavailable: boolean;
|
||||
}
|
||||
|
||||
export function rankScreenShareCodecs(input: ScreenShareCodecRankingInput): ScreenShareCodecRanking {
|
||||
const {codecs} = input.profile;
|
||||
const isHardware = (codec: VideoCodec): boolean => codecs[codec].supported && codecs[codec].hardware;
|
||||
const hardwareAvailable = CODEC_PREFERENCE.some((codec) => isHardware(codec));
|
||||
const pin = input.pin !== 'auto' && codecs[input.pin].allowed && codecs[input.pin].supported ? input.pin : null;
|
||||
const baselineOnly =
|
||||
input.profile.browser === 'firefox' || (input.profile.browser !== 'chromium' && !input.profile.desktop);
|
||||
const survives = (codec: VideoCodec): boolean => {
|
||||
if (!codecs[codec].allowed || !codecs[codec].supported) return false;
|
||||
if (baselineOnly && !BASELINE_BROWSER_CODECS.has(codec)) return false;
|
||||
if (codec === 'h265') return pin === 'h265' || (input.encoderModeSetting !== 'software' && isHardware('h265'));
|
||||
return true;
|
||||
};
|
||||
const survivors = CODEC_PREFERENCE.filter(survives);
|
||||
const ranked =
|
||||
input.encoderModeSetting === 'software'
|
||||
? survivors
|
||||
: [...survivors.filter(isHardware), ...survivors.filter((codec) => !isHardware(codec))];
|
||||
const ordered = pin === null ? ranked : [pin, ...ranked.filter((codec) => codec !== pin)];
|
||||
return {
|
||||
order: ordered.length > 0 ? ordered : [LAST_RESORT_VIDEO_CODEC],
|
||||
hardwareUnavailable: input.encoderModeSetting === 'hardware' && !hardwareAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildScreenShareCodecAdvertisements(
|
||||
order: ReadonlyArray<VideoCodec>,
|
||||
decode: Record<VideoCodec, boolean>,
|
||||
): Array<FluxerCodecAdvertisement> {
|
||||
return [
|
||||
{
|
||||
name: 'opus',
|
||||
type: 'audio',
|
||||
payload_type: 120,
|
||||
priority: 1000,
|
||||
encode: true,
|
||||
decode: true,
|
||||
},
|
||||
...CODEC_PREFERENCE.map((codec) => ({
|
||||
name: VIDEO_CODEC_NAMES[codec],
|
||||
type: 'video' as const,
|
||||
payload_type: VIDEO_CODEC_PROTOCOL_TABLE[codec].payloadType,
|
||||
rtx_payload_type: VIDEO_CODEC_PROTOCOL_TABLE[codec].rtxPayloadType,
|
||||
priority: VIDEO_CODEC_PROTOCOL_TABLE[codec].priority,
|
||||
encode: order.includes(codec),
|
||||
decode: decode[codec],
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export function countUnknownScreenShareParticipants(
|
||||
participants: ReadonlyArray<{identity: string; firstSeenAt: number}>,
|
||||
advertised: ReadonlySet<string>,
|
||||
now: number,
|
||||
graceMs: number = SCREEN_SHARE_CODEC_ADVERTISEMENT_GRACE_MS,
|
||||
): number {
|
||||
return participants.filter(
|
||||
(participant) => !advertised.has(participant.identity) && now - participant.firstSeenAt >= graceMs,
|
||||
).length;
|
||||
}
|
||||
|
||||
export function getDecodeSet(codecs: ReadonlyArray<FluxerCodecAdvertisement>): Set<VideoCodec> {
|
||||
const result = new Set<VideoCodec>();
|
||||
for (const codec of codecs) {
|
||||
if (codec.type !== 'video' || codec.decode !== true) continue;
|
||||
const mapped = NAME_TO_VIDEO_CODEC[codec.name as FluxerVideoCodecName];
|
||||
if (mapped) result.add(mapped);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getEncodeSet(codecs: ReadonlyArray<FluxerCodecAdvertisement>): Set<VideoCodec> {
|
||||
const result = new Set<VideoCodec>();
|
||||
for (const codec of codecs) {
|
||||
if (codec.type !== 'video' || codec.encode !== true) continue;
|
||||
const mapped = NAME_TO_VIDEO_CODEC[codec.name as FluxerVideoCodecName];
|
||||
if (mapped) result.add(mapped);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function selectCompatibilityFallbackCodec(
|
||||
localEncode: ReadonlySet<VideoCodec>,
|
||||
remoteDecode: ReadonlyArray<ReadonlySet<VideoCodec>>,
|
||||
): VideoCodec {
|
||||
for (const codec of CODEC_PREFERENCE) {
|
||||
if (!COMPATIBILITY_CODECS.has(codec)) continue;
|
||||
if (localEncode.has(codec) && remoteDecode.every((decode) => decode.has(codec))) return codec;
|
||||
}
|
||||
return LAST_RESORT_VIDEO_CODEC;
|
||||
}
|
||||
|
||||
function negotiateVideoCodec(
|
||||
localEncode: ReadonlySet<VideoCodec>,
|
||||
remoteDecode: ReadonlyArray<ReadonlySet<VideoCodec>>,
|
||||
unknownParticipants: number,
|
||||
codecPreference: ReadonlyArray<VideoCodec>,
|
||||
): {codec: VideoCodec; candidates: Array<VideoCodec>} {
|
||||
const candidates = codecPreference.filter((codec) => {
|
||||
if (!localEncode.has(codec)) return false;
|
||||
if (unknownParticipants > 0 && !COMPATIBILITY_CODECS.has(codec)) return false;
|
||||
return remoteDecode.every((decode) => decode.has(codec));
|
||||
});
|
||||
return {codec: candidates[0] ?? selectCompatibilityFallbackCodec(localEncode, remoteDecode), candidates};
|
||||
}
|
||||
|
||||
export function computeNegotiatedVideoCodec(
|
||||
localCodecs: ReadonlyArray<FluxerCodecAdvertisement>,
|
||||
remoteCodecs: ReadonlyArray<ReadonlyArray<FluxerCodecAdvertisement>>,
|
||||
unknownParticipants = 0,
|
||||
codecPreference: ReadonlyArray<VideoCodec> = CODEC_PREFERENCE,
|
||||
): CodecNegotiationSelection {
|
||||
const {codec, candidates} = negotiateVideoCodec(
|
||||
getEncodeSet(localCodecs),
|
||||
remoteCodecs.map((remote) => getDecodeSet(remote)),
|
||||
unknownParticipants,
|
||||
codecPreference,
|
||||
);
|
||||
return {codec, reason: 'manual', candidates, unknownParticipants};
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export type ScreenShareEndedModal = 'source-stopped' | 'encoder-failed' | 'codec
|
||||
|
||||
export type ScreenShareEncoderVerification =
|
||||
| 'recover-stalled'
|
||||
| 'ignore-repeated-stall'
|
||||
| 'stop-stalled'
|
||||
| 'accept-negotiated'
|
||||
| 'correct-negotiated';
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ScreenshareResolution, StreamingMode} from '@app/features/voice/state/VoiceSettings';
|
||||
import type {ScreenShareCaptureOptions, TrackPublishOptions, VideoEncoding} from 'livekit-client';
|
||||
import type {
|
||||
ScreenShareContentHint,
|
||||
ScreenShareScalabilityModePreference,
|
||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import type {ScreenShareCaptureOptions, TrackPublishOptions, VideoCodec, VideoEncoding} from 'livekit-client';
|
||||
|
||||
const DIMENSIONS: Record<
|
||||
ScreenshareResolution,
|
||||
@@ -18,8 +22,6 @@ const DIMENSIONS: Record<
|
||||
source: {width: 3840, height: 2160},
|
||||
};
|
||||
export const SCREEN_SHARE_MAX_VIDEO_BITRATE_BPS = 6_000_000;
|
||||
export const SCREEN_SHARE_DEGRADATION_PREFERENCE: NonNullable<TrackPublishOptions['degradationPreference']> =
|
||||
'maintain-resolution';
|
||||
export const SUPPORTED_SCREEN_SHARE_FRAME_RATES = [15, 30, 60, 90, 120] as const;
|
||||
|
||||
export type SupportedScreenShareFrameRate = (typeof SUPPORTED_SCREEN_SHARE_FRAME_RATES)[number];
|
||||
@@ -50,18 +52,14 @@ export function resolveScreenShareFrameRate(frameRate: number): SupportedScreenS
|
||||
return 15;
|
||||
}
|
||||
|
||||
function getScreenShareDimensions(resolution: ScreenshareResolution): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
return DIMENSIONS[resolution];
|
||||
function getScreenShareRungPixels(resolution: ScreenshareResolution): number {
|
||||
return DIMENSIONS[resolution].width * DIMENSIONS[resolution].height;
|
||||
}
|
||||
|
||||
function resolveBitrateRungForDimensions(dimensions: {width: number; height: number}): ScreenshareResolution {
|
||||
const pixels = dimensions.width * dimensions.height;
|
||||
function resolveBitrateRungForPixels(pixels: number): ScreenshareResolution {
|
||||
let rung: ScreenshareResolution = BITRATE_RUNGS[0];
|
||||
for (const candidate of BITRATE_RUNGS) {
|
||||
if (DIMENSIONS[candidate].width * DIMENSIONS[candidate].height > pixels) break;
|
||||
if (getScreenShareRungPixels(candidate) > pixels) break;
|
||||
rung = candidate;
|
||||
}
|
||||
return rung;
|
||||
@@ -72,9 +70,9 @@ function resolveBitrateRung(
|
||||
sourceDimensions?: {
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
} | null,
|
||||
): ScreenshareResolution {
|
||||
return resolveBitrateRungForDimensions(resolveEffectiveScreenShareDimensions(resolution, sourceDimensions));
|
||||
return resolveEffectiveScreenShareDimensions(resolution, sourceDimensions).rung;
|
||||
}
|
||||
|
||||
export function getScreenShareBitrateBps(
|
||||
@@ -83,32 +81,18 @@ export function getScreenShareBitrateBps(
|
||||
sourceDimensions?: {
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
} | null,
|
||||
): number {
|
||||
return BITRATE_KBPS[resolveBitrateRung(resolution, sourceDimensions)][frameRate] * 1000;
|
||||
}
|
||||
|
||||
export function capScreenShareEncodingToDimensions(
|
||||
encoding: VideoEncoding,
|
||||
dimensions: {width?: number; height?: number} | undefined,
|
||||
): VideoEncoding {
|
||||
if (typeof encoding.maxBitrate !== 'number') return encoding;
|
||||
const width = dimensions?.width;
|
||||
const height = dimensions?.height;
|
||||
if (!width || !height || width <= 0 || height <= 0) return encoding;
|
||||
const frameRate = resolveScreenShareFrameRate(encoding.maxFramerate ?? 60);
|
||||
const ceiling = BITRATE_KBPS[resolveBitrateRungForDimensions({width, height})][frameRate] * 1000;
|
||||
if (encoding.maxBitrate <= ceiling) return encoding;
|
||||
return {...encoding, maxBitrate: ceiling};
|
||||
}
|
||||
|
||||
export function getScreenShareEncoding(
|
||||
resolution: ScreenshareResolution,
|
||||
frameRate: number,
|
||||
sourceDimensions?: {
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
} | null,
|
||||
): VideoEncoding {
|
||||
const resolvedFrameRate = resolveScreenShareFrameRate(frameRate);
|
||||
return {
|
||||
@@ -147,6 +131,7 @@ export interface BuiltScreenShareOptions {
|
||||
export interface ScreenShareBuildConfig {
|
||||
resolution: ScreenshareResolution;
|
||||
frameRate: number;
|
||||
context: ScreenShareContext;
|
||||
includeAudio: boolean;
|
||||
contentHint?: ScreenShareCaptureOptions['contentHint'];
|
||||
sourceDimensions?: {
|
||||
@@ -167,35 +152,43 @@ function resolveScreenShareCursorCapture(
|
||||
return preferredDisplaySurface === 'window' ? 'never' : 'always';
|
||||
}
|
||||
|
||||
function floorToEven(value: number): number {
|
||||
return Math.max(2, Math.floor(value / 2) * 2);
|
||||
}
|
||||
|
||||
export function resolveEffectiveScreenShareDimensions(
|
||||
resolution: ScreenshareResolution,
|
||||
sourceDimensions?: {
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
} | null,
|
||||
): {
|
||||
width: number;
|
||||
height: number;
|
||||
rung: ScreenshareResolution;
|
||||
} {
|
||||
const preset = getScreenShareDimensions(resolution);
|
||||
if (resolution !== 'source' || !sourceDimensions) return preset;
|
||||
if (sourceDimensions.width <= 0 || sourceDimensions.height <= 0) return preset;
|
||||
const preset = DIMENSIONS[resolution];
|
||||
if (!sourceDimensions || sourceDimensions.width <= 0 || sourceDimensions.height <= 0) {
|
||||
return {width: preset.width, height: preset.height, rung: resolution};
|
||||
}
|
||||
const budget = getScreenShareRungPixels(resolution);
|
||||
const sourcePixels = sourceDimensions.width * sourceDimensions.height;
|
||||
if (sourcePixels <= budget) {
|
||||
return {
|
||||
width: sourceDimensions.width,
|
||||
height: sourceDimensions.height,
|
||||
rung: resolveBitrateRungForPixels(sourcePixels),
|
||||
};
|
||||
}
|
||||
const scale = Math.sqrt(budget / sourcePixels);
|
||||
return {
|
||||
width: Math.min(preset.width, sourceDimensions.width),
|
||||
height: Math.min(preset.height, sourceDimensions.height),
|
||||
width: floorToEven(sourceDimensions.width * scale),
|
||||
height: floorToEven(sourceDimensions.height * scale),
|
||||
rung: resolution,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildScreenShareOptions(config: ScreenShareBuildConfig): BuiltScreenShareOptions;
|
||||
export function buildScreenShareOptions(resolution: ScreenshareResolution, frameRate: number): BuiltScreenShareOptions;
|
||||
export function buildScreenShareOptions(
|
||||
configOrResolution: ScreenShareBuildConfig | ScreenshareResolution,
|
||||
maybeFrameRate?: number,
|
||||
): BuiltScreenShareOptions {
|
||||
const config: ScreenShareBuildConfig =
|
||||
typeof configOrResolution === 'object'
|
||||
? configOrResolution
|
||||
: {resolution: configOrResolution, frameRate: maybeFrameRate ?? 30, includeAudio: true};
|
||||
export function buildScreenShareOptions(config: ScreenShareBuildConfig): BuiltScreenShareOptions {
|
||||
const {width, height} = resolveEffectiveScreenShareDimensions(config.resolution, config.sourceDimensions);
|
||||
const resolvedFrameRate = resolveScreenShareFrameRate(config.frameRate);
|
||||
const video: ScreenShareVideoOptions = {
|
||||
@@ -215,7 +208,7 @@ export function buildScreenShareOptions(
|
||||
video,
|
||||
},
|
||||
publishOptions: {
|
||||
degradationPreference: SCREEN_SHARE_DEGRADATION_PREFERENCE,
|
||||
degradationPreference: resolveScreenShareDegradationPreference(config),
|
||||
screenShareEncoding: getScreenShareEncoding(config.resolution, resolvedFrameRate, config.sourceDimensions),
|
||||
},
|
||||
};
|
||||
@@ -263,7 +256,7 @@ export function resolveStreamingModeSettings(
|
||||
return clampToFreeTier(resolved.resolution, resolved.frameRate);
|
||||
}
|
||||
|
||||
export type ScreenShareContext = 'display' | 'device';
|
||||
export type ScreenShareContext = 'display' | 'app' | 'device';
|
||||
|
||||
export function normaliseStreamingModeForContext(mode: StreamingMode, context: ScreenShareContext): StreamingMode {
|
||||
if (context === 'device' && mode === 'screenshare') {
|
||||
@@ -285,3 +278,211 @@ export function normaliseResolutionForContext(
|
||||
}
|
||||
return resolution;
|
||||
}
|
||||
|
||||
const PREMIUM_SCREEN_SHARE_RESOLUTIONS: ReadonlyArray<ScreenshareResolution> = ['high', 'ultra', 'source'];
|
||||
const SENT_PIXEL_FILL_RATIO = 0.98;
|
||||
|
||||
export interface ScreenShareQualityInput {
|
||||
mode: StreamingMode;
|
||||
storedResolution: ScreenshareResolution;
|
||||
storedFrameRate: number;
|
||||
entitled: boolean;
|
||||
context: ScreenShareContext;
|
||||
}
|
||||
|
||||
export interface ScreenShareTargetInput extends ScreenShareQualityInput {
|
||||
sourceDimensions: {width: number; height: number} | null;
|
||||
hintSetting: ScreenShareContentHint;
|
||||
}
|
||||
|
||||
export interface ScreenShareTarget {
|
||||
mode: StreamingMode;
|
||||
resolution: ScreenshareResolution;
|
||||
frameRate: SupportedScreenShareFrameRate;
|
||||
context: ScreenShareContext;
|
||||
width: number;
|
||||
height: number;
|
||||
rung: ScreenshareResolution;
|
||||
maxBitrate: number;
|
||||
contentHint: ScreenShareCaptureOptions['contentHint'];
|
||||
presetOwned: boolean;
|
||||
tierLimited: boolean;
|
||||
deviceMapped: boolean;
|
||||
}
|
||||
|
||||
export function resolveScreenShareDegradationPreference(target: {
|
||||
context: ScreenShareContext;
|
||||
}): NonNullable<TrackPublishOptions['degradationPreference']> {
|
||||
return target.context === 'device' ? 'balanced' : 'maintain-resolution';
|
||||
}
|
||||
|
||||
function resolveEffectiveScreenShareQuality(input: ScreenShareQualityInput): {
|
||||
mode: StreamingMode;
|
||||
resolution: ScreenshareResolution;
|
||||
frameRate: SupportedScreenShareFrameRate;
|
||||
} {
|
||||
const mode = normaliseStreamingModeForContext(input.mode, input.context);
|
||||
const resolution = normaliseResolutionForContext(input.storedResolution, input.context, input.entitled);
|
||||
const settings = resolveStreamingModeSettings(mode, resolution, input.storedFrameRate, input.entitled);
|
||||
return {mode, resolution: settings.resolution, frameRate: settings.frameRate};
|
||||
}
|
||||
|
||||
function resolveScreenShareContentHintForMode(
|
||||
mode: StreamingMode,
|
||||
hintSetting: ScreenShareContentHint,
|
||||
): ScreenShareCaptureOptions['contentHint'] {
|
||||
if (mode === 'gaming') return 'motion';
|
||||
if (mode === 'screenshare') return 'text';
|
||||
return hintSetting === 'auto' ? undefined : hintSetting;
|
||||
}
|
||||
|
||||
export function resolveScreenShareTarget(input: ScreenShareTargetInput): ScreenShareTarget {
|
||||
const effective = resolveEffectiveScreenShareQuality(input);
|
||||
const onDisplay = resolveEffectiveScreenShareQuality({...input, context: 'display'});
|
||||
const fit = resolveEffectiveScreenShareDimensions(effective.resolution, input.sourceDimensions);
|
||||
return {
|
||||
mode: effective.mode,
|
||||
resolution: effective.resolution,
|
||||
frameRate: effective.frameRate,
|
||||
context: input.context,
|
||||
width: fit.width,
|
||||
height: fit.height,
|
||||
rung: fit.rung,
|
||||
maxBitrate: BITRATE_KBPS[fit.rung][effective.frameRate] * 1000,
|
||||
contentHint: resolveScreenShareContentHintForMode(effective.mode, input.hintSetting),
|
||||
presetOwned: effective.mode !== 'custom',
|
||||
tierLimited:
|
||||
!input.entitled &&
|
||||
input.mode === 'custom' &&
|
||||
(PREMIUM_SCREEN_SHARE_RESOLUTIONS.includes(input.storedResolution) ||
|
||||
resolveScreenShareFrameRate(input.storedFrameRate) > FREE_TIER_MAX_FRAME_RATE),
|
||||
deviceMapped:
|
||||
effective.mode !== onDisplay.mode ||
|
||||
effective.resolution !== onDisplay.resolution ||
|
||||
effective.frameRate !== onDisplay.frameRate,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScreenShareLayeringInput {
|
||||
codec: VideoCodec | undefined;
|
||||
svcSetting: ScreenShareScalabilityModePreference | undefined;
|
||||
}
|
||||
|
||||
export interface ScreenShareLayering {
|
||||
simulcast: boolean;
|
||||
scalabilityMode: TrackPublishOptions['scalabilityMode'];
|
||||
}
|
||||
|
||||
export function resolveScreenShareLayering(input: ScreenShareLayeringInput): ScreenShareLayering {
|
||||
if (input.codec !== 'av1' && input.codec !== 'vp9') {
|
||||
return {simulcast: false, scalabilityMode: undefined};
|
||||
}
|
||||
return {
|
||||
simulcast: false,
|
||||
scalabilityMode: input.svcSetting === 'single_layer' ? 'L1T1' : 'L1T3',
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScreenShareSenderParametersInput {
|
||||
encodings: ReadonlyArray<RTCRtpEncodingParameters>;
|
||||
target: ScreenShareTarget;
|
||||
capture: {width: number; height: number} | null;
|
||||
scalabilityMode: TrackPublishOptions['scalabilityMode'];
|
||||
}
|
||||
|
||||
export interface ScreenShareSenderParameters {
|
||||
degradationPreference: NonNullable<TrackPublishOptions['degradationPreference']>;
|
||||
encodings: Array<RTCRtpEncodingParameters>;
|
||||
}
|
||||
|
||||
function distributeScreenShareBitrate(
|
||||
encodings: ReadonlyArray<RTCRtpEncodingParameters>,
|
||||
maxBitrate: number,
|
||||
): Array<number> {
|
||||
if (encodings.length === 1) return [maxBitrate];
|
||||
const weights = encodings.map((encoding) =>
|
||||
typeof encoding.maxBitrate === 'number' && encoding.maxBitrate > 0 ? encoding.maxBitrate : 0,
|
||||
);
|
||||
if (weights.some((weight) => weight <= 0)) {
|
||||
return encodings.map(() => Math.floor(maxBitrate / encodings.length));
|
||||
}
|
||||
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
return weights.map((weight) => Math.floor((weight / total) * maxBitrate));
|
||||
}
|
||||
|
||||
export function resolveScreenShareSenderCodec(
|
||||
codecOverride: VideoCodec | undefined,
|
||||
negotiatedCodec: VideoCodec | undefined,
|
||||
publishedCodec: VideoCodec | undefined,
|
||||
): VideoCodec | undefined {
|
||||
return codecOverride ?? negotiatedCodec ?? publishedCodec;
|
||||
}
|
||||
|
||||
export function buildScreenShareSenderParameters(input: ScreenShareSenderParametersInput): ScreenShareSenderParameters {
|
||||
const targetPixels = input.target.width * input.target.height;
|
||||
const capturePixels = input.capture ? input.capture.width * input.capture.height : null;
|
||||
const baseScale = Math.min(...input.encodings.map((encoding) => encoding.scaleResolutionDownBy ?? 1));
|
||||
const targetScale = capturePixels === null ? 1 : Math.max(1, Math.sqrt(capturePixels / targetPixels));
|
||||
const sentPixels = capturePixels === null ? null : capturePixels / (targetScale * targetScale);
|
||||
const maxBitrate =
|
||||
sentPixels !== null && sentPixels < targetPixels * SENT_PIXEL_FILL_RATIO
|
||||
? Math.min(
|
||||
input.target.maxBitrate,
|
||||
BITRATE_KBPS[resolveBitrateRungForPixels(sentPixels)][input.target.frameRate] * 1000,
|
||||
)
|
||||
: input.target.maxBitrate;
|
||||
const bitrates = distributeScreenShareBitrate(input.encodings, maxBitrate);
|
||||
return {
|
||||
degradationPreference: resolveScreenShareDegradationPreference(input.target),
|
||||
encodings: input.encodings.map((encoding, index) => {
|
||||
const applied: RTCRtpEncodingParameters = {
|
||||
...encoding,
|
||||
maxBitrate: bitrates[index],
|
||||
maxFramerate: input.target.frameRate,
|
||||
priority: 'high',
|
||||
networkPriority: 'high',
|
||||
scaleResolutionDownBy: ((encoding.scaleResolutionDownBy ?? 1) / baseScale) * targetScale,
|
||||
};
|
||||
if (input.scalabilityMode) {
|
||||
applied.scalabilityMode = input.scalabilityMode;
|
||||
} else {
|
||||
delete applied.scalabilityMode;
|
||||
}
|
||||
return applied;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type ScreenShareQualityPick =
|
||||
| {axis: 'resolution'; resolution: ScreenshareResolution}
|
||||
| {axis: 'frameRate'; frameRate: SupportedScreenShareFrameRate};
|
||||
|
||||
export interface ScreenShareQualityPatch {
|
||||
streamingMode: 'custom';
|
||||
screenshareResolution?: ScreenshareResolution;
|
||||
videoFrameRate?: SupportedScreenShareFrameRate;
|
||||
}
|
||||
|
||||
export function resolveScreenShareQualityPick(
|
||||
input: ScreenShareQualityInput,
|
||||
pick: ScreenShareQualityPick,
|
||||
): ScreenShareQualityPatch | null {
|
||||
const effective = resolveEffectiveScreenShareQuality(input);
|
||||
const stored = resolveEffectiveScreenShareQuality({...input, mode: 'custom'});
|
||||
if (pick.axis === 'resolution') {
|
||||
if (normaliseResolutionForContext(pick.resolution, input.context, input.entitled) !== pick.resolution) return null;
|
||||
if (pick.resolution === effective.resolution) return null;
|
||||
return {
|
||||
streamingMode: 'custom',
|
||||
screenshareResolution: pick.resolution,
|
||||
...(stored.frameRate === effective.frameRate ? {} : {videoFrameRate: effective.frameRate}),
|
||||
};
|
||||
}
|
||||
if (!input.entitled && pick.frameRate > FREE_TIER_MAX_FRAME_RATE) return null;
|
||||
if (pick.frameRate === effective.frameRate) return null;
|
||||
return {
|
||||
streamingMode: 'custom',
|
||||
...(stored.resolution === effective.resolution ? {} : {screenshareResolution: effective.resolution}),
|
||||
videoFrameRate: pick.frameRate,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user