feat(streaming): implement frame relay functionality for real-time video streaming and enhance client stream visibility
This commit is contained in:
@@ -151,6 +151,9 @@ let peerConnection = null;
|
|||||||
let peerSessionId = null;
|
let peerSessionId = null;
|
||||||
let peerTargetDeviceId = null;
|
let peerTargetDeviceId = null;
|
||||||
let remoteStreamWaitTimer = null;
|
let remoteStreamWaitTimer = null;
|
||||||
|
let frameRelayTimer = null;
|
||||||
|
let frameCanvas = null;
|
||||||
|
let frameContext = null;
|
||||||
const rtcConfig = {
|
const rtcConfig = {
|
||||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||||
};
|
};
|
||||||
@@ -233,10 +236,14 @@ const stopCameraPreview = () => {
|
|||||||
|
|
||||||
const setClientStreamVisibility = (isVisible) => {
|
const setClientStreamVisibility = (isVisible) => {
|
||||||
const videoEl = $('clientStreamVideo');
|
const videoEl = $('clientStreamVideo');
|
||||||
|
const imageEl = $('clientStreamImage');
|
||||||
const placeholderEl = $('clientStreamPlaceholder');
|
const placeholderEl = $('clientStreamPlaceholder');
|
||||||
if (videoEl) {
|
if (videoEl) {
|
||||||
videoEl.classList.toggle('hidden', !isVisible);
|
videoEl.classList.toggle('hidden', !isVisible);
|
||||||
}
|
}
|
||||||
|
if (imageEl) {
|
||||||
|
imageEl.classList.toggle('hidden', !isVisible);
|
||||||
|
}
|
||||||
if (placeholderEl) {
|
if (placeholderEl) {
|
||||||
placeholderEl.classList.toggle('hidden', isVisible);
|
placeholderEl.classList.toggle('hidden', isVisible);
|
||||||
}
|
}
|
||||||
@@ -248,6 +255,7 @@ const clearClientStream = () => {
|
|||||||
remoteStreamWaitTimer = null;
|
remoteStreamWaitTimer = null;
|
||||||
}
|
}
|
||||||
const videoEl = $('clientStreamVideo');
|
const videoEl = $('clientStreamVideo');
|
||||||
|
const imageEl = $('clientStreamImage');
|
||||||
if (remoteClientStream) {
|
if (remoteClientStream) {
|
||||||
remoteClientStream.getTracks().forEach((track) => track.stop());
|
remoteClientStream.getTracks().forEach((track) => track.stop());
|
||||||
remoteClientStream = null;
|
remoteClientStream = null;
|
||||||
@@ -255,9 +263,54 @@ const clearClientStream = () => {
|
|||||||
if (videoEl) {
|
if (videoEl) {
|
||||||
videoEl.srcObject = null;
|
videoEl.srcObject = null;
|
||||||
}
|
}
|
||||||
|
if (imageEl) {
|
||||||
|
imageEl.src = '';
|
||||||
|
}
|
||||||
setClientStreamVisibility(false);
|
setClientStreamVisibility(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopFrameRelay = () => {
|
||||||
|
if (frameRelayTimer) {
|
||||||
|
clearInterval(frameRelayTimer);
|
||||||
|
frameRelayTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startFrameRelay = async (streamSessionId, toDeviceId) => {
|
||||||
|
if (!socket || !streamSessionId || !toDeviceId) return;
|
||||||
|
|
||||||
|
const ready = await startCameraPreview();
|
||||||
|
if (!ready) {
|
||||||
|
throw new Error('Camera permission is required before streaming');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cameraVideoEl = $('cameraVideo');
|
||||||
|
if (!cameraVideoEl) return;
|
||||||
|
|
||||||
|
stopFrameRelay();
|
||||||
|
frameRelayTimer = setInterval(() => {
|
||||||
|
if (!socket || cameraVideoEl.readyState < 2 || !cameraVideoEl.videoWidth || !cameraVideoEl.videoHeight) return;
|
||||||
|
|
||||||
|
if (!frameCanvas) {
|
||||||
|
frameCanvas = document.createElement('canvas');
|
||||||
|
frameContext = frameCanvas.getContext('2d');
|
||||||
|
}
|
||||||
|
if (!frameCanvas || !frameContext) return;
|
||||||
|
|
||||||
|
frameCanvas.width = cameraVideoEl.videoWidth;
|
||||||
|
frameCanvas.height = cameraVideoEl.videoHeight;
|
||||||
|
frameContext.drawImage(cameraVideoEl, 0, 0, frameCanvas.width, frameCanvas.height);
|
||||||
|
const frame = frameCanvas.toDataURL('image/jpeg', 0.6);
|
||||||
|
|
||||||
|
socket.emit('stream:frame', {
|
||||||
|
toDeviceId,
|
||||||
|
streamSessionId,
|
||||||
|
frame,
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
const teardownPeerConnection = () => {
|
const teardownPeerConnection = () => {
|
||||||
if (peerConnection) {
|
if (peerConnection) {
|
||||||
peerConnection.onicecandidate = null;
|
peerConnection.onicecandidate = null;
|
||||||
@@ -373,6 +426,7 @@ const connectSocket = () => {
|
|||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
store.update({ socketConnected: false });
|
store.update({ socketConnected: false });
|
||||||
|
stopFrameRelay();
|
||||||
teardownPeerConnection();
|
teardownPeerConnection();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -391,11 +445,13 @@ const connectSocket = () => {
|
|||||||
await API.streams.getPublishCreds(streamId);
|
await API.streams.getPublishCreds(streamId);
|
||||||
if (payload.sourceDeviceId) {
|
if (payload.sourceDeviceId) {
|
||||||
await startOfferToClient(streamId, payload.sourceDeviceId);
|
await startOfferToClient(streamId, payload.sourceDeviceId);
|
||||||
|
await startFrameRelay(streamId, payload.sourceDeviceId);
|
||||||
}
|
}
|
||||||
addActivity('Stream', 'Accepted & Published');
|
addActivity('Stream', 'Accepted & Published');
|
||||||
// Auto-stop after 15s for simulation
|
// Auto-stop after 15s for simulation
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await API.streams.end(streamId);
|
await API.streams.end(streamId);
|
||||||
|
stopFrameRelay();
|
||||||
if (socket && payload.sourceDeviceId) {
|
if (socket && payload.sourceDeviceId) {
|
||||||
socket.emit('webrtc:signal', {
|
socket.emit('webrtc:signal', {
|
||||||
toDeviceId: payload.sourceDeviceId,
|
toDeviceId: payload.sourceDeviceId,
|
||||||
@@ -438,6 +494,23 @@ const connectSocket = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('stream:frame', (payload) => {
|
||||||
|
if (!payload?.frame) return;
|
||||||
|
if (remoteStreamWaitTimer) {
|
||||||
|
clearTimeout(remoteStreamWaitTimer);
|
||||||
|
remoteStreamWaitTimer = null;
|
||||||
|
}
|
||||||
|
const imageEl = $('clientStreamImage');
|
||||||
|
if (!imageEl) return;
|
||||||
|
imageEl.src = payload.frame;
|
||||||
|
imageEl.classList.remove('hidden');
|
||||||
|
const videoEl = $('clientStreamVideo');
|
||||||
|
if (videoEl) {
|
||||||
|
videoEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
setClientStreamVisibility(true);
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('webrtc:signal', async (payload) => {
|
socket.on('webrtc:signal', async (payload) => {
|
||||||
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;
|
||||||
@@ -614,6 +687,7 @@ const Actions = {
|
|||||||
await API.auth.signOut();
|
await API.auth.signOut();
|
||||||
store.update({ session: null, screen: 'auth', device: null, deviceToken: null, socketConnected: false });
|
store.update({ session: null, screen: 'auth', device: null, deviceToken: null, socketConnected: false });
|
||||||
if (socket) socket.disconnect();
|
if (socket) socket.disconnect();
|
||||||
|
stopFrameRelay();
|
||||||
teardownPeerConnection();
|
teardownPeerConnection();
|
||||||
stopCameraPreview();
|
stopCameraPreview();
|
||||||
localStorage.removeItem('mobileSimDevice');
|
localStorage.removeItem('mobileSimDevice');
|
||||||
@@ -873,6 +947,7 @@ store.subscribe(render);
|
|||||||
init();
|
init();
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
|
stopFrameRelay();
|
||||||
teardownPeerConnection();
|
teardownPeerConnection();
|
||||||
stopCameraPreview();
|
stopCameraPreview();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const webrtcSignalSchema = z.object({
|
|||||||
data: z.record(z.string(), z.unknown()).nullable().optional(),
|
data: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const streamFrameSchema = z.object({
|
||||||
|
toDeviceId: z.string().uuid(),
|
||||||
|
streamSessionId: z.string().uuid(),
|
||||||
|
frame: z.string().min(32),
|
||||||
|
capturedAt: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const roomForDevice = (deviceId: string): string => `device:${deviceId}`;
|
const roomForDevice = (deviceId: string): string => `device:${deviceId}`;
|
||||||
|
|
||||||
let io: SocketIOServer | null = null;
|
let io: SocketIOServer | null = null;
|
||||||
@@ -233,6 +240,7 @@ export const setupRealtimeGateway = (server: HttpServer): SocketIOServer => {
|
|||||||
io.on('connection', async (socket) => {
|
io.on('connection', async (socket) => {
|
||||||
const auth = socket.data.deviceAuth as { userId: string; deviceId: string; role: 'camera' | 'client' };
|
const auth = socket.data.deviceAuth as { userId: string; deviceId: string; role: 'camera' | 'client' };
|
||||||
const deviceRoom = roomForDevice(auth.deviceId);
|
const deviceRoom = roomForDevice(auth.deviceId);
|
||||||
|
const verifiedRelayTargets = new Set<string>();
|
||||||
|
|
||||||
socket.join(deviceRoom);
|
socket.join(deviceRoom);
|
||||||
await markDevicePresence(auth.deviceId, 'online');
|
await markDevicePresence(auth.deviceId, 'online');
|
||||||
@@ -321,6 +329,38 @@ export const setupRealtimeGateway = (server: HttpServer): SocketIOServer => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('stream:frame', async (input) => {
|
||||||
|
const parsed = streamFrameSchema.safeParse(input);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
socket.emit('error:stream_frame', {
|
||||||
|
message: 'Invalid stream frame payload',
|
||||||
|
errors: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifiedRelayTargets.has(parsed.data.toDeviceId)) {
|
||||||
|
const targetDevice = await db.query.devices.findFirst({
|
||||||
|
where: and(eq(devices.id, parsed.data.toDeviceId), eq(devices.userId, auth.userId)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetDevice) {
|
||||||
|
socket.emit('error:stream_frame', { message: 'Target device not found for this account' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifiedRelayTargets.add(parsed.data.toDeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
io?.to(roomForDevice(parsed.data.toDeviceId)).emit('stream:frame', {
|
||||||
|
fromDeviceId: auth.deviceId,
|
||||||
|
streamSessionId: parsed.data.streamSessionId,
|
||||||
|
frame: parsed.data.frame,
|
||||||
|
capturedAt: parsed.data.capturedAt ?? new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('disconnect', async () => {
|
socket.on('disconnect', async () => {
|
||||||
// Small delay allows fast reconnects to reuse presence without flapping.
|
// Small delay allows fast reconnects to reuse presence without flapping.
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user