Skip to content

Commit f60e3bd

Browse files
authored
[Enhancement]Incoming video pause (#888)
1 parent 64b2dff commit f60e3bd

File tree

42 files changed

+1028
-115
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1028
-115
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
44

55
# Upcoming
66

7-
### 🔄 Changed
7+
### ✅ Added
8+
- `ClientCapabilities` have been added to support remote subscriber track pause. [#888](https://github.com/GetStream/stream-video-swift/pull/888)
89

910
# [1.28.1](https://github.com/GetStream/stream-video-swift/releases/tag/1.28.1)
1011
_July 11, 2025_

DemoApp/Sources/Components/AppEnvironment.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,20 @@ extension AppEnvironment {
620620
}()
621621
}
622622

623+
extension AppEnvironment {
624+
625+
static var clientCapabilities: Set<ClientCapability>?
626+
}
627+
628+
extension ClientCapability: Debuggable {
629+
var title: String {
630+
switch self {
631+
case .subscriberVideoPause:
632+
"Subscriber video pause"
633+
}
634+
}
635+
}
636+
623637
extension String: Debuggable {
624638
var title: String {
625639
self

DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,14 @@ struct SimpleCallingView: View {
197197
try policies.forEach { try call.addProximityPolicy($0) }
198198
}
199199

200+
private func setClientCapabilities(for callId: String) async {
201+
guard let clientCapabilities = AppEnvironment.clientCapabilities else {
202+
return
203+
}
204+
let call = streamVideo.call(callType: callType, callId: callId)
205+
await call.enableClientCapabilities(clientCapabilities)
206+
}
207+
200208
private func parseURLIfRequired(_ text: String) {
201209
let adapter = DeeplinkAdapter()
202210
guard
@@ -234,6 +242,7 @@ struct SimpleCallingView: View {
234242
await setPreferredVideoCodec(for: text)
235243
try? await setAudioSessionPolicyOverride(for: text)
236244
try? setProximityPolicies(for: text)
245+
await setClientCapabilities(for: text)
237246
viewModel.enterLobby(
238247
callType: callType,
239248
callId: text,
@@ -243,11 +252,13 @@ struct SimpleCallingView: View {
243252
await setPreferredVideoCodec(for: text)
244253
try? await setAudioSessionPolicyOverride(for: text)
245254
try? setProximityPolicies(for: text)
255+
await setClientCapabilities(for: text)
246256
viewModel.joinCall(callType: callType, callId: text)
247257
case let .start(callId):
248258
await setPreferredVideoCodec(for: callId)
249259
try? await setAudioSessionPolicyOverride(for: callId)
250260
try? setProximityPolicies(for: callId)
261+
await setClientCapabilities(for: callId)
251262
viewModel.startCall(
252263
callType: callType,
253264
callId: callId,

DemoApp/Sources/Views/Login/DebugMenu.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ struct DebugMenu: View {
129129
didSet { AppEnvironment.proximityPolicies = proximityPolicies }
130130
}
131131

132+
@State private var availableClientCapabilities = ClientCapability.allCases
133+
@State private var preferredClientCapabilities = AppEnvironment.clientCapabilities {
134+
didSet { AppEnvironment.clientCapabilities = preferredClientCapabilities }
135+
}
136+
132137
var body: some View {
133138
Menu {
134139
makeMenu(
@@ -188,6 +193,40 @@ struct DebugMenu: View {
188193
label: "ClosedCaptions Integration"
189194
) { self.closedCaptionsIntegration = $0 }
190195

196+
makeMultipleSelectMenu(
197+
for: availableClientCapabilities,
198+
currentValues: preferredClientCapabilities ?? [],
199+
additionalItems: {
200+
if preferredClientCapabilities != nil {
201+
Divider()
202+
203+
Button {
204+
self.preferredClientCapabilities = nil
205+
} label: {
206+
Text("Remove overrides")
207+
}
208+
} else {
209+
EmptyView()
210+
}
211+
},
212+
label: "Override Client Capabilities"
213+
) { item, isSelected in
214+
if isSelected {
215+
if let preferredClientCapabilities {
216+
if preferredClientCapabilities.count == 1 {
217+
self.preferredClientCapabilities = nil
218+
} else {
219+
self.preferredClientCapabilities = preferredClientCapabilities.filter { item != $0 }
220+
}
221+
}
222+
} else {
223+
if preferredClientCapabilities == nil {
224+
preferredClientCapabilities = Set<ClientCapability>()
225+
}
226+
preferredClientCapabilities?.insert(item)
227+
}
228+
}
229+
191230
makeMenu(
192231
for: [.default, .ownCapabilities],
193232
currentValue: audioSessionPolicy,
@@ -484,6 +523,7 @@ struct DebugMenu: View {
484523
private func makeMultipleSelectMenu<Item: Debuggable>(
485524
for items: [Item],
486525
currentValues: Set<Item>,
526+
@ViewBuilder additionalItems: () -> some View = { EmptyView() },
487527
label: String,
488528
updater: @escaping (Item, Bool) -> Void
489529
) -> some View {
@@ -501,6 +541,7 @@ struct DebugMenu: View {
501541
}
502542
}
503543
}
544+
additionalItems()
504545
} label: {
505546
Text(label)
506547
}

DemoApp/Sources/Views/Reactions/ReactionOverlayView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ struct ReactionOverlayView_Previews: PreviewProvider {
5757
joinedAt: Date(),
5858
audioLevel: 0,
5959
audioLevels: [],
60-
pin: nil
60+
pin: nil,
61+
pausedTracks: []
6162
)
6263
)
6364
}

DemoApp/Sources/Views/Reactions/ReactionsViewModifier.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ struct ReactionsViewModifier_Previews: PreviewProvider {
5555
joinedAt: Date(),
5656
audioLevel: 0,
5757
audioLevels: [],
58-
pin: nil
58+
pin: nil,
59+
pausedTracks: []
5960
)
6061
)
6162
)

DocumentationTests/DocumentationTests/DocumentationTests.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
4029E95E2CB94EAE00E1D571 /* 22-manual-quality-selection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4029E95D2CB94EA700E1D571 /* 22-manual-quality-selection.swift */; };
2121
404CAED82B8E3874007087BC /* 06-apply-video-filters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 404CAED72B8E3874007087BC /* 06-apply-video-filters.swift */; };
2222
4068C1252B67C056006B0BEE /* 03-callkit-integration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4068C1242B67C056006B0BEE /* 03-callkit-integration.swift */; };
23+
40895E622E264BB000D3049D /* 25-incoming-video-state.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40895E612E264BB000D3049D /* 25-incoming-video-state.swift */; };
2324
408CE0F52BD91B490052EC3A /* 19-transcriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408CE0F42BD91B490052EC3A /* 19-transcriptions.swift */; };
2425
409774B02CC19F5500E0D3EE /* 23-network-disruption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409774AF2CC19F4900E0D3EE /* 23-network-disruption.swift */; };
2526
409C39692B67CC5C0090044C /* 04-screensharing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409C39682B67CC5C0090044C /* 04-screensharing.swift */; };
@@ -102,6 +103,7 @@
102103
4029E95D2CB94EA700E1D571 /* 22-manual-quality-selection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "22-manual-quality-selection.swift"; sourceTree = "<group>"; };
103104
404CAED72B8E3874007087BC /* 06-apply-video-filters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "06-apply-video-filters.swift"; sourceTree = "<group>"; };
104105
4068C1242B67C056006B0BEE /* 03-callkit-integration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-callkit-integration.swift"; sourceTree = "<group>"; };
106+
40895E612E264BB000D3049D /* 25-incoming-video-state.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "25-incoming-video-state.swift"; sourceTree = "<group>"; };
105107
408CE0F42BD91B490052EC3A /* 19-transcriptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "19-transcriptions.swift"; sourceTree = "<group>"; };
106108
409774AF2CC19F4900E0D3EE /* 23-network-disruption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "23-network-disruption.swift"; sourceTree = "<group>"; };
107109
409C39682B67CC5C0090044C /* 04-screensharing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-screensharing.swift"; sourceTree = "<group>"; };
@@ -286,6 +288,7 @@
286288
4029E95D2CB94EA700E1D571 /* 22-manual-quality-selection.swift */,
287289
409774AF2CC19F4900E0D3EE /* 23-network-disruption.swift */,
288290
401C1EF32D494CED00304609 /* 24-closed-captions.swift */,
291+
40895E612E264BB000D3049D /* 25-incoming-video-state.swift */,
289292
);
290293
path = "05-ui-cookbook";
291294
sourceTree = "<group>";
@@ -487,6 +490,7 @@
487490
40FFDC442B63E95D004DA7A2 /* 14-swiftui-vs-uikit.swift in Sources */,
488491
40FFDC872B63FEAE004DA7A2 /* 05-incoming-call.swift in Sources */,
489492
4029E95E2CB94EAE00E1D571 /* 22-manual-quality-selection.swift in Sources */,
493+
40895E622E264BB000D3049D /* 25-incoming-video-state.swift in Sources */,
490494
40FFDC942B6401CC004DA7A2 /* 07-video-fallback.swift in Sources */,
491495
400D91C72B63D96800EBA47D /* 03-quickstart.swift in Sources */,
492496
40FFDC9E2B64063D004DA7A2 /* 12-connection-unstable.swift in Sources */,
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import StreamVideo
2+
import StreamVideoSwiftUI
3+
import SwiftUI
4+
import Combine
5+
6+
@MainActor
7+
fileprivate func content() {
8+
asyncContainer {
9+
let call = streamVideo.call(callType: "default", callId: "my-call-id")
10+
await call.disableClientCapabilities([.subscriberVideoPause])
11+
}
12+
13+
container {
14+
let cancellable = call
15+
.state
16+
.$participants
17+
.sink { participants in
18+
let pausedVideoParticipants = participants.filter {
19+
$0.pausedTracks.contains(.video)
20+
}
21+
22+
print("Participants with paused video tracks: \(pausedVideoParticipants)")
23+
}
24+
25+
// Cancel when no longer needed:
26+
cancellable.cancel()
27+
}
28+
29+
viewContainer {
30+
if participant.pausedTracks.contains(.video) {
31+
Image(systemName: "video.slash.fill")
32+
.foregroundColor(.yellow)
33+
.padding(4)
34+
}
35+
}
36+
37+
container {
38+
struct ParticipantInfoView: View {
39+
@Injected(\.images) var images
40+
@Injected(\.fonts) var fonts
41+
@Injected(\.colors) var colors
42+
43+
var participant: CallParticipant
44+
var isPinned: Bool
45+
var maxHeight: CGFloat
46+
47+
public init(
48+
participant: CallParticipant,
49+
isPinned: Bool,
50+
maxHeight: Float = 14
51+
) {
52+
self.participant = participant
53+
self.isPinned = isPinned
54+
self.maxHeight = CGFloat(maxHeight)
55+
}
56+
57+
public var body: some View {
58+
HStack(spacing: 4) {
59+
if isPinned {
60+
Image(systemName: "pin.fill")
61+
.resizable()
62+
.aspectRatio(contentMode: .fit)
63+
.frame(maxHeight: maxHeight)
64+
.foregroundColor(.white)
65+
.padding(.trailing, 4)
66+
}
67+
Text(participant.name.isEmpty ? participant.id : participant.name)
68+
.foregroundColor(.white)
69+
.multilineTextAlignment(.leading)
70+
.lineLimit(1)
71+
.font(fonts.caption1)
72+
.minimumScaleFactor(0.7)
73+
.accessibility(identifier: "participantName")
74+
75+
if participant.pausedTracks.contains(.video) {
76+
Image(systemName: "wifi.slash")
77+
.resizable()
78+
.aspectRatio(contentMode: .fit)
79+
.frame(maxHeight: maxHeight)
80+
.foregroundColor(.white)
81+
.padding(.trailing, 4)
82+
}
83+
84+
SoundIndicator(participant: participant)
85+
.frame(maxHeight: maxHeight)
86+
}
87+
.padding(.all, 2)
88+
.padding(.horizontal, 4)
89+
.frame(height: 28)
90+
.cornerRadius(
91+
8,
92+
corners: [.topRight],
93+
backgroundColor: colors.participantInfoBackgroundColor
94+
)
95+
}
96+
}
97+
}
98+
99+
container {
100+
struct VideoCallParticipantModifier: ViewModifier {
101+
102+
var participant: CallParticipant
103+
var call: Call?
104+
var availableFrame: CGRect
105+
var ratio: CGFloat
106+
var showAllInfo: Bool
107+
var decorations: Set<VideoCallParticipantDecoration>
108+
109+
public init(
110+
participant: CallParticipant,
111+
call: Call?,
112+
availableFrame: CGRect,
113+
ratio: CGFloat,
114+
showAllInfo: Bool,
115+
decorations: [VideoCallParticipantDecoration] = VideoCallParticipantDecoration.allCases
116+
) {
117+
self.participant = participant
118+
self.call = call
119+
self.availableFrame = availableFrame
120+
self.ratio = ratio
121+
self.showAllInfo = showAllInfo
122+
self.decorations = .init(decorations)
123+
}
124+
125+
public func body(content: Content) -> some View {
126+
content
127+
.adjustVideoFrame(to: availableFrame.size.width, ratio: ratio)
128+
.overlay(
129+
ZStack {
130+
BottomView(content: {
131+
HStack {
132+
ParticipantInfoView(
133+
participant: participant,
134+
isPinned: participant.isPinned
135+
)
136+
137+
Spacer()
138+
139+
if showAllInfo {
140+
ConnectionQualityIndicator(
141+
connectionQuality: participant.connectionQuality
142+
)
143+
}
144+
}
145+
})
146+
}
147+
)
148+
.applyDecorationModifierIfRequired(
149+
VideoCallParticipantOptionsModifier(participant: participant, call: call),
150+
decoration: .options,
151+
availableDecorations: decorations
152+
)
153+
.applyDecorationModifierIfRequired(
154+
VideoCallParticipantSpeakingModifier(participant: participant, participantCount: participantCount),
155+
decoration: .speaking,
156+
availableDecorations: decorations
157+
)
158+
.clipShape(RoundedRectangle(cornerRadius: 16))
159+
.clipped()
160+
}
161+
162+
@MainActor
163+
private var participantCount: Int {
164+
call?.state.participants.count ?? 0
165+
}
166+
}
167+
}
168+
}

DocumentationTests/DocumentationTests/DocumentationTests/GloballyUsedVariables.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ var participant = CallParticipant(
3737
joinedAt: .init(),
3838
audioLevel: 0,
3939
audioLevels: [],
40-
pin: nil
40+
pin: nil,
41+
pausedTracks: []
4142
)
4243
var contentMode = UIView.ContentMode.scaleAspectFit
4344
var id = ""
@@ -421,7 +422,8 @@ var otherParticipant = CallParticipant(
421422
joinedAt: .init(),
422423
audioLevel: 0,
423424
audioLevels: [],
424-
pin: nil
425+
pin: nil,
426+
pausedTracks: []
425427
)
426428

427429
final class UserManager {

0 commit comments

Comments
 (0)