From 890593d92d8b59216aad483e44d781c4d16e2e8c Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 7 Apr 2026 15:59:39 +0200 Subject: [PATCH 1/3] feat(client): support server-side pinning on participant join Apply a server-side pin when a participant joins with isPinned flag set by the SFU. Updated SFU protobuf types to include the new isPinned field on ParticipantJoined, SendMetrics RPC, and negotiationId on SendAnswerRequest. --- .../src/events/__tests__/participant.test.ts | 41 ++++++ packages/client/src/events/participant.ts | 1 + .../client/src/gen/video/sfu/event/events.ts | 5 + .../src/gen/video/sfu/signal_rpc/signal.ts | 137 ++++++++++++++++-- 4 files changed, 169 insertions(+), 15 deletions(-) diff --git a/packages/client/src/events/__tests__/participant.test.ts b/packages/client/src/events/__tests__/participant.test.ts index 59340b9d7c..7b4d5a8c70 100644 --- a/packages/client/src/events/__tests__/participant.test.ts +++ b/packages/client/src/events/__tests__/participant.test.ts @@ -74,6 +74,47 @@ describe('Participant events', () => { expect(state.participants).toEqual([]); }); + + it('sets a server-side pin when isPinned is true', () => { + const state = new CallState(); + state.setSortParticipantsBy(noopComparator()); + + const onParticipantJoined = watchParticipantJoined(state); + const now = Date.now(); + + onParticipantJoined({ + // @ts-expect-error incomplete data + participant: { + userId: 'user-id', + sessionId: 'session-id', + }, + isPinned: true, + }); + + const participant = state.findParticipantBySessionId('session-id'); + expect(participant?.pin).toBeDefined(); + expect(participant?.pin?.isLocalPin).toBe(false); + expect(participant?.pin?.pinnedAt).toBeGreaterThanOrEqual(now); + }); + + it('does not set a pin when isPinned is false', () => { + const state = new CallState(); + state.setSortParticipantsBy(noopComparator()); + + const onParticipantJoined = watchParticipantJoined(state); + + onParticipantJoined({ + // @ts-expect-error incomplete data + participant: { + userId: 'user-id', + sessionId: 'session-id', + }, + isPinned: false, + }); + + const participant = state.findParticipantBySessionId('session-id'); + expect(participant?.pin).toBeUndefined(); + }); }); describe('orphaned tracks reconciliation', () => { diff --git a/packages/client/src/events/participant.ts b/packages/client/src/events/participant.ts index a9725deca3..b927446fec 100644 --- a/packages/client/src/events/participant.ts +++ b/packages/client/src/events/participant.ts @@ -38,6 +38,7 @@ export const watchParticipantJoined = (state: CallState) => { StreamVideoParticipantPatch | undefined, Partial >(participant, orphanedTracks, { + ...(e.isPinned && { pin: { isLocalPin: false, pinnedAt: Date.now() } }), viewportVisibilityState: { videoTrack: VisibilityState.UNKNOWN, screenShareTrack: VisibilityState.UNKNOWN, diff --git a/packages/client/src/gen/video/sfu/event/events.ts b/packages/client/src/gen/video/sfu/event/events.ts index 12d102d5cd..4e45616d88 100644 --- a/packages/client/src/gen/video/sfu/event/events.ts +++ b/packages/client/src/gen/video/sfu/event/events.ts @@ -625,6 +625,10 @@ export interface ParticipantJoined { * @generated from protobuf field: stream.video.sfu.models.Participant participant = 2; */ participant?: Participant; + /** + * @generated from protobuf field: bool is_pinned = 3; + */ + isPinned: boolean; } /** * ParticipantJoined is fired when a user leaves a call @@ -1557,6 +1561,7 @@ class ParticipantJoined$Type extends MessageType { super('stream.video.sfu.event.ParticipantJoined', [ { no: 1, name: 'call_cid', kind: 'scalar', T: 9 /*ScalarType.STRING*/ }, { no: 2, name: 'participant', kind: 'message', T: () => Participant }, + { no: 3, name: 'is_pinned', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ }, ]); } } diff --git a/packages/client/src/gen/video/sfu/signal_rpc/signal.ts b/packages/client/src/gen/video/sfu/signal_rpc/signal.ts index 84c11a0402..22b4889111 100644 --- a/packages/client/src/gen/video/sfu/signal_rpc/signal.ts +++ b/packages/client/src/gen/video/sfu/signal_rpc/signal.ts @@ -1,23 +1,25 @@ + // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size // @generated from protobuf file "video/sfu/signal_rpc/signal.proto" (package "stream.video.sfu.signal", syntax proto3) // tslint:disable -import { - AndroidState, - AppleState, - Error, - ICETrickle, - InputDevices, - PeerType, - PerformanceStats, - RTMPIngress, - TrackInfo, - TrackType, - VideoDimension, - WebsocketReconnectStrategy, -} from '../models/models'; +import { ICETrickle } from '../models/models'; import { ServiceType } from '@protobuf-ts/runtime-rpc'; import { MessageType } from '@protobuf-ts/runtime'; - +import { TrackInfo } from '../models/models'; +import { VideoDimension } from '../models/models'; +import { TrackType } from '../models/models'; +import { PeerType } from '../models/models'; +import { PerformanceStats } from '../models/models'; +import { RTMPIngress } from '../models/models'; +import { AppleState } from '../models/models'; +import { AndroidState } from '../models/models'; +import { InputDevices } from '../models/models'; +import { RemoteOutboundRtp } from '../models/models'; +import { RemoteInboundRtp } from '../models/models'; +import { OutboundRtp } from '../models/models'; +import { InboundRtp } from '../models/models'; +import { WebsocketReconnectStrategy } from '../models/models'; +import { Error } from '../models/models'; /** * @generated from protobuf message stream.video.sfu.signal.StartNoiseCancellationRequest */ @@ -93,6 +95,39 @@ export interface Telemetry { oneofKind: undefined; }; } +/** + * @generated from protobuf message stream.video.sfu.signal.SendMetricsRequest + */ +export interface SendMetricsRequest { + /** + * @generated from protobuf field: string session_id = 1; + */ + sessionId: string; + /** + * @generated from protobuf field: string unified_session_id = 2; + */ + unifiedSessionId: string; + /** + * @generated from protobuf field: repeated stream.video.sfu.models.InboundRtp inbounds = 3; + */ + inbounds: InboundRtp[]; + /** + * @generated from protobuf field: repeated stream.video.sfu.models.OutboundRtp outbounds = 4; + */ + outbounds: OutboundRtp[]; + /** + * @generated from protobuf field: repeated stream.video.sfu.models.RemoteInboundRtp remote_inbounds = 5; + */ + remoteInbounds: RemoteInboundRtp[]; + /** + * @generated from protobuf field: repeated stream.video.sfu.models.RemoteOutboundRtp remote_outbounds = 6; + */ + remoteOutbounds: RemoteOutboundRtp[]; +} +/** + * @generated from protobuf message stream.video.sfu.signal.SendMetricsResponse + */ +export interface SendMetricsResponse {} /** * @generated from protobuf message stream.video.sfu.signal.SendStatsRequest */ @@ -336,6 +371,10 @@ export interface SendAnswerRequest { * @generated from protobuf field: string session_id = 3; */ sessionId: string; + /** + * @generated from protobuf field: uint32 negotiation_id = 4; + */ + negotiationId: number; } /** * @generated from protobuf message stream.video.sfu.signal.SendAnswerResponse @@ -502,6 +541,62 @@ class Telemetry$Type extends MessageType { */ export const Telemetry = new Telemetry$Type(); // @generated message type with reflection information, may provide speed optimized methods +class SendMetricsRequest$Type extends MessageType { + constructor() { + super('stream.video.sfu.signal.SendMetricsRequest', [ + { no: 1, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ }, + { + no: 2, + name: 'unified_session_id', + kind: 'scalar', + T: 9 /*ScalarType.STRING*/, + }, + { + no: 3, + name: 'inbounds', + kind: 'message', + repeat: 2 /*RepeatType.UNPACKED*/, + T: () => InboundRtp, + }, + { + no: 4, + name: 'outbounds', + kind: 'message', + repeat: 2 /*RepeatType.UNPACKED*/, + T: () => OutboundRtp, + }, + { + no: 5, + name: 'remote_inbounds', + kind: 'message', + repeat: 2 /*RepeatType.UNPACKED*/, + T: () => RemoteInboundRtp, + }, + { + no: 6, + name: 'remote_outbounds', + kind: 'message', + repeat: 2 /*RepeatType.UNPACKED*/, + T: () => RemoteOutboundRtp, + }, + ]); + } +} +/** + * @generated MessageType for protobuf message stream.video.sfu.signal.SendMetricsRequest + */ +export const SendMetricsRequest = new SendMetricsRequest$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class SendMetricsResponse$Type extends MessageType { + constructor() { + super('stream.video.sfu.signal.SendMetricsResponse', []); + } +} +/** + * @generated MessageType for protobuf message stream.video.sfu.signal.SendMetricsResponse + */ +export const SendMetricsResponse = new SendMetricsResponse$Type(); +// @generated message type with reflection information, may provide speed optimized methods class SendStatsRequest$Type extends MessageType { constructor() { super('stream.video.sfu.signal.SendStatsRequest', [ @@ -776,6 +871,12 @@ class SendAnswerRequest$Type extends MessageType { }, { no: 2, name: 'sdp', kind: 'scalar', T: 9 /*ScalarType.STRING*/ }, { no: 3, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ }, + { + no: 4, + name: 'negotiation_id', + kind: 'scalar', + T: 13 /*ScalarType.UINT32*/, + }, ]); } } @@ -885,6 +986,12 @@ export const SignalServer = new ServiceType( I: SendStatsRequest, O: SendStatsResponse, }, + { + name: 'SendMetrics', + options: {}, + I: SendMetricsRequest, + O: SendMetricsResponse, + }, { name: 'StartNoiseCancellation', options: {}, From 375a13327adb83eb414a605498f416fea65ee0ac Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 7 Apr 2026 16:02:28 +0200 Subject: [PATCH 2/3] chore: revert wrongly committed changes --- .../src/gen/video/sfu/signal_rpc/signal.ts | 131 ++---------------- 1 file changed, 15 insertions(+), 116 deletions(-) diff --git a/packages/client/src/gen/video/sfu/signal_rpc/signal.ts b/packages/client/src/gen/video/sfu/signal_rpc/signal.ts index 22b4889111..4b7bdb4a21 100644 --- a/packages/client/src/gen/video/sfu/signal_rpc/signal.ts +++ b/packages/client/src/gen/video/sfu/signal_rpc/signal.ts @@ -1,25 +1,23 @@ - // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size // @generated from protobuf file "video/sfu/signal_rpc/signal.proto" (package "stream.video.sfu.signal", syntax proto3) // tslint:disable -import { ICETrickle } from '../models/models'; +import { + AndroidState, + AppleState, + Error, + ICETrickle, + InputDevices, + PeerType, + PerformanceStats, + RTMPIngress, + TrackInfo, + TrackType, + VideoDimension, + WebsocketReconnectStrategy, +} from '../models/models'; import { ServiceType } from '@protobuf-ts/runtime-rpc'; import { MessageType } from '@protobuf-ts/runtime'; -import { TrackInfo } from '../models/models'; -import { VideoDimension } from '../models/models'; -import { TrackType } from '../models/models'; -import { PeerType } from '../models/models'; -import { PerformanceStats } from '../models/models'; -import { RTMPIngress } from '../models/models'; -import { AppleState } from '../models/models'; -import { AndroidState } from '../models/models'; -import { InputDevices } from '../models/models'; -import { RemoteOutboundRtp } from '../models/models'; -import { RemoteInboundRtp } from '../models/models'; -import { OutboundRtp } from '../models/models'; -import { InboundRtp } from '../models/models'; -import { WebsocketReconnectStrategy } from '../models/models'; -import { Error } from '../models/models'; + /** * @generated from protobuf message stream.video.sfu.signal.StartNoiseCancellationRequest */ @@ -95,39 +93,6 @@ export interface Telemetry { oneofKind: undefined; }; } -/** - * @generated from protobuf message stream.video.sfu.signal.SendMetricsRequest - */ -export interface SendMetricsRequest { - /** - * @generated from protobuf field: string session_id = 1; - */ - sessionId: string; - /** - * @generated from protobuf field: string unified_session_id = 2; - */ - unifiedSessionId: string; - /** - * @generated from protobuf field: repeated stream.video.sfu.models.InboundRtp inbounds = 3; - */ - inbounds: InboundRtp[]; - /** - * @generated from protobuf field: repeated stream.video.sfu.models.OutboundRtp outbounds = 4; - */ - outbounds: OutboundRtp[]; - /** - * @generated from protobuf field: repeated stream.video.sfu.models.RemoteInboundRtp remote_inbounds = 5; - */ - remoteInbounds: RemoteInboundRtp[]; - /** - * @generated from protobuf field: repeated stream.video.sfu.models.RemoteOutboundRtp remote_outbounds = 6; - */ - remoteOutbounds: RemoteOutboundRtp[]; -} -/** - * @generated from protobuf message stream.video.sfu.signal.SendMetricsResponse - */ -export interface SendMetricsResponse {} /** * @generated from protobuf message stream.video.sfu.signal.SendStatsRequest */ @@ -371,10 +336,6 @@ export interface SendAnswerRequest { * @generated from protobuf field: string session_id = 3; */ sessionId: string; - /** - * @generated from protobuf field: uint32 negotiation_id = 4; - */ - negotiationId: number; } /** * @generated from protobuf message stream.video.sfu.signal.SendAnswerResponse @@ -541,62 +502,6 @@ class Telemetry$Type extends MessageType { */ export const Telemetry = new Telemetry$Type(); // @generated message type with reflection information, may provide speed optimized methods -class SendMetricsRequest$Type extends MessageType { - constructor() { - super('stream.video.sfu.signal.SendMetricsRequest', [ - { no: 1, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ }, - { - no: 2, - name: 'unified_session_id', - kind: 'scalar', - T: 9 /*ScalarType.STRING*/, - }, - { - no: 3, - name: 'inbounds', - kind: 'message', - repeat: 2 /*RepeatType.UNPACKED*/, - T: () => InboundRtp, - }, - { - no: 4, - name: 'outbounds', - kind: 'message', - repeat: 2 /*RepeatType.UNPACKED*/, - T: () => OutboundRtp, - }, - { - no: 5, - name: 'remote_inbounds', - kind: 'message', - repeat: 2 /*RepeatType.UNPACKED*/, - T: () => RemoteInboundRtp, - }, - { - no: 6, - name: 'remote_outbounds', - kind: 'message', - repeat: 2 /*RepeatType.UNPACKED*/, - T: () => RemoteOutboundRtp, - }, - ]); - } -} -/** - * @generated MessageType for protobuf message stream.video.sfu.signal.SendMetricsRequest - */ -export const SendMetricsRequest = new SendMetricsRequest$Type(); -// @generated message type with reflection information, may provide speed optimized methods -class SendMetricsResponse$Type extends MessageType { - constructor() { - super('stream.video.sfu.signal.SendMetricsResponse', []); - } -} -/** - * @generated MessageType for protobuf message stream.video.sfu.signal.SendMetricsResponse - */ -export const SendMetricsResponse = new SendMetricsResponse$Type(); -// @generated message type with reflection information, may provide speed optimized methods class SendStatsRequest$Type extends MessageType { constructor() { super('stream.video.sfu.signal.SendStatsRequest', [ @@ -986,12 +891,6 @@ export const SignalServer = new ServiceType( I: SendStatsRequest, O: SendStatsResponse, }, - { - name: 'SendMetrics', - options: {}, - I: SendMetricsRequest, - O: SendMetricsResponse, - }, { name: 'StartNoiseCancellation', options: {}, From e85a6b2b678e70a235494ac98dbfceef26b9fd2d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 7 Apr 2026 16:03:08 +0200 Subject: [PATCH 3/3] chore: revert wrongly committed changes --- packages/client/src/gen/video/sfu/signal_rpc/signal.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/client/src/gen/video/sfu/signal_rpc/signal.ts b/packages/client/src/gen/video/sfu/signal_rpc/signal.ts index 4b7bdb4a21..84c11a0402 100644 --- a/packages/client/src/gen/video/sfu/signal_rpc/signal.ts +++ b/packages/client/src/gen/video/sfu/signal_rpc/signal.ts @@ -776,12 +776,6 @@ class SendAnswerRequest$Type extends MessageType { }, { no: 2, name: 'sdp', kind: 'scalar', T: 9 /*ScalarType.STRING*/ }, { no: 3, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ }, - { - no: 4, - name: 'negotiation_id', - kind: 'scalar', - T: 13 /*ScalarType.UINT32*/, - }, ]); } }