Skip to content

Commit 379e7a1

Browse files
authored
[Enhancement]Handle camera session interruptions (#907)
1 parent 365b984 commit 379e7a1

File tree

5 files changed

+152
-2
lines changed

5 files changed

+152
-2
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
44

55
# Upcoming
66

7+
8+
### ✅ Added
9+
- The SDK now handles the interruptions produced from AVCaptureSession to ensure video capturing is active when needed. [#907](https://github.com/GetStream/stream-video-swift/pull/907)
10+
711
### 🐞 Fixed
812
- AudioSession management issues that were causing audio not being recorded during calls. [#906](https://github.com/GetStream/stream-video-swift/pull/906)
913

Sources/StreamVideo/Utils/SerialActorQueue/DispatchQueueExecutor.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,6 @@ final class DispatchQueueExecutor: SerialExecutor, @unchecked Sendable {
9898
}
9999
}
100100

101-
#if swift(>=6.0)
101+
#if compiler(>=6.0)
102102
extension DispatchQueueExecutor: TaskExecutor {}
103103
#endif
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import AVFoundation
6+
import Combine
7+
import Foundation
8+
import StreamWebRTC
9+
10+
/// Handles camera-related interruptions by observing `AVCaptureSession` interruption notifications.
11+
final class CameraInterruptionsHandler: StreamVideoCapturerActionHandler, @unchecked Sendable {
12+
13+
/// Represents the current camera session state (idle or running).
14+
private enum State {
15+
/// No active camera session.
16+
case idle
17+
/// An active camera session with a disposable bag for cleanup.
18+
case running(session: AVCaptureSession, disposableBag: DisposableBag)
19+
}
20+
21+
private var state: State = .idle
22+
/// Ensures serialized handling of interruption events.
23+
private let processingQueue = OperationQueue(maxConcurrentOperationCount: 1)
24+
25+
// MARK: - StreamVideoCapturerActionHandler
26+
27+
/// Handles camera-related actions triggered by the video capturer.
28+
func handle(_ action: StreamVideoCapturer.Action) async throws {
29+
switch action {
30+
/// Handle start capture event and register for interruption notifications.
31+
case let .startCapture(_, _, _, _, videoCapturer, _):
32+
if let cameraCapturer = videoCapturer as? RTCCameraVideoCapturer {
33+
didStartCapture(session: cameraCapturer.captureSession)
34+
} else {
35+
didStopCapture()
36+
}
37+
/// Handle stop capture event and cleanup.
38+
case .stopCapture:
39+
didStopCapture()
40+
default:
41+
break
42+
}
43+
}
44+
45+
// MARK: - Private
46+
47+
/// Sets up observers and state when camera capture starts.
48+
private func didStartCapture(session: AVCaptureSession) {
49+
let disposableBag = DisposableBag()
50+
51+
let interruptedNotification: Notification.Name = {
52+
#if compiler(>=6.0)
53+
return AVCaptureSession.wasInterruptedNotification
54+
#else
55+
return .AVCaptureSessionWasInterrupted
56+
#endif
57+
}()
58+
59+
/// Observe AVCaptureSession interruptions and log reasons.
60+
NotificationCenter
61+
.default
62+
.publisher(for: interruptedNotification)
63+
.compactMap { (notification: Notification) -> String? in
64+
guard
65+
let userInfo = notification.userInfo,
66+
let reasonRawValue = userInfo[AVCaptureSessionInterruptionReasonKey] as? NSNumber,
67+
let reason = AVCaptureSession.InterruptionReason(rawValue: reasonRawValue.intValue)
68+
else {
69+
return nil
70+
}
71+
return reason.description
72+
}
73+
.compactMap { $0 }
74+
.log(.debug, subsystems: .webRTC) { "CameraCapture session was interrupted with reason: \($0)." }
75+
.receive(on: processingQueue)
76+
.sink { _ in }
77+
.store(in: disposableBag)
78+
79+
/// Observe end of AVCaptureSession interruptions and restart session if needed.
80+
NotificationCenter
81+
.default
82+
.publisher(for: .AVCaptureSessionInterruptionEnded)
83+
.log(.debug, subsystems: .webRTC) { _ in "CameraCapture session interruption ended." }
84+
.receive(on: processingQueue)
85+
.sink { [weak self] _ in self?.handleInterruptionEnded() }
86+
.store(in: disposableBag)
87+
88+
state = .running(session: session, disposableBag: disposableBag)
89+
}
90+
91+
/// Cleans up resources and resets state when camera capture stops.
92+
private func didStopCapture() {
93+
switch state {
94+
case .idle:
95+
break
96+
case let .running(_, disposableBag):
97+
disposableBag.removeAll()
98+
processingQueue.cancelAllOperations()
99+
}
100+
state = .idle
101+
}
102+
103+
/// Restarts the session if it was interrupted and not running.
104+
private func handleInterruptionEnded() {
105+
switch state {
106+
case .idle:
107+
break
108+
case let .running(session, _):
109+
guard !session.isRunning else {
110+
return
111+
}
112+
session.startRunning()
113+
}
114+
}
115+
}
116+
117+
#if compiler(>=6.0)
118+
extension AVCaptureSession.InterruptionReason: @retroactive CustomStringConvertible {}
119+
#else
120+
extension AVCaptureSession.InterruptionReason: CustomStringConvertible {}
121+
#endif
122+
123+
extension AVCaptureSession.InterruptionReason {
124+
/// Provides a readable description for each interruption reason.
125+
public var description: String {
126+
switch self {
127+
case .videoDeviceNotAvailableInBackground:
128+
return ".videoDeviceNotAvailableInBackground"
129+
case .audioDeviceInUseByAnotherClient:
130+
return ".audioDeviceInUseByAnotherClient"
131+
case .videoDeviceInUseByAnotherClient:
132+
return ".videoDeviceInUseByAnotherClient"
133+
case .videoDeviceNotAvailableWithMultipleForegroundApps:
134+
return ".videoDeviceNotAvailableWithMultipleForegroundApps"
135+
case .videoDeviceNotAvailableDueToSystemPressure:
136+
return ".videoDeviceNotAvailableDueToSystemPressure"
137+
@unknown default:
138+
return "\(self)"
139+
}
140+
}
141+
}

Sources/StreamVideo/WebRTC/v2/VideoCapturing/StreamVideoCapturer.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ final class StreamVideoCapturer: StreamVideoCapturing {
5252
CameraFocusHandler(),
5353
CameraCapturePhotoHandler(),
5454
CameraVideoOutputHandler(),
55-
CameraZoomHandler()
55+
CameraZoomHandler(),
56+
CameraInterruptionsHandler()
5657
]
5758
)
5859
#endif

StreamVideo.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,7 @@
711711
40D36AE22DDE023800972D75 /* WebRTCStatsCollecting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D36AE12DDE023800972D75 /* WebRTCStatsCollecting.swift */; };
712712
40D36AE42DDE02D100972D75 /* MockWebRTCStatsCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D36AE32DDE02D100972D75 /* MockWebRTCStatsCollector.swift */; };
713713
40D6ADDD2ACDB51C00EF5336 /* VideoRenderer_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D6ADDC2ACDB51C00EF5336 /* VideoRenderer_Tests.swift */; };
714+
40D75C652E44F5CE000E0438 /* CameraInterruptionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D75C642E44F5CE000E0438 /* CameraInterruptionsHandler.swift */; };
714715
40D75C522E437FBC000E0438 /* InterruptionEffect_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D75C512E437FBC000E0438 /* InterruptionEffect_Tests.swift */; };
715716
40D75C542E438317000E0438 /* RouteChangeEffect_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D75C532E438317000E0438 /* RouteChangeEffect_Tests.swift */; };
716717
40D75C562E4385FE000E0438 /* MockAVAudioSessionPortDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D75C552E4385FE000E0438 /* MockAVAudioSessionPortDescription.swift */; };
@@ -2243,6 +2244,7 @@
22432244
40D36AE12DDE023800972D75 /* WebRTCStatsCollecting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCStatsCollecting.swift; sourceTree = "<group>"; };
22442245
40D36AE32DDE02D100972D75 /* MockWebRTCStatsCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockWebRTCStatsCollector.swift; sourceTree = "<group>"; };
22452246
40D6ADDC2ACDB51C00EF5336 /* VideoRenderer_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRenderer_Tests.swift; sourceTree = "<group>"; };
2247+
40D75C642E44F5CE000E0438 /* CameraInterruptionsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraInterruptionsHandler.swift; sourceTree = "<group>"; };
22462248
40D75C512E437FBC000E0438 /* InterruptionEffect_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterruptionEffect_Tests.swift; sourceTree = "<group>"; };
22472249
40D75C532E438317000E0438 /* RouteChangeEffect_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteChangeEffect_Tests.swift; sourceTree = "<group>"; };
22482250
40D75C552E4385FE000E0438 /* MockAVAudioSessionPortDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAVAudioSessionPortDescription.swift; sourceTree = "<group>"; };
@@ -5203,6 +5205,7 @@
52035205
40E3635E2D0A18B10028C52A /* CameraZoomHandler.swift */,
52045206
40E3635A2D0A15E40028C52A /* CameraCapturePhotoHandler.swift */,
52055207
40E3635C2D0A17C10028C52A /* CameraVideoOutputHandler.swift */,
5208+
40D75C642E44F5CE000E0438 /* CameraInterruptionsHandler.swift */,
52065209
);
52075210
path = Camera;
52085211
sourceTree = "<group>";
@@ -8169,6 +8172,7 @@
81698172
40AD64C42DC269E60077AE15 /* ProximityMonitor.swift in Sources */,
81708173
40AD64C52DC269E60077AE15 /* VideoProximityPolicy.swift in Sources */,
81718174
40AD64C62DC269E60077AE15 /* ProximityPolicy.swift in Sources */,
8175+
40D75C652E44F5CE000E0438 /* CameraInterruptionsHandler.swift in Sources */,
81728176
40AD64C72DC269E60077AE15 /* SpeakerProximityPolicy.swift in Sources */,
81738177
841BAA3D2BD15CDE000C73E4 /* GetCallStatsResponse.swift in Sources */,
81748178
842E70D92B91BE1700D2D68B /* CallClosedCaption.swift in Sources */,

0 commit comments

Comments
 (0)