replace eager signal deletion with scheduled backend cleanup; add Firefox audio fix, SDP quality tuning, and negotiation hardening
This commit is contained in:
+2
-1
@@ -25,7 +25,8 @@
|
|||||||
"openpgp": "^6.3.0",
|
"openpgp": "^6.3.0",
|
||||||
"spacetimedb": "^2.1.0",
|
"spacetimedb": "^2.1.0",
|
||||||
"svelte": "^5.55.1",
|
"svelte": "^5.55.1",
|
||||||
"svelte-check": "^4.4.6"
|
"svelte-check": "^4.4.6",
|
||||||
|
"webrtc-adapter": "^9.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.31.0",
|
"@cloudflare/vite-plugin": "^1.31.0",
|
||||||
|
|||||||
Generated
+16
@@ -32,6 +32,9 @@ importers:
|
|||||||
svelte-check:
|
svelte-check:
|
||||||
specifier: ^4.4.6
|
specifier: ^4.4.6
|
||||||
version: 4.4.6(picomatch@4.0.4)(svelte@5.55.1)(typescript@5.6.3)
|
version: 4.4.6(picomatch@4.0.4)(svelte@5.55.1)(typescript@5.6.3)
|
||||||
|
webrtc-adapter:
|
||||||
|
specifier: ^9.0.5
|
||||||
|
version: 9.0.5
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@cloudflare/vite-plugin':
|
'@cloudflare/vite-plugin':
|
||||||
specifier: ^1.31.0
|
specifier: ^1.31.0
|
||||||
@@ -1924,6 +1927,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||||
engines: {node: '>=v12.22.7'}
|
engines: {node: '>=v12.22.7'}
|
||||||
|
|
||||||
|
sdp@3.2.2:
|
||||||
|
resolution: {integrity: sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==}
|
||||||
|
|
||||||
semver@7.7.4:
|
semver@7.7.4:
|
||||||
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2234,6 +2240,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
webrtc-adapter@9.0.5:
|
||||||
|
resolution: {integrity: sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==}
|
||||||
|
engines: {node: '>=6.0.0', npm: '>=3.10.0'}
|
||||||
|
|
||||||
whatwg-encoding@3.1.1:
|
whatwg-encoding@3.1.1:
|
||||||
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3841,6 +3851,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
xmlchars: 2.2.0
|
xmlchars: 2.2.0
|
||||||
|
|
||||||
|
sdp@3.2.2: {}
|
||||||
|
|
||||||
semver@7.7.4: {}
|
semver@7.7.4: {}
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.34.5:
|
||||||
@@ -4138,6 +4150,10 @@ snapshots:
|
|||||||
|
|
||||||
webidl-conversions@7.0.0: {}
|
webidl-conversions@7.0.0: {}
|
||||||
|
|
||||||
|
webrtc-adapter@9.0.5:
|
||||||
|
dependencies:
|
||||||
|
sdp: 3.2.2
|
||||||
|
|
||||||
whatwg-encoding@3.1.1:
|
whatwg-encoding@3.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
iconv-lite: 0.6.3
|
iconv-lite: 0.6.3
|
||||||
|
|||||||
@@ -86,6 +86,15 @@ pub fn init(ctx: &ReducerContext) {
|
|||||||
// Grant access to system user
|
// Grant access to system user
|
||||||
sync_server_access(&ctx.db, system_identity, s.id);
|
sync_server_access(&ctx.db, system_identity, s.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed the recurring WebRTC signal cleanup job if it doesn't exist yet
|
||||||
|
if ctx.db.webrtc_signal_cleanup().iter().next().is_none() {
|
||||||
|
let first_run = ctx.timestamp + spacetimedb::TimeDuration::from_micros(30_000_000);
|
||||||
|
ctx.db.webrtc_signal_cleanup().insert(WebRTCSignalCleanup {
|
||||||
|
scheduled_id: 0,
|
||||||
|
scheduled_at: spacetimedb::ScheduleAt::Time(first_run),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[spacetimedb::reducer(client_connected)]
|
#[spacetimedb::reducer(client_connected)]
|
||||||
|
|||||||
@@ -1052,6 +1052,51 @@ pub fn send_webrtc_signal(
|
|||||||
media_type,
|
media_type,
|
||||||
data,
|
data,
|
||||||
channel_id,
|
channel_id,
|
||||||
|
sent: ctx.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[spacetimedb::reducer]
|
||||||
|
pub fn delete_webrtc_signal(ctx: &ReducerContext, signal_id: u64) {
|
||||||
|
if let Some(signal) = ctx.db.webrtc_signal().id().find(signal_id) {
|
||||||
|
// Only allow sender or receiver to delete the signal
|
||||||
|
if signal.sender == ctx.sender() || signal.receiver == ctx.sender() {
|
||||||
|
ctx.db.webrtc_signal().id().delete(signal_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[spacetimedb::table(accessor = webrtc_signal_cleanup, scheduled(run_webrtc_signal_cleanup))]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WebRTCSignalCleanup {
|
||||||
|
#[primary_key]
|
||||||
|
#[auto_inc]
|
||||||
|
pub scheduled_id: u64,
|
||||||
|
pub scheduled_at: spacetimedb::ScheduleAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[spacetimedb::reducer]
|
||||||
|
pub fn run_webrtc_signal_cleanup(ctx: &ReducerContext, _cleanup: WebRTCSignalCleanup) {
|
||||||
|
const CLEANUP_INTERVAL_MICROS: i64 = 30_000_000; // 30 seconds
|
||||||
|
const SIGNAL_TTL_MICROS: i64 = 60_000_000; // 60 seconds
|
||||||
|
|
||||||
|
let cutoff = ctx.timestamp - spacetimedb::TimeDuration::from_micros(SIGNAL_TTL_MICROS);
|
||||||
|
|
||||||
|
let stale: Vec<u64> = ctx.db.webrtc_signal()
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.sent < cutoff)
|
||||||
|
.map(|s| s.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for id in stale {
|
||||||
|
ctx.db.webrtc_signal().id().delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-schedule next cleanup
|
||||||
|
let next_run = ctx.timestamp + spacetimedb::TimeDuration::from_micros(CLEANUP_INTERVAL_MICROS);
|
||||||
|
ctx.db.webrtc_signal_cleanup().insert(WebRTCSignalCleanup {
|
||||||
|
scheduled_id: 0,
|
||||||
|
scheduled_at: spacetimedb::ScheduleAt::Time(next_run),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,8 +152,11 @@ pub struct WebRTCSignal {
|
|||||||
pub data: String,
|
pub data: String,
|
||||||
#[index(btree)]
|
#[index(btree)]
|
||||||
pub channel_id: u64,
|
pub channel_id: u64,
|
||||||
|
pub sent: Timestamp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#[spacetimedb::table(accessor = channel_subscription)]
|
#[spacetimedb::table(accessor = channel_subscription)]
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ChannelSubscription {
|
pub struct ChannelSubscription {
|
||||||
|
|||||||
@@ -415,13 +415,23 @@ pub fn clear_user_presence(db: &Local, identity: Identity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_signaling_for_user(db: &Local, identity: Identity) {
|
pub fn clear_signaling_for_user(db: &Local, identity: Identity) {
|
||||||
let signals: Vec<_> = db
|
let signals_as_sender: Vec<_> = db
|
||||||
.webrtc_signal()
|
.webrtc_signal()
|
||||||
.sender()
|
.sender()
|
||||||
.filter(identity)
|
.filter(identity)
|
||||||
.map(|s| s.id)
|
.map(|s| s.id)
|
||||||
.collect();
|
.collect();
|
||||||
for id in signals {
|
for id in signals_as_sender {
|
||||||
|
db.webrtc_signal().id().delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let signals_as_receiver: Vec<_> = db
|
||||||
|
.webrtc_signal()
|
||||||
|
.receiver()
|
||||||
|
.filter(identity)
|
||||||
|
.map(|s| s.id)
|
||||||
|
.collect();
|
||||||
|
for id in signals_as_receiver {
|
||||||
db.webrtc_signal().id().delete(id);
|
db.webrtc_signal().id().delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,53 +17,19 @@
|
|||||||
let providerKey = $state(0);
|
let providerKey = $state(0);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Hold off until OIDC is settled for the first time
|
// Hold off until OIDC is settled
|
||||||
if (auth.isLoading) return;
|
if (auth.isLoading) return;
|
||||||
|
|
||||||
const currentToken = auth.user?.id_token;
|
const currentToken = auth.user?.id_token;
|
||||||
|
|
||||||
// 1. Initial creation
|
// Only (re)create the builder if we don't have one, or if the token changed.
|
||||||
if (!builder) {
|
if (!builder || currentToken !== lastUsedOidcToken) {
|
||||||
console.log(`[SpacetimeProvider] Initializing connection builder. OIDC present: ${!!currentToken}`);
|
console.log(`[SpacetimeProvider] Initializing connection (Auth Settled). OIDC present: ${!!currentToken}`);
|
||||||
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
builder = connectionBuilder(currentToken);
|
builder = connectionBuilder(currentToken);
|
||||||
lastUsedOidcToken = currentToken;
|
lastUsedOidcToken = currentToken;
|
||||||
providerKey += 1;
|
providerKey += 1; // Force re-mount of InnerSpacetimeDBProvider for a clean handshake
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Identity transition (Logged out -> Logged in)
|
|
||||||
// If we were a guest (or null) and now have a token, we SHOULD remount
|
|
||||||
// to ensure the OIDC credentials take over completely.
|
|
||||||
if (currentToken && !lastUsedOidcToken) {
|
|
||||||
console.log("[SpacetimeProvider] Transitioning from Guest/None to OIDC session. Remounting...");
|
|
||||||
untrack(() => {
|
|
||||||
builder = connectionBuilder(currentToken);
|
|
||||||
lastUsedOidcToken = currentToken;
|
|
||||||
providerKey += 1;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Background Refresh (Token -> New Token)
|
|
||||||
// If it's just a refresh, we DON'T remount. We let InnerSpacetimeDBProvider
|
|
||||||
// handle the in-place upgrade via withToken.
|
|
||||||
if (currentToken && currentToken !== lastUsedOidcToken) {
|
|
||||||
console.log("[SpacetimeProvider] Background token refresh detected. Upgrading in-place.");
|
|
||||||
untrack(() => {
|
|
||||||
lastUsedOidcToken = currentToken;
|
|
||||||
// Notice we DON'T increment providerKey here
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Logout (Token -> Null)
|
|
||||||
if (!currentToken && lastUsedOidcToken) {
|
|
||||||
console.log("[SpacetimeProvider] User logged out. Remounting for Guest mode.");
|
|
||||||
untrack(() => {
|
|
||||||
builder = connectionBuilder(undefined);
|
|
||||||
lastUsedOidcToken = undefined;
|
|
||||||
providerKey += 1;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -71,17 +37,36 @@
|
|||||||
// Reactive labels for the loading screen
|
// Reactive labels for the loading screen
|
||||||
const host = getStdbHost();
|
const host = getStdbHost();
|
||||||
const dbName = getStdbDbName();
|
const dbName = getStdbDbName();
|
||||||
|
|
||||||
|
// Unified status message logic
|
||||||
|
const statusInfo = $derived.by(() => {
|
||||||
|
if (auth.isLoading) {
|
||||||
|
return {
|
||||||
|
title: "Authenticating...",
|
||||||
|
icon: "fa-id-card",
|
||||||
|
message: "Verifying your identity with the provider."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!builder) {
|
||||||
|
return {
|
||||||
|
title: "Preparing Handshake...",
|
||||||
|
icon: "fa-key",
|
||||||
|
message: "Finalizing security credentials."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default connecting state
|
||||||
|
return {
|
||||||
|
title: "Connecting to SpacetimeDB...",
|
||||||
|
icon: "fa-circle-notch",
|
||||||
|
spin: true,
|
||||||
|
message: "Establishing a secure connection to the chat server."
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !builder || (auth.isLoading && !auth.user)}
|
{#if builder && !auth.isLoading}
|
||||||
<div class="login-screen">
|
|
||||||
<div class="login-card" style="text-align: center;">
|
|
||||||
<i class="fas fa-id-card fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
|
|
||||||
<h1>Authenticating...</h1>
|
|
||||||
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Synchronizing your session credentials.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#key providerKey}
|
{#key providerKey}
|
||||||
<InnerSpacetimeDBProvider
|
<InnerSpacetimeDBProvider
|
||||||
{builder}
|
{builder}
|
||||||
@@ -93,6 +78,35 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
</InnerSpacetimeDBProvider>
|
</InnerSpacetimeDBProvider>
|
||||||
{/key}
|
{/key}
|
||||||
|
{:else}
|
||||||
|
<div class="login-screen">
|
||||||
|
<div class="login-card" style="text-align: center;">
|
||||||
|
<i class="fas {statusInfo.icon} {statusInfo.spin ? 'fa-spin' : 'fa-pulse'}" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
|
||||||
|
<h1>{statusInfo.title}</h1>
|
||||||
|
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">{statusInfo.message}</p>
|
||||||
|
|
||||||
|
<div class="connection-details-box">
|
||||||
|
<div class="connection-detail">
|
||||||
|
<div class="detail-label">Host</div>
|
||||||
|
<div class="detail-value">{host}</div>
|
||||||
|
</div>
|
||||||
|
<div class="connection-detail">
|
||||||
|
<div class="detail-label">Database</div>
|
||||||
|
<div class="detail-value">{dbName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if onCancel}
|
||||||
|
<button
|
||||||
|
onclick={onCancel}
|
||||||
|
class="btn-secondary"
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -134,4 +148,35 @@
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-details-box {
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid var(--background-modifier-accent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-family: var(--font-code);
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useSpacetimeDB } from "spacetimedb/svelte";
|
import { useSpacetimeDB } from "spacetimedb/svelte";
|
||||||
import { setContext, onMount, untrack } from "svelte";
|
import { setContext, onMount, untrack } from "svelte";
|
||||||
import { ChatService } from "./services/chat.svelte";
|
import { ChatService, Permissions } from "./services/chat.svelte";
|
||||||
import { WebRTCService } from "./services/webrtc/webrtc.svelte";
|
import { WebRTCService } from "./services/webrtc/webrtc.svelte";
|
||||||
import ServerList from "./components/ServerList.svelte";
|
import ServerList from "./components/ServerList.svelte";
|
||||||
import ChannelList from "./components/ChannelList.svelte";
|
import ChannelList from "./components/ChannelList.svelte";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { Permissions } from "../../services/chat.svelte";
|
import { Permissions } from "../../services/chat.svelte";
|
||||||
import Button from "../ui/Button.svelte";
|
import Button from "../ui/Button.svelte";
|
||||||
import Input from "../ui/Input.svelte";
|
import Input from "../ui/Input.svelte";
|
||||||
import type * as Types from "../../../module_bindings/types";
|
|
||||||
|
|
||||||
const chat = getContext<ChatService>("chat");
|
const chat = getContext<ChatService>("chat");
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@
|
|||||||
class:drag-over={dragOverRoleId === -1n}
|
class:drag-over={dragOverRoleId === -1n}
|
||||||
ondragover={(e) => { e.preventDefault(); dragOverRoleId = -1n; }}
|
ondragover={(e) => { e.preventDefault(); dragOverRoleId = -1n; }}
|
||||||
onondragleave={() => dragOverRoleId = null}
|
onondragleave={() => dragOverRoleId = null}
|
||||||
ondrop={(e) => {
|
ondrop={(_e) => {
|
||||||
if (draggedRoleId !== null) {
|
if (draggedRoleId !== null) {
|
||||||
// Logic to move to the very bottom
|
// Logic to move to the very bottom
|
||||||
const roleList = [...roles];
|
const roleList = [...roles];
|
||||||
|
|||||||
@@ -24,17 +24,20 @@ export class ChannelAudioWebRTCService {
|
|||||||
isDeafened = $state(false);
|
isDeafened = $state(false);
|
||||||
|
|
||||||
peerManager: PeerManagerService;
|
peerManager: PeerManagerService;
|
||||||
|
localMedia: any; // Type added in constructor
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
identity: Identity | null,
|
identity: Identity | null,
|
||||||
connectedChannelId: bigint | undefined,
|
connectedChannelId: bigint | undefined,
|
||||||
localStream: MediaStream | null,
|
localStream: MediaStream | null,
|
||||||
isDeafened: boolean,
|
isDeafened: boolean,
|
||||||
|
localMedia: any,
|
||||||
) {
|
) {
|
||||||
this.identity = identity;
|
this.identity = identity;
|
||||||
this.connectedChannelId = connectedChannelId;
|
this.connectedChannelId = connectedChannelId;
|
||||||
this.localStream = localStream;
|
this.localStream = localStream;
|
||||||
this.isDeafened = isDeafened;
|
this.isDeafened = isDeafened;
|
||||||
|
this.localMedia = localMedia;
|
||||||
|
|
||||||
const [usStore] = useTable(tables.visible_user_states);
|
const [usStore] = useTable(tables.visible_user_states);
|
||||||
usStore.subscribe((v) => (this.userStates = v));
|
usStore.subscribe((v) => (this.userStates = v));
|
||||||
@@ -58,9 +61,9 @@ export class ChannelAudioWebRTCService {
|
|||||||
|
|
||||||
// Track Syncing
|
// Track Syncing
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const audioTrack = this.localStream?.getAudioTracks()[0] || null;
|
const transmittableStream = this.localMedia.localStream ? this.localMedia.getTransmittableStream() : null;
|
||||||
// Accessing peers and peerStatuses to trigger effect on changes
|
const audioTrack = transmittableStream?.getAudioTracks()[0] || null;
|
||||||
void this.peerManager.peerStatuses;
|
|
||||||
this.peerManager.peers.forEach(async (peer, peerIdHex) => {
|
this.peerManager.peers.forEach(async (peer, peerIdHex) => {
|
||||||
const transceivers = peer.pc.getTransceivers();
|
const transceivers = peer.pc.getTransceivers();
|
||||||
const audioTransceiver = transceivers.find(t => t.receiver.track.kind === 'audio' || t.sender.track?.kind === 'audio');
|
const audioTransceiver = transceivers.find(t => t.receiver.track.kind === 'audio' || t.sender.track?.kind === 'audio');
|
||||||
@@ -90,14 +93,14 @@ export class ChannelAudioWebRTCService {
|
|||||||
const currentIdentity = this.identity;
|
const currentIdentity = this.identity;
|
||||||
|
|
||||||
if (!currentChannelId || !currentIdentity) {
|
if (!currentChannelId || !currentIdentity) {
|
||||||
console.log(`[WebRTC][voice] Cleaning up channel state (Channel: ${currentChannelId}, Identity: ${currentIdentity?.toHexString().substring(0,8)})`);
|
if (lastChannelId) {
|
||||||
this.peerManager.peers.forEach((_, id) =>
|
console.log(`[WebRTC][voice] Cleaning up channel state (Channel: ${lastChannelId}, Identity: ${currentIdentity?.toHexString().substring(0,8)})`);
|
||||||
this.peerManager.closePeer(id),
|
this.peerManager.peers.forEach((_, id) => this.peerManager.closePeer(id));
|
||||||
);
|
this.processedSignals.clear();
|
||||||
this.processedSignals.clear();
|
this.makingOffer.clear();
|
||||||
this.makingOffer.clear();
|
this.ignoreOffer.clear();
|
||||||
this.ignoreOffer.clear();
|
this.signalingQueue.clear();
|
||||||
this.signalingQueue.clear();
|
}
|
||||||
lastChannelId = undefined;
|
lastChannelId = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -128,7 +131,8 @@ export class ChannelAudioWebRTCService {
|
|||||||
console.log(`[WebRTC][voice] Initiating mesh connection to ${id}`);
|
console.log(`[WebRTC][voice] Initiating mesh connection to ${id}`);
|
||||||
const pc = this.peerManager.createPeerConnection(id);
|
const pc = this.peerManager.createPeerConnection(id);
|
||||||
if (pc) {
|
if (pc) {
|
||||||
const audioTrack = this.localStream?.getAudioTracks()[0] || null;
|
const transmittableStream = this.localMedia.localStream ? this.localMedia.getTransmittableStream() : null;
|
||||||
|
const audioTrack = transmittableStream?.getAudioTracks()[0] || null;
|
||||||
const transceiver = pc.addTransceiver('audio', { direction: 'sendrecv' });
|
const transceiver = pc.addTransceiver('audio', { direction: 'sendrecv' });
|
||||||
if (audioTrack) {
|
if (audioTrack) {
|
||||||
transceiver.sender.replaceTrack(audioTrack);
|
transceiver.sender.replaceTrack(audioTrack);
|
||||||
@@ -161,10 +165,6 @@ export class ChannelAudioWebRTCService {
|
|||||||
s.mediaType.tag === "Voice"
|
s.mediaType.tag === "Voice"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mySignals.length > 0) {
|
|
||||||
console.log(`[WebRTC][voice] Found ${mySignals.length} signals for channel ${currentChannelId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const signal of mySignals) {
|
for (const signal of mySignals) {
|
||||||
if (this.processedSignals.has(signal.id)) continue;
|
if (this.processedSignals.has(signal.id)) continue;
|
||||||
this.processedSignals.add(signal.id);
|
this.processedSignals.add(signal.id);
|
||||||
@@ -181,6 +181,7 @@ export class ChannelAudioWebRTCService {
|
|||||||
this.handleIceCandidate(signal);
|
this.handleIceCandidate(signal);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -227,7 +228,10 @@ export class ChannelAudioWebRTCService {
|
|||||||
return;
|
return;
|
||||||
try {
|
try {
|
||||||
this.makingOffer.set(peerIdHex, true);
|
this.makingOffer.set(peerIdHex, true);
|
||||||
await pc.setLocalDescription();
|
const offer = await pc.createOffer();
|
||||||
|
const mungedOffer = this.peerManager.mungeSDP(offer);
|
||||||
|
await pc.setLocalDescription(mungedOffer);
|
||||||
|
|
||||||
console.log(`[WebRTC][voice] Sending Offer to ${peerIdHex.substring(0,8)}`);
|
console.log(`[WebRTC][voice] Sending Offer to ${peerIdHex.substring(0,8)}`);
|
||||||
this.#sendSignal({
|
this.#sendSignal({
|
||||||
receiver: Identity.fromString(peerIdHex),
|
receiver: Identity.fromString(peerIdHex),
|
||||||
@@ -236,6 +240,8 @@ export class ChannelAudioWebRTCService {
|
|||||||
data: JSON.stringify(pc.localDescription),
|
data: JSON.stringify(pc.localDescription),
|
||||||
channelId: this.connectedChannelId,
|
channelId: this.connectedChannelId,
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[WebRTC][voice] Error during negotiation for ${peerIdHex}`, e);
|
||||||
} finally {
|
} finally {
|
||||||
this.makingOffer.set(peerIdHex, false);
|
this.makingOffer.set(peerIdHex, false);
|
||||||
}
|
}
|
||||||
@@ -249,7 +255,7 @@ export class ChannelAudioWebRTCService {
|
|||||||
receiver: Identity.fromString(peerIdHex),
|
receiver: Identity.fromString(peerIdHex),
|
||||||
signalKind: { tag: "IceCandidate" },
|
signalKind: { tag: "IceCandidate" },
|
||||||
mediaType: { tag: "Voice" },
|
mediaType: { tag: "Voice" },
|
||||||
data: JSON.stringify(candidate),
|
data: JSON.stringify(candidate.toJSON()),
|
||||||
channelId: this.connectedChannelId,
|
channelId: this.connectedChannelId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -257,40 +263,61 @@ export class ChannelAudioWebRTCService {
|
|||||||
|
|
||||||
handleOffer(signal: Types.WebRtcSignal) {
|
handleOffer(signal: Types.WebRtcSignal) {
|
||||||
const peerIdHex = signal.sender.toHexString();
|
const peerIdHex = signal.sender.toHexString();
|
||||||
|
console.log(`[WebRTC][voice] Received Offer from ${peerIdHex.substring(0,8)}`);
|
||||||
this.enqueueSignalingTask(peerIdHex, async () => {
|
this.enqueueSignalingTask(peerIdHex, async () => {
|
||||||
const pc = this.peerManager.createPeerConnection(peerIdHex);
|
const pc = this.peerManager.createPeerConnection(peerIdHex);
|
||||||
if (!pc) return;
|
if (!pc) return;
|
||||||
try {
|
try {
|
||||||
|
const description = JSON.parse(signal.data);
|
||||||
const isPolite = this.identity!.toHexString() < peerIdHex;
|
const isPolite = this.identity!.toHexString() < peerIdHex;
|
||||||
const offerCollision =
|
const offerCollision =
|
||||||
pc.signalingState !== "stable" || !!this.makingOffer.get(peerIdHex);
|
description.type === "offer" &&
|
||||||
|
(pc.signalingState !== "stable" || !!this.makingOffer.get(peerIdHex));
|
||||||
|
|
||||||
const ignoreOffer = !isPolite && offerCollision;
|
const ignoreOffer = !isPolite && offerCollision;
|
||||||
this.ignoreOffer.set(peerIdHex, ignoreOffer);
|
this.ignoreOffer.set(peerIdHex, ignoreOffer);
|
||||||
if (ignoreOffer) return;
|
if (ignoreOffer) {
|
||||||
|
console.log(`[WebRTC][voice] Ignoring offer collision from ${peerIdHex.substring(0,8)} (Impolite)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (offerCollision) await pc.setLocalDescription({ type: "rollback" });
|
if (offerCollision) {
|
||||||
await pc.setRemoteDescription(
|
console.log(`[WebRTC][voice] Rolling back for offer from ${peerIdHex.substring(0,8)} (Polite)`);
|
||||||
new RTCSessionDescription(JSON.parse(signal.data)),
|
await Promise.all([
|
||||||
);
|
pc.setLocalDescription({ type: "rollback" }),
|
||||||
|
pc.setRemoteDescription(description)
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
await pc.setRemoteDescription(description);
|
||||||
|
}
|
||||||
|
|
||||||
// Map local track to the transceiver created by the offer
|
// Map local track to the transceiver created by the offer
|
||||||
const transceivers = pc.getTransceivers();
|
const transceivers = pc.getTransceivers();
|
||||||
const audioTrack = this.localStream?.getAudioTracks()[0];
|
const transmittableStream = this.localMedia.localStream ? this.localMedia.getTransmittableStream() : null;
|
||||||
const audioTransceiver = transceivers.find((t: RTCRtpTransceiver) => t.receiver.track.kind === 'audio');
|
const audioTrack = transmittableStream?.getAudioTracks()[0];
|
||||||
|
const audioTransceiver = transceivers.find((t: RTCRtpTransceiver) =>
|
||||||
|
t.receiver.track.kind === 'audio' || t.sender.track?.kind === 'audio'
|
||||||
|
);
|
||||||
|
|
||||||
if (audioTransceiver && audioTrack) {
|
if (audioTransceiver) {
|
||||||
await audioTransceiver.sender.replaceTrack(audioTrack);
|
if (audioTrack) {
|
||||||
audioTransceiver.direction = 'sendrecv';
|
await audioTransceiver.sender.replaceTrack(audioTrack);
|
||||||
|
}
|
||||||
|
if (audioTransceiver.direction === 'recvonly') {
|
||||||
|
audioTransceiver.direction = 'sendrecv';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const answer = await pc.createAnswer();
|
const answer = await pc.createAnswer();
|
||||||
await pc.setLocalDescription(answer);
|
const mungedAnswer = this.peerManager.mungeSDP(answer);
|
||||||
|
await pc.setLocalDescription(mungedAnswer);
|
||||||
|
|
||||||
console.log(`[WebRTC][voice] Sending Answer to ${peerIdHex.substring(0,8)}`);
|
console.log(`[WebRTC][voice] Sending Answer to ${peerIdHex.substring(0,8)}`);
|
||||||
this.#sendSignal({
|
this.#sendSignal({
|
||||||
receiver: signal.sender,
|
receiver: signal.sender,
|
||||||
signalKind: { tag: "Answer" },
|
signalKind: { tag: "Answer" },
|
||||||
mediaType: { tag: "Voice" },
|
mediaType: { tag: "Voice" },
|
||||||
data: JSON.stringify(answer),
|
data: JSON.stringify(pc.localDescription),
|
||||||
channelId: this.connectedChannelId!,
|
channelId: this.connectedChannelId!,
|
||||||
});
|
});
|
||||||
await this.drainCandidateQueue(peerIdHex, pc);
|
await this.drainCandidateQueue(peerIdHex, pc);
|
||||||
@@ -310,9 +337,8 @@ export class ChannelAudioWebRTCService {
|
|||||||
const peer = this.peerManager.getPeer(peerIdHex);
|
const peer = this.peerManager.getPeer(peerIdHex);
|
||||||
if (!peer) return;
|
if (!peer) return;
|
||||||
try {
|
try {
|
||||||
await peer.pc.setRemoteDescription(
|
const description = JSON.parse(signal.data);
|
||||||
new RTCSessionDescription(JSON.parse(signal.data)),
|
await peer.pc.setRemoteDescription(description);
|
||||||
);
|
|
||||||
await this.drainCandidateQueue(peerIdHex, peer.pc);
|
await this.drainCandidateQueue(peerIdHex, peer.pc);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -325,24 +351,24 @@ export class ChannelAudioWebRTCService {
|
|||||||
|
|
||||||
handleIceCandidate(signal: Types.WebRtcSignal) {
|
handleIceCandidate(signal: Types.WebRtcSignal) {
|
||||||
const peerIdHex = signal.sender.toHexString();
|
const peerIdHex = signal.sender.toHexString();
|
||||||
console.log(`[WebRTC][voice] Received ICE candidate from ${peerIdHex.substring(0,8)}`);
|
|
||||||
this.enqueueSignalingTask(peerIdHex, async () => {
|
this.enqueueSignalingTask(peerIdHex, async () => {
|
||||||
const pc = this.peerManager.createPeerConnection(peerIdHex);
|
const pc = this.peerManager.getPeer(peerIdHex)?.pc;
|
||||||
if (!pc) return;
|
if (!pc) return;
|
||||||
try {
|
try {
|
||||||
const candidate = JSON.parse(signal.data);
|
const candidate = JSON.parse(signal.data);
|
||||||
if (pc.remoteDescription) {
|
if (candidate && candidate.candidate) {
|
||||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
if (pc.remoteDescription) {
|
||||||
} else if (!this.ignoreOffer.get(peerIdHex)) {
|
await pc.addIceCandidate(candidate);
|
||||||
const queue = this.candidateQueue.get(peerIdHex) || [];
|
} else {
|
||||||
queue.push(candidate);
|
const queue = this.candidateQueue.get(peerIdHex) || [];
|
||||||
this.candidateQueue.set(peerIdHex, queue);
|
queue.push(candidate);
|
||||||
|
this.candidateQueue.set(peerIdHex, queue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
if (!this.ignoreOffer.get(peerIdHex)) {
|
||||||
`[WebRTC][voice] Failed to handle ICE from ${peerIdHex}`,
|
console.warn(`[WebRTC][voice] Failed to handle ICE from ${peerIdHex}`, e);
|
||||||
e,
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export class LocalMediaService {
|
|||||||
|
|
||||||
connectedChannelId: bigint | undefined = $state();
|
connectedChannelId: bigint | undefined = $state();
|
||||||
#audioContext: AudioContext | null = null;
|
#audioContext: AudioContext | null = null;
|
||||||
|
#outboundGainNode: GainNode | null = null;
|
||||||
|
#outboundStream: MediaStream | null = null;
|
||||||
|
|
||||||
constructor(connectedChannelId: bigint | undefined) {
|
constructor(connectedChannelId: bigint | undefined) {
|
||||||
this.connectedChannelId = connectedChannelId;
|
this.connectedChannelId = connectedChannelId;
|
||||||
@@ -52,14 +54,14 @@ export class LocalMediaService {
|
|||||||
this.enumerateDevices();
|
this.enumerateDevices();
|
||||||
navigator.mediaDevices.ondevicechange = () => this.enumerateDevices();
|
navigator.mediaDevices.ondevicechange = () => this.enumerateDevices();
|
||||||
|
|
||||||
// Handle Mute/Deafen/VAD effect on tracks
|
// Handle Mute/Deafen/VAD effect on OUTBOUND stream
|
||||||
|
// Instead of disabling the track (which breaks SRTP in Firefox),
|
||||||
|
// we use a GainNode to silence the signal.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (this.localStream) {
|
if (this.#outboundGainNode) {
|
||||||
this.localStream.getAudioTracks().forEach((track) => {
|
const isSilenced = this.isMuted || this.isDeafened || !this.isTalking;
|
||||||
// Track is enabled only if NOT muted, NOT deafened, and ACTIVELY talking
|
const ctx = this.getAudioContext();
|
||||||
// This prevents background noise from leaking to other peers.
|
this.#outboundGainNode.gain.setTargetAtTime(isSilenced ? 0 : 1, ctx.currentTime, 0.01);
|
||||||
track.enabled = !this.isMuted && !this.isDeafened && this.isTalking;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -254,6 +256,30 @@ export class LocalMediaService {
|
|||||||
return this.#audioContext;
|
return this.#audioContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a stream suitable for WebRTC transmission.
|
||||||
|
* Uses Web Audio to provide clean gating without breaking the SRTP transport.
|
||||||
|
* Memoizes the stream to prevent leaking nodes and redundant processing.
|
||||||
|
*/
|
||||||
|
getTransmittableStream = () => {
|
||||||
|
if (!this.localStream) return null;
|
||||||
|
if (this.#outboundStream) return this.#outboundStream;
|
||||||
|
|
||||||
|
console.log("[local-media] Creating outbound transmittable stream...");
|
||||||
|
const ctx = this.getAudioContext();
|
||||||
|
const source = ctx.createMediaStreamSource(this.localStream);
|
||||||
|
const destination = ctx.createMediaStreamDestination();
|
||||||
|
const gainNode = ctx.createGain();
|
||||||
|
|
||||||
|
source.connect(gainNode);
|
||||||
|
gainNode.connect(destination);
|
||||||
|
|
||||||
|
this.#outboundGainNode = gainNode;
|
||||||
|
this.#outboundStream = destination.stream;
|
||||||
|
|
||||||
|
return this.#outboundStream;
|
||||||
|
};
|
||||||
|
|
||||||
get isSharingScreen() {
|
get isSharingScreen() {
|
||||||
return !!this.localScreenStream;
|
return !!this.localScreenStream;
|
||||||
}
|
}
|
||||||
@@ -291,14 +317,22 @@ export class LocalMediaService {
|
|||||||
try {
|
try {
|
||||||
console.log("[local-media] Requesting mic permission...");
|
console.log("[local-media] Requesting mic permission...");
|
||||||
this.getAudioContext(); // Resume/Init context on user gesture
|
this.getAudioContext(); // Resume/Init context on user gesture
|
||||||
const constraints: MediaStreamConstraints = {
|
|
||||||
audio:
|
const audioConstraints: MediaTrackConstraints = {
|
||||||
this.selectedDeviceId && this.selectedDeviceId !== "default"
|
echoCancellation: true,
|
||||||
? { deviceId: { exact: this.selectedDeviceId } }
|
noiseSuppression: true,
|
||||||
: true,
|
autoGainControl: true,
|
||||||
video: false,
|
channelCount: 1, // Mono is more efficient for voice mesh
|
||||||
};
|
};
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
||||||
|
if (this.selectedDeviceId && this.selectedDeviceId !== "default") {
|
||||||
|
audioConstraints.deviceId = { exact: this.selectedDeviceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: audioConstraints,
|
||||||
|
video: false,
|
||||||
|
});
|
||||||
this.localStream = stream;
|
this.localStream = stream;
|
||||||
console.log("[local-media] Mic stream acquired successfully.");
|
console.log("[local-media] Mic stream acquired successfully.");
|
||||||
// Re-enumerate to get labels if they weren't available before
|
// Re-enumerate to get labels if they weren't available before
|
||||||
@@ -322,6 +356,11 @@ export class LocalMediaService {
|
|||||||
this.localStream.getTracks().forEach((track) => track.stop());
|
this.localStream.getTracks().forEach((track) => track.stop());
|
||||||
this.localStream = null;
|
this.localStream = null;
|
||||||
}
|
}
|
||||||
|
if (this.#outboundGainNode) {
|
||||||
|
this.#outboundGainNode.disconnect();
|
||||||
|
this.#outboundGainNode = null;
|
||||||
|
}
|
||||||
|
this.#outboundStream = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
startScreenShare = async (
|
startScreenShare = async (
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { Identity } from "spacetimedb";
|
|||||||
import type { Peer, WebRTCStats } from "./types";
|
import type { Peer, WebRTCStats } from "./types";
|
||||||
|
|
||||||
const ICE_SERVERS: RTCConfiguration = {
|
const ICE_SERVERS: RTCConfiguration = {
|
||||||
iceServers: [{ urls: "stun:stun.services.mozilla.com:3478" }],
|
iceServers: [
|
||||||
|
{ urls: "stun:stun.l.google.com:19302" },
|
||||||
|
{ urls: "stun:stun1.l.google.com:19302" },
|
||||||
|
{ urls: "stun:stun2.l.google.com:19302" },
|
||||||
|
{ urls: "stun:stun.services.mozilla.com" },
|
||||||
|
],
|
||||||
|
iceCandidatePoolSize: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class PeerManagerService {
|
export class PeerManagerService {
|
||||||
@@ -210,7 +216,11 @@ export class PeerManagerService {
|
|||||||
console.log(
|
console.log(
|
||||||
`[WebRTC][${this.mediaType}] Creating new PeerConnection for ${peerIdHex}`,
|
`[WebRTC][${this.mediaType}] Creating new PeerConnection for ${peerIdHex}`,
|
||||||
);
|
);
|
||||||
const pc = new RTCPeerConnection(ICE_SERVERS);
|
const pc = new RTCPeerConnection({
|
||||||
|
...ICE_SERVERS,
|
||||||
|
bundlePolicy: 'max-bundle',
|
||||||
|
rtcpMuxPolicy: 'require'
|
||||||
|
});
|
||||||
|
|
||||||
pc.onnegotiationneeded = () => {
|
pc.onnegotiationneeded = () => {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -376,6 +386,48 @@ export class PeerManagerService {
|
|||||||
|
|
||||||
getPeer = (peerIdHex: string) => this.peers.get(peerIdHex);
|
getPeer = (peerIdHex: string) => this.peers.get(peerIdHex);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Munges the SDP to force high-quality Opus audio parameters.
|
||||||
|
* Forces stereo=1, sprop-stereo=1, and maxaveragebitrate=48000.
|
||||||
|
* 48kbps is a better balance of quality and resilience for mesh networking.
|
||||||
|
*/
|
||||||
|
mungeSDP = (description: RTCSessionDescriptionInit): RTCSessionDescriptionInit => {
|
||||||
|
if (!description.sdp) return description;
|
||||||
|
|
||||||
|
let sdp = description.sdp;
|
||||||
|
|
||||||
|
// 1. Force Opus to use high bitrate and stereo
|
||||||
|
// We look for the Opus rtpmap and then the corresponding fmtp line
|
||||||
|
const opusRtpMap = sdp.match(/a=rtpmap:(\d+) opus\/48000\/2/);
|
||||||
|
if (opusRtpMap) {
|
||||||
|
const payloadType = opusRtpMap[1];
|
||||||
|
const fmtpRegex = new RegExp(`a=fmtp:${payloadType} (.*)`);
|
||||||
|
const fmtpMatch = sdp.match(fmtpRegex);
|
||||||
|
|
||||||
|
if (fmtpMatch) {
|
||||||
|
let params = fmtpMatch[1];
|
||||||
|
if (!params.includes("stereo=1")) params += ";stereo=1";
|
||||||
|
if (!params.includes("sprop-stereo=1")) params += ";sprop-stereo=1";
|
||||||
|
// Lowered from 128000 to 48000 for better mesh resilience
|
||||||
|
if (params.includes("maxaveragebitrate=")) {
|
||||||
|
params = params.replace(/maxaveragebitrate=\d+/, "maxaveragebitrate=48000");
|
||||||
|
} else {
|
||||||
|
params += ";maxaveragebitrate=48000";
|
||||||
|
}
|
||||||
|
|
||||||
|
sdp = sdp.replace(fmtpMatch[0], `a=fmtp:${payloadType} ${params}`);
|
||||||
|
} else {
|
||||||
|
// Add fmtp line if missing
|
||||||
|
sdp = sdp.replace(opusRtpMap[0], `${opusRtpMap[0]}\na=fmtp:${payloadType} stereo=1;sprop-stereo=1;maxaveragebitrate=48000`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...description,
|
||||||
|
sdp
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
applyEncoderSettings = async (pc: RTCPeerConnection, peerIdHex: string) => {
|
applyEncoderSettings = async (pc: RTCPeerConnection, peerIdHex: string) => {
|
||||||
if (this.mediaType !== "screen") return;
|
if (this.mediaType !== "screen") return;
|
||||||
|
|
||||||
@@ -393,8 +445,13 @@ export class PeerManagerService {
|
|||||||
params.encodings[0].priority = "high";
|
params.encodings[0].priority = "high";
|
||||||
|
|
||||||
// Maintain resolution over framerate during congestion
|
// Maintain resolution over framerate during congestion
|
||||||
// @ts-expect-error - Svelte 5 rune or non-standard property
|
if ("degradationPreference" in sender) {
|
||||||
sender.degradationPreference = "maintain-resolution";
|
try {
|
||||||
|
(sender as any).degradationPreference = "maintain-resolution";
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[WebRTC][screen] Failed to set degradationPreference", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await sender.setParameters(params);
|
await sender.setParameters(params);
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -23,15 +23,18 @@ export class ScreenSharingWebRTCService {
|
|||||||
localScreenStream = $state<MediaStream | null>(null);
|
localScreenStream = $state<MediaStream | null>(null);
|
||||||
|
|
||||||
peerManager: PeerManagerService;
|
peerManager: PeerManagerService;
|
||||||
|
localMedia: any;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
identity: Identity | null,
|
identity: Identity | null,
|
||||||
connectedChannelId: bigint | undefined,
|
connectedChannelId: bigint | undefined,
|
||||||
localScreenStream: MediaStream | null,
|
localScreenStream: MediaStream | null,
|
||||||
|
localMedia: any,
|
||||||
) {
|
) {
|
||||||
this.identity = identity;
|
this.identity = identity;
|
||||||
this.connectedChannelId = connectedChannelId;
|
this.connectedChannelId = connectedChannelId;
|
||||||
this.localScreenStream = localScreenStream;
|
this.localScreenStream = localScreenStream;
|
||||||
|
this.localMedia = localMedia;
|
||||||
|
|
||||||
const [usStore] = useTable(tables.visible_user_states);
|
const [usStore] = useTable(tables.visible_user_states);
|
||||||
usStore.subscribe((v) => (this.userStates = v));
|
usStore.subscribe((v) => (this.userStates = v));
|
||||||
@@ -56,8 +59,7 @@ export class ScreenSharingWebRTCService {
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const videoTrack = this.localScreenStream?.getVideoTracks()[0] || null;
|
const videoTrack = this.localScreenStream?.getVideoTracks()[0] || null;
|
||||||
const audioTrack = this.localScreenStream?.getAudioTracks()[0] || null;
|
const audioTrack = this.localScreenStream?.getAudioTracks()[0] || null;
|
||||||
// Accessing peers and peerStatuses to trigger effect on changes
|
|
||||||
void this.peerManager.peerStatuses;
|
|
||||||
this.peerManager.peers.forEach(async (peer, peerIdHex) => {
|
this.peerManager.peers.forEach(async (peer, peerIdHex) => {
|
||||||
const transceivers = peer.pc.getTransceivers();
|
const transceivers = peer.pc.getTransceivers();
|
||||||
let changed = false;
|
let changed = false;
|
||||||
@@ -89,14 +91,14 @@ export class ScreenSharingWebRTCService {
|
|||||||
const currentIdentity = this.identity;
|
const currentIdentity = this.identity;
|
||||||
|
|
||||||
if (!currentChannelId || !currentIdentity) {
|
if (!currentChannelId || !currentIdentity) {
|
||||||
console.log(`[WebRTC][screen] Cleaning up screen state (Channel: ${currentChannelId}, Identity: ${currentIdentity?.toHexString().substring(0,8)})`);
|
if (lastChannelId) {
|
||||||
this.peerManager.peers.forEach((_, id) =>
|
console.log(`[WebRTC][screen] Cleaning up screen state (Channel: ${lastChannelId}, Identity: ${currentIdentity?.toHexString().substring(0,8)})`);
|
||||||
this.peerManager.closePeer(id),
|
this.peerManager.peers.forEach((_, id) => this.peerManager.closePeer(id));
|
||||||
);
|
this.processedSignals.clear();
|
||||||
this.processedSignals.clear();
|
this.makingOffer.clear();
|
||||||
this.makingOffer.clear();
|
this.ignoreOffer.clear();
|
||||||
this.ignoreOffer.clear();
|
this.signalingQueue.clear();
|
||||||
this.signalingQueue.clear();
|
}
|
||||||
lastChannelId = undefined;
|
lastChannelId = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -169,10 +171,6 @@ export class ScreenSharingWebRTCService {
|
|||||||
s.mediaType.tag === "Screen"
|
s.mediaType.tag === "Screen"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mySignals.length > 0) {
|
|
||||||
console.log(`[WebRTC][screen] Found ${mySignals.length} signals for channel ${currentChannelId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const signal of mySignals) {
|
for (const signal of mySignals) {
|
||||||
if (this.processedSignals.has(signal.id)) continue;
|
if (this.processedSignals.has(signal.id)) continue;
|
||||||
this.processedSignals.add(signal.id);
|
this.processedSignals.add(signal.id);
|
||||||
@@ -189,6 +187,7 @@ export class ScreenSharingWebRTCService {
|
|||||||
this.handleIceCandidate(signal);
|
this.handleIceCandidate(signal);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -244,6 +243,8 @@ export class ScreenSharingWebRTCService {
|
|||||||
data: JSON.stringify(pc.localDescription),
|
data: JSON.stringify(pc.localDescription),
|
||||||
channelId: this.connectedChannelId,
|
channelId: this.connectedChannelId,
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[WebRTC][screen] Error during negotiation for ${peerIdHex}`, e);
|
||||||
} finally {
|
} finally {
|
||||||
this.makingOffer.set(peerIdHex, false);
|
this.makingOffer.set(peerIdHex, false);
|
||||||
}
|
}
|
||||||
@@ -257,7 +258,7 @@ export class ScreenSharingWebRTCService {
|
|||||||
receiver: Identity.fromString(peerIdHex),
|
receiver: Identity.fromString(peerIdHex),
|
||||||
signalKind: { tag: "IceCandidate" },
|
signalKind: { tag: "IceCandidate" },
|
||||||
mediaType: { tag: "Screen" },
|
mediaType: { tag: "Screen" },
|
||||||
data: JSON.stringify(candidate),
|
data: JSON.stringify(candidate.toJSON()),
|
||||||
channelId: this.connectedChannelId,
|
channelId: this.connectedChannelId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -270,17 +271,28 @@ export class ScreenSharingWebRTCService {
|
|||||||
const pc = this.peerManager.createPeerConnection(peerIdHex);
|
const pc = this.peerManager.createPeerConnection(peerIdHex);
|
||||||
if (!pc) return;
|
if (!pc) return;
|
||||||
try {
|
try {
|
||||||
|
const description = JSON.parse(signal.data);
|
||||||
const isPolite = this.identity!.toHexString() < peerIdHex;
|
const isPolite = this.identity!.toHexString() < peerIdHex;
|
||||||
const offerCollision =
|
const offerCollision =
|
||||||
pc.signalingState !== "stable" || !!this.makingOffer.get(peerIdHex);
|
description.type === "offer" &&
|
||||||
|
(pc.signalingState !== "stable" || !!this.makingOffer.get(peerIdHex));
|
||||||
|
|
||||||
const ignoreOffer = !isPolite && offerCollision;
|
const ignoreOffer = !isPolite && offerCollision;
|
||||||
this.ignoreOffer.set(peerIdHex, ignoreOffer);
|
this.ignoreOffer.set(peerIdHex, ignoreOffer);
|
||||||
if (ignoreOffer) return;
|
if (ignoreOffer) {
|
||||||
|
console.log(`[WebRTC][screen] Ignoring offer collision from ${peerIdHex.substring(0,8)} (Impolite)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (offerCollision) await pc.setLocalDescription({ type: "rollback" });
|
if (offerCollision) {
|
||||||
await pc.setRemoteDescription(
|
console.log(`[WebRTC][screen] Rolling back for offer from ${peerIdHex.substring(0,8)} (Polite)`);
|
||||||
new RTCSessionDescription(JSON.parse(signal.data)),
|
await Promise.all([
|
||||||
);
|
pc.setLocalDescription({ type: "rollback" }),
|
||||||
|
pc.setRemoteDescription(description)
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
await pc.setRemoteDescription(description);
|
||||||
|
}
|
||||||
|
|
||||||
// After setting remote offer, the transceivers exist.
|
// After setting remote offer, the transceivers exist.
|
||||||
// We should attach our local tracks if we have them.
|
// We should attach our local tracks if we have them.
|
||||||
@@ -288,16 +300,28 @@ export class ScreenSharingWebRTCService {
|
|||||||
const videoTrack = this.localScreenStream?.getVideoTracks()[0];
|
const videoTrack = this.localScreenStream?.getVideoTracks()[0];
|
||||||
const audioTrack = this.localScreenStream?.getAudioTracks()[0];
|
const audioTrack = this.localScreenStream?.getAudioTracks()[0];
|
||||||
|
|
||||||
const videoTransceiver = transceivers.find((t: RTCRtpTransceiver) => t.receiver.track.kind === 'video');
|
const videoTransceiver = transceivers.find((t: RTCRtpTransceiver) =>
|
||||||
const audioTransceiver = transceivers.find((t: RTCRtpTransceiver) => t.receiver.track.kind === 'audio');
|
t.receiver.track.kind === 'video' || t.sender.track?.kind === 'video'
|
||||||
|
);
|
||||||
|
const audioTransceiver = transceivers.find((t: RTCRtpTransceiver) =>
|
||||||
|
t.receiver.track.kind === 'audio' || t.sender.track?.kind === 'audio'
|
||||||
|
);
|
||||||
|
|
||||||
if (videoTransceiver && videoTrack) {
|
if (videoTransceiver) {
|
||||||
await videoTransceiver.sender.replaceTrack(videoTrack);
|
if (videoTrack) {
|
||||||
videoTransceiver.direction = 'sendrecv';
|
await videoTransceiver.sender.replaceTrack(videoTrack);
|
||||||
|
}
|
||||||
|
if (videoTransceiver.direction === 'recvonly') {
|
||||||
|
videoTransceiver.direction = 'sendrecv';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (audioTransceiver && audioTrack) {
|
if (audioTransceiver) {
|
||||||
await audioTransceiver.sender.replaceTrack(audioTrack);
|
if (audioTrack) {
|
||||||
audioTransceiver.direction = 'sendrecv';
|
await audioTransceiver.sender.replaceTrack(audioTrack);
|
||||||
|
}
|
||||||
|
if (audioTransceiver.direction === 'recvonly') {
|
||||||
|
audioTransceiver.direction = 'sendrecv';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const answer = await pc.createAnswer();
|
const answer = await pc.createAnswer();
|
||||||
@@ -307,7 +331,7 @@ export class ScreenSharingWebRTCService {
|
|||||||
receiver: signal.sender,
|
receiver: signal.sender,
|
||||||
signalKind: { tag: "Answer" },
|
signalKind: { tag: "Answer" },
|
||||||
mediaType: { tag: "Screen" },
|
mediaType: { tag: "Screen" },
|
||||||
data: JSON.stringify(answer),
|
data: JSON.stringify(pc.localDescription),
|
||||||
channelId: this.connectedChannelId!,
|
channelId: this.connectedChannelId!,
|
||||||
});
|
});
|
||||||
await this.drainCandidateQueue(peerIdHex, pc);
|
await this.drainCandidateQueue(peerIdHex, pc);
|
||||||
@@ -327,9 +351,8 @@ export class ScreenSharingWebRTCService {
|
|||||||
const peer = this.peerManager.getPeer(peerIdHex);
|
const peer = this.peerManager.getPeer(peerIdHex);
|
||||||
if (!peer) return;
|
if (!peer) return;
|
||||||
try {
|
try {
|
||||||
await peer.pc.setRemoteDescription(
|
const description = JSON.parse(signal.data);
|
||||||
new RTCSessionDescription(JSON.parse(signal.data)),
|
await peer.pc.setRemoteDescription(description);
|
||||||
);
|
|
||||||
await this.drainCandidateQueue(peerIdHex, peer.pc);
|
await this.drainCandidateQueue(peerIdHex, peer.pc);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -342,24 +365,24 @@ export class ScreenSharingWebRTCService {
|
|||||||
|
|
||||||
handleIceCandidate(signal: Types.WebRtcSignal) {
|
handleIceCandidate(signal: Types.WebRtcSignal) {
|
||||||
const peerIdHex = signal.sender.toHexString();
|
const peerIdHex = signal.sender.toHexString();
|
||||||
console.log(`[WebRTC][screen] Received ICE candidate from ${peerIdHex.substring(0,8)}`);
|
|
||||||
this.enqueueSignalingTask(peerIdHex, async () => {
|
this.enqueueSignalingTask(peerIdHex, async () => {
|
||||||
const pc = this.peerManager.createPeerConnection(peerIdHex);
|
const pc = this.peerManager.getPeer(peerIdHex)?.pc;
|
||||||
if (!pc) return;
|
if (!pc) return;
|
||||||
try {
|
try {
|
||||||
const candidate = JSON.parse(signal.data);
|
const candidate = JSON.parse(signal.data);
|
||||||
if (pc.remoteDescription) {
|
if (candidate && candidate.candidate) {
|
||||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
if (pc.remoteDescription) {
|
||||||
} else if (!this.ignoreOffer.get(peerIdHex)) {
|
await pc.addIceCandidate(candidate);
|
||||||
const queue = this.candidateQueue.get(peerIdHex) || [];
|
} else {
|
||||||
queue.push(candidate);
|
const queue = this.candidateQueue.get(peerIdHex) || [];
|
||||||
this.candidateQueue.set(peerIdHex, queue);
|
queue.push(candidate);
|
||||||
|
this.candidateQueue.set(peerIdHex, queue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
if (!this.ignoreOffer.get(peerIdHex)) {
|
||||||
`[WebRTC][screen] Failed to handle ICE from ${peerIdHex}`,
|
console.warn(`[WebRTC][screen] Failed to handle ICE from ${peerIdHex}`, e);
|
||||||
e,
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,12 +77,14 @@ export class WebRTCService {
|
|||||||
connectedChannelId,
|
connectedChannelId,
|
||||||
this.localMedia.localStream,
|
this.localMedia.localStream,
|
||||||
this.localMedia.isDeafened,
|
this.localMedia.isDeafened,
|
||||||
|
this.localMedia,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.screen = new ScreenSharingWebRTCService(
|
this.screen = new ScreenSharingWebRTCService(
|
||||||
identity,
|
identity,
|
||||||
connectedChannelId,
|
connectedChannelId,
|
||||||
this.localMedia.localScreenStream,
|
this.localMedia.localScreenStream,
|
||||||
|
this.localMedia,
|
||||||
);
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// src/main.ts
|
// src/main.ts
|
||||||
|
import "webrtc-adapter";
|
||||||
import { mount } from "svelte";
|
import { mount } from "svelte";
|
||||||
import "@fortawesome/fontawesome-free/css/all.min.css";
|
import "@fortawesome/fontawesome-free/css/all.min.css";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|||||||
Reference in New Issue
Block a user