feat(sim): use SFU handshake endpoints in single_server_sfu mode
This commit is contained in:
@@ -138,9 +138,20 @@ const API = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ops: {
|
ops: {
|
||||||
|
ready: () => API.request('/ops/ready'),
|
||||||
listRecordings: () => API.request('/recordings/me/list'),
|
listRecordings: () => API.request('/recordings/me/list'),
|
||||||
getRecordingDownloadUrl: (recordingId) => API.request(`/recordings/${recordingId}/download-url`),
|
getRecordingDownloadUrl: (recordingId) => API.request(`/recordings/${recordingId}/download-url`),
|
||||||
listNotifications: () => API.request('/push-notifications/me'),
|
listNotifications: () => API.request('/push-notifications/me'),
|
||||||
|
},
|
||||||
|
|
||||||
|
sfu: {
|
||||||
|
getSession: (id) => API.request(`/streams/${id}/sfu/session`),
|
||||||
|
createPublishTransport: (id) => API.request(`/streams/${id}/sfu/publish-transport`, { method: 'POST', body: JSON.stringify({ role: 'camera' }) }),
|
||||||
|
connectPublishTransport: (id, payload) => API.request(`/streams/${id}/sfu/publish-transport/connect`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
createSubscribeTransport: (id) => API.request(`/streams/${id}/sfu/subscribe-transport`, { method: 'POST', body: JSON.stringify({ role: 'viewer' }) }),
|
||||||
|
connectSubscribeTransport: (id, payload) => API.request(`/streams/${id}/sfu/subscribe-transport/connect`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
produce: (id, payload) => API.request(`/streams/${id}/sfu/produce`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
consume: (id, payload) => API.request(`/streams/${id}/sfu/consume`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,10 +177,26 @@ let webrtcConnected = false;
|
|||||||
let hasWebrtcEverConnected = false;
|
let hasWebrtcEverConnected = false;
|
||||||
let lastPeerConnectionState = null;
|
let lastPeerConnectionState = null;
|
||||||
let pendingRemoteCandidates = [];
|
let pendingRemoteCandidates = [];
|
||||||
|
let mediaMode = 'legacy';
|
||||||
const rtcConfig = {
|
const rtcConfig = {
|
||||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSfuMode = () => mediaMode === 'single_server_sfu';
|
||||||
|
|
||||||
|
const detectMediaMode = async () => {
|
||||||
|
try {
|
||||||
|
const ready = await API.ops.ready();
|
||||||
|
const mode = ready?.checks?.mediaMode;
|
||||||
|
if (mode === 'single_server_sfu' || mode === 'legacy') {
|
||||||
|
mediaMode = mode;
|
||||||
|
addActivity('Media', `Mode: ${mediaMode}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
mediaMode = 'legacy';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
// Load local storage
|
// Load local storage
|
||||||
const saved = localStorage.getItem('mobileSimDevice');
|
const saved = localStorage.getItem('mobileSimDevice');
|
||||||
@@ -184,6 +211,7 @@ const init = async () => {
|
|||||||
const session = await API.auth.getSession();
|
const session = await API.auth.getSession();
|
||||||
if (session && session.session) {
|
if (session && session.session) {
|
||||||
store.update({ session });
|
store.update({ session });
|
||||||
|
await detectMediaMode();
|
||||||
if (store.get().deviceToken) {
|
if (store.get().deviceToken) {
|
||||||
// If we have a token, skip onboarding
|
// If we have a token, skip onboarding
|
||||||
navigateBasedOnRole();
|
navigateBasedOnRole();
|
||||||
@@ -660,6 +688,82 @@ const startOfferToClient = async (streamSessionId, requesterDeviceId) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startSfuPublishHandshake = async (streamSessionId) => {
|
||||||
|
const ready = await startCameraPreview();
|
||||||
|
if (!ready || !localCameraStream) {
|
||||||
|
throw new Error('Camera stream unavailable for SFU publish');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishTransport = await API.sfu.createPublishTransport(streamSessionId);
|
||||||
|
const transportId = publishTransport?.transport?.transportId;
|
||||||
|
if (!transportId) {
|
||||||
|
throw new Error('Missing SFU publish transport id');
|
||||||
|
}
|
||||||
|
|
||||||
|
await API.sfu.connectPublishTransport(streamSessionId, {
|
||||||
|
transportId,
|
||||||
|
dtlsParameters: { role: 'auto' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const track = localCameraStream.getVideoTracks()[0];
|
||||||
|
await API.sfu.produce(streamSessionId, {
|
||||||
|
transportId,
|
||||||
|
kind: 'video',
|
||||||
|
rtpParameters: {
|
||||||
|
codec: 'VP8',
|
||||||
|
source: 'mobile-sim-camera',
|
||||||
|
trackSettings: track?.getSettings?.() ?? {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
addActivity('SFU', 'Publish handshake completed');
|
||||||
|
};
|
||||||
|
|
||||||
|
const setClientSfuConnectedState = () => {
|
||||||
|
if (remoteStreamWaitTimer) {
|
||||||
|
clearTimeout(remoteStreamWaitTimer);
|
||||||
|
remoteStreamWaitTimer = null;
|
||||||
|
}
|
||||||
|
setClientStreamPlaceholderText('Connected via SFU (simulated media path)');
|
||||||
|
setClientStreamMode('connecting');
|
||||||
|
};
|
||||||
|
|
||||||
|
const startSfuSubscribeHandshake = async (streamSessionId) => {
|
||||||
|
const subscribeTransport = await API.sfu.createSubscribeTransport(streamSessionId);
|
||||||
|
const transportId = subscribeTransport?.transport?.transportId;
|
||||||
|
if (!transportId) {
|
||||||
|
throw new Error('Missing SFU subscribe transport id');
|
||||||
|
}
|
||||||
|
|
||||||
|
await API.sfu.connectSubscribeTransport(streamSessionId, {
|
||||||
|
transportId,
|
||||||
|
dtlsParameters: { role: 'auto' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let consumeResult = null;
|
||||||
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
|
try {
|
||||||
|
consumeResult = await API.sfu.consume(streamSessionId, {
|
||||||
|
transportId,
|
||||||
|
rtpCapabilities: { browser: 'mobile-sim' },
|
||||||
|
});
|
||||||
|
if (consumeResult?.consumer?.consumerId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Camera may still be establishing producer; retry briefly.
|
||||||
|
}
|
||||||
|
await sleep(350);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!consumeResult?.consumer?.consumerId) {
|
||||||
|
throw new Error('SFU consumer was not created');
|
||||||
|
}
|
||||||
|
|
||||||
|
setClientSfuConnectedState();
|
||||||
|
addActivity('SFU', `Subscribed to producer ${consumeResult.consumer.producerId}`);
|
||||||
|
};
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
const finalizeRecordingForStream = async (streamSessionId, captureResult) => {
|
const finalizeRecordingForStream = async (streamSessionId, captureResult) => {
|
||||||
@@ -738,6 +842,7 @@ const connectSocket = () => {
|
|||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
store.update({ socketConnected: true });
|
store.update({ socketConnected: true });
|
||||||
addActivity('System', 'Connected to realtime server');
|
addActivity('System', 'Connected to realtime server');
|
||||||
|
void detectMediaMode();
|
||||||
if (store.get().device?.role === 'camera') {
|
if (store.get().device?.role === 'camera') {
|
||||||
startCameraPreview();
|
startCameraPreview();
|
||||||
}
|
}
|
||||||
@@ -765,7 +870,9 @@ const connectSocket = () => {
|
|||||||
await API.streams.accept(streamId);
|
await API.streams.accept(streamId);
|
||||||
await API.streams.getPublishCreds(streamId);
|
await API.streams.getPublishCreds(streamId);
|
||||||
await startLocalRecording();
|
await startLocalRecording();
|
||||||
if (payload.sourceDeviceId) {
|
if (isSfuMode()) {
|
||||||
|
await startSfuPublishHandshake(streamId);
|
||||||
|
} else if (payload.sourceDeviceId) {
|
||||||
await startOfferToClient(streamId, payload.sourceDeviceId);
|
await startOfferToClient(streamId, payload.sourceDeviceId);
|
||||||
frameRelayStartTimer = setTimeout(() => {
|
frameRelayStartTimer = setTimeout(() => {
|
||||||
if (!webrtcConnected && !hasWebrtcEverConnected) {
|
if (!webrtcConnected && !hasWebrtcEverConnected) {
|
||||||
@@ -780,7 +887,7 @@ const connectSocket = () => {
|
|||||||
await API.streams.end(streamId);
|
await API.streams.end(streamId);
|
||||||
await finalizeRecordingForStream(streamId, captureResult);
|
await finalizeRecordingForStream(streamId, captureResult);
|
||||||
stopFrameRelay();
|
stopFrameRelay();
|
||||||
if (socket && payload.sourceDeviceId) {
|
if (!isSfuMode() && socket && payload.sourceDeviceId) {
|
||||||
socket.emit('webrtc:signal', {
|
socket.emit('webrtc:signal', {
|
||||||
toDeviceId: payload.sourceDeviceId,
|
toDeviceId: payload.sourceDeviceId,
|
||||||
streamSessionId: streamId,
|
streamSessionId: streamId,
|
||||||
@@ -816,19 +923,25 @@ const connectSocket = () => {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await API.streams.getSubscribeCreds(payload.streamSessionId);
|
await API.streams.getSubscribeCreds(payload.streamSessionId);
|
||||||
|
if (isSfuMode()) {
|
||||||
|
await startSfuSubscribeHandshake(payload.streamSessionId);
|
||||||
|
}
|
||||||
Toast.show('Connected to Stream', 'success');
|
Toast.show('Connected to Stream', 'success');
|
||||||
|
if (!isSfuMode()) {
|
||||||
remoteStreamWaitTimer = setTimeout(() => {
|
remoteStreamWaitTimer = setTimeout(() => {
|
||||||
if (!remoteClientStream) {
|
if (!remoteClientStream) {
|
||||||
Toast.show('Stream connected but no video received', 'error');
|
Toast.show('Stream connected but no video received', 'error');
|
||||||
addActivity('Stream', 'No remote video track received');
|
addActivity('Stream', 'No remote video track received');
|
||||||
}
|
}
|
||||||
}, 6000);
|
}, 6000);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.show('Stream connect failed', 'error');
|
Toast.show('Stream connect failed', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('stream:frame', (payload) => {
|
socket.on('stream:frame', (payload) => {
|
||||||
|
if (isSfuMode()) return;
|
||||||
if (webrtcConnected) return;
|
if (webrtcConnected) return;
|
||||||
if (!payload?.frame) return;
|
if (!payload?.frame) return;
|
||||||
if (remoteStreamWaitTimer) {
|
if (remoteStreamWaitTimer) {
|
||||||
@@ -854,6 +967,7 @@ const connectSocket = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('webrtc:signal', async (payload) => {
|
socket.on('webrtc:signal', async (payload) => {
|
||||||
|
if (isSfuMode()) return;
|
||||||
const device = store.get().device;
|
const device = store.get().device;
|
||||||
if (!device || !payload?.streamSessionId || !payload?.signalType || !payload?.fromDeviceId) return;
|
if (!device || !payload?.streamSessionId || !payload?.signalType || !payload?.fromDeviceId) return;
|
||||||
|
|
||||||
@@ -986,6 +1100,7 @@ const Actions = {
|
|||||||
await API.auth.signIn({ email, password });
|
await API.auth.signIn({ email, password });
|
||||||
const session = await API.auth.getSession();
|
const session = await API.auth.getSession();
|
||||||
store.update({ session });
|
store.update({ session });
|
||||||
|
await detectMediaMode();
|
||||||
Toast.show(`Welcome, ${session.user.name}`, 'success');
|
Toast.show(`Welcome, ${session.user.name}`, 'success');
|
||||||
|
|
||||||
// Proceed
|
// Proceed
|
||||||
@@ -1038,6 +1153,7 @@ const Actions = {
|
|||||||
|
|
||||||
store.update({ device: res.device, deviceToken: res.deviceToken });
|
store.update({ device: res.device, deviceToken: res.deviceToken });
|
||||||
localStorage.setItem('mobileSimDevice', JSON.stringify({ device: res.device, deviceToken: res.deviceToken }));
|
localStorage.setItem('mobileSimDevice', JSON.stringify({ device: res.device, deviceToken: res.deviceToken }));
|
||||||
|
await detectMediaMode();
|
||||||
|
|
||||||
Toast.show('Device Registered', 'success');
|
Toast.show('Device Registered', 'success');
|
||||||
navigateBasedOnRole();
|
navigateBasedOnRole();
|
||||||
|
|||||||
Reference in New Issue
Block a user