Skip to content

Commit 0a972d2

Browse files
authored
[Fix]Enforce picture-in-picture stop on foreground (#803)
1 parent a1695e6 commit 0a972d2

File tree

7 files changed

+266
-9
lines changed

7 files changed

+266
-9
lines changed

CHANGELOG.md

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

55
# Upcoming
66

7+
### 🐞 Fixed
8+
- Fix an issue that when the app was becoming active from the application switcher, Picture-in-Picture wasn't stopped. [#803](https://github.com/GetStream/stream-video-swift/pull/803)
9+
710
### 🔄 Changed
811
- Update OutgoingCallView to get updates from ringing call [#798](https://github.com/GetStream/stream-video-swift/pull/798)
912

Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureController.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ final class PictureInPictureController: @unchecked Sendable {
4343
/// Collection of active subscriptions.
4444
private var disposableBag = DisposableBag()
4545

46+
/// Adapter responsible for enforcing the stop of Picture in Picture when
47+
/// the application returns to the foreground. It monitors app state and
48+
/// PiP activity to ensure PiP is stopped when appropriate.
49+
private var enforcedStopAdapter: PictureInPictureEnforcedStopAdapter?
50+
4651
// MARK: - Lifecycle
4752

4853
/// Creates a new Picture-in-Picture controller.
@@ -78,15 +83,6 @@ final class PictureInPictureController: @unchecked Sendable {
7883
.log(.error, subsystems: .pictureInPicture) { "Picture-in-Picture failed to start: \($0)." }
7984
.sink { _ in }
8085
.store(in: disposableBag)
81-
82-
// Add delay to prevent premature cancellation
83-
applicationStateAdapter
84-
.statePublisher
85-
.filter { $0 == .foreground }
86-
.debounce(for: .milliseconds(250), scheduler: RunLoop.main)
87-
.receive(on: DispatchQueue.main)
88-
.sink { [weak self] _ in self?.pictureInPictureController?.stopPictureInPicture() }
89-
.store(in: disposableBag)
9086
}
9187

9288
/// Updates the Picture-in-Picture controller when the source view changes.
@@ -95,6 +91,7 @@ final class PictureInPictureController: @unchecked Sendable {
9591
guard let sourceView, store.state.call != nil else {
9692
/// We ensure to cleanUp every Picture-in-Picture interacting component so that the next
9793
/// Call will start with clean state.
94+
pictureInPictureController?.stopPictureInPicture()
9895
pictureInPictureController?.contentSource = nil
9996
contentViewController = nil
10097
pictureInPictureController = nil
@@ -163,8 +160,11 @@ final class PictureInPictureController: @unchecked Sendable {
163160
.sink { [weak self] in self?.store.dispatch(.setActive($0)) }
164161
.store(in: disposableBag, key: DisposableKey.isActive.rawValue)
165162

163+
enforcedStopAdapter = .init(pictureInPictureController)
164+
166165
log.debug("Controller has been configured.", subsystems: .pictureInPicture)
167166
} else {
167+
enforcedStopAdapter = nil
168168
disposableBag.remove(DisposableKey.isPossible.rawValue)
169169
disposableBag.remove(DisposableKey.isActive.rawValue)
170170
log.debug("Controller has been released.", subsystems: .pictureInPicture)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import AVKit
6+
import Combine
7+
import StreamVideo
8+
9+
/// An adapter responsible for enforcing the stop of Picture in Picture
10+
/// playback when the application returns to the foreground. It listens
11+
/// to application state and PiP activity changes and stops PiP if the
12+
/// app becomes active.
13+
final class PictureInPictureEnforcedStopAdapter {
14+
15+
/// Keys used to identify disposable operations.
16+
private enum DisposableKey: String { case stopEnforceOperation }
17+
18+
/// Adapter that provides the current application state.
19+
@Injected(\.applicationStateAdapter) private var applicationStateAdapter
20+
21+
/// A serial dispatch queue for background processing.
22+
private let processingQueue = DispatchQueue(label: UUID().uuidString)
23+
24+
/// A bag to store Combine subscriptions for cancellation.
25+
private let disposableBag = DisposableBag()
26+
27+
/// Initializes the adapter with a Picture in Picture controller and
28+
/// starts observing application state and PiP activity to enforce stop.
29+
///
30+
/// - Parameter pictureInPictureController: The PiP controller to manage.
31+
init(_ pictureInPictureController: StreamPictureInPictureControllerProtocol) {
32+
Publishers.CombineLatest(
33+
applicationStateAdapter.statePublisher,
34+
pictureInPictureController.isPictureInPictureActivePublisher
35+
)
36+
.sink {
37+
[weak self, weak pictureInPictureController] in self?.didUpdate(
38+
applicationState: $0,
39+
isPictureInPictureActive: $1,
40+
pictureInPictureController: pictureInPictureController
41+
)
42+
}
43+
.store(in: disposableBag)
44+
}
45+
46+
/// Cleans up all stored subscriptions when the instance is deallocated.
47+
deinit {
48+
disposableBag.removeAll()
49+
}
50+
51+
/// Handles updates to application state and PiP activity.
52+
/// Starts a timer that attempts to stop PiP if the app is foregrounded
53+
/// and PiP is active.
54+
///
55+
/// - Parameters:
56+
/// - applicationState: The current state of the application.
57+
/// - isPictureInPictureActive: A Boolean indicating whether PiP is active.
58+
/// - pictureInPictureController: The PiP controller to manage.
59+
private func didUpdate(
60+
applicationState: ApplicationState,
61+
isPictureInPictureActive: Bool,
62+
pictureInPictureController: StreamPictureInPictureControllerProtocol?
63+
) {
64+
switch (applicationState, isPictureInPictureActive) {
65+
case (.foreground, true):
66+
Foundation
67+
.Timer
68+
.publish(every: 0.1, on: .main, in: .default)
69+
.autoconnect()
70+
.filter { [weak self] _ in self?.applicationStateAdapter.state == .foreground }
71+
.log(.debug) { _ in "Will attempt to forcefully stop Picture-in-Picture." }
72+
.receive(on: DispatchQueue.main)
73+
.sink { [weak pictureInPictureController] _ in pictureInPictureController?.stopPictureInPicture() }
74+
.store(in: disposableBag, key: DisposableKey.stopEnforceOperation.rawValue)
75+
default:
76+
disposableBag.remove(DisposableKey.stopEnforceOperation.rawValue)
77+
}
78+
}
79+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import AVKit
6+
import Combine
7+
import Foundation
8+
9+
protocol StreamPictureInPictureControllerProtocol: AnyObject {
10+
var isPictureInPictureActivePublisher: AnyPublisher<Bool, Never> { get }
11+
12+
func stopPictureInPicture()
13+
}
14+
15+
extension AVPictureInPictureController: StreamPictureInPictureControllerProtocol {
16+
var isPictureInPictureActivePublisher: AnyPublisher<Bool, Never> {
17+
publisher(for: \.isPictureInPictureActive).eraseToAnyPublisher()
18+
}
19+
}

StreamVideo.xcodeproj/project.pbxproj

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,11 @@
545545
40B499CA2AC1A5E100A53B60 /* OSLogDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B499C92AC1A5E100A53B60 /* OSLogDestination.swift */; };
546546
40B499CC2AC1A90F00A53B60 /* DeeplinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B499CB2AC1A90F00A53B60 /* DeeplinkTests.swift */; };
547547
40B499CE2AC1AA0900A53B60 /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4030E59F2A9DF5BD003E8CBA /* AppEnvironment.swift */; };
548+
40B575CE2DCCEA7D00F489B8 /* PictureInPictureEnforcedStopAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B575CD2DCCEA7D00F489B8 /* PictureInPictureEnforcedStopAdapter.swift */; };
549+
40B575D02DCCEBA900F489B8 /* PictureInPictureEnforcedStopAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B575CF2DCCEBA900F489B8 /* PictureInPictureEnforcedStopAdapterTests.swift */; };
550+
40B575D12DCCEC4500F489B8 /* MockAppStateAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4052BF542DBA830D0085AFA5 /* MockAppStateAdapter.swift */; };
551+
40B575D42DCCECE800F489B8 /* MockAVPictureInPictureController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B575D22DCCECDA00F489B8 /* MockAVPictureInPictureController.swift */; };
552+
40B575D82DCCF00200F489B8 /* StreamPictureInPictureControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B575D52DCCEFB500F489B8 /* StreamPictureInPictureControllerProtocol.swift */; };
548553
40B713692A275F1400D1FE67 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8456E6C5287EB55F004E180E /* AppState.swift */; };
549554
40BBC4792C6227DC002AEF92 /* DemoNoiseCancellationButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40BBC4782C6227DC002AEF92 /* DemoNoiseCancellationButtonView.swift */; };
550555
40BBC47C2C6227F1002AEF92 /* View+PresentDemoMoreMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40BBC47B2C6227F1002AEF92 /* View+PresentDemoMoreMenu.swift */; };
@@ -1998,6 +2003,10 @@
19982003
40B48C562D1588DB002C4EAB /* Stream_Video_Sfu_Models_TrackInfo+Dummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Stream_Video_Sfu_Models_TrackInfo+Dummy.swift"; sourceTree = "<group>"; };
19992004
40B499C92AC1A5E100A53B60 /* OSLogDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogDestination.swift; sourceTree = "<group>"; };
20002005
40B499CB2AC1A90F00A53B60 /* DeeplinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkTests.swift; sourceTree = "<group>"; };
2006+
40B575CD2DCCEA7D00F489B8 /* PictureInPictureEnforcedStopAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictureInPictureEnforcedStopAdapter.swift; sourceTree = "<group>"; };
2007+
40B575CF2DCCEBA900F489B8 /* PictureInPictureEnforcedStopAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictureInPictureEnforcedStopAdapterTests.swift; sourceTree = "<group>"; };
2008+
40B575D22DCCECDA00F489B8 /* MockAVPictureInPictureController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAVPictureInPictureController.swift; sourceTree = "<group>"; };
2009+
40B575D52DCCEFB500F489B8 /* StreamPictureInPictureControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamPictureInPictureControllerProtocol.swift; sourceTree = "<group>"; };
20012010
40BBC4782C6227DC002AEF92 /* DemoNoiseCancellationButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoNoiseCancellationButtonView.swift; sourceTree = "<group>"; };
20022011
40BBC47B2C6227F1002AEF92 /* View+PresentDemoMoreMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+PresentDemoMoreMenu.swift"; sourceTree = "<group>"; };
20032012
40BBC47D2C62287F002AEF92 /* DemoReconnectionButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoReconnectionButtonView.swift; sourceTree = "<group>"; };
@@ -3897,6 +3906,7 @@
38973906
40914C962B567B6B00F6A13E /* PictureInPicture */ = {
38983907
isa = PBXGroup;
38993908
children = (
3909+
40B575D22DCCECDA00F489B8 /* MockAVPictureInPictureController.swift */,
39003910
409AF6FC2DAFF86800EE7BF6 /* PictureInPictureVideoParticipantViewTests.swift */,
39013911
409AF6FA2DAFF7E900EE7BF6 /* PictureInPictureScreenSharingViewTests.swift */,
39023912
409AF6F62DAFF3D500EE7BF6 /* PictureInPictureVideoCallViewControllerTests.swift */,
@@ -3908,6 +3918,7 @@
39083918
40914C9B2B56AA6600F6A13E /* StreamBufferTransformerTests.swift */,
39093919
4013A8F02D82D93900F81C15 /* StreamPictureInPictureAdapterTests.swift */,
39103920
409AF6F82DAFF67200EE7BF6 /* PictureInPictureContentViewTests.swift */,
3921+
40B575CF2DCCEBA900F489B8 /* PictureInPictureEnforcedStopAdapterTests.swift */,
39113922
);
39123923
path = PictureInPicture;
39133924
sourceTree = "<group>";
@@ -3998,6 +4009,8 @@
39984009
40A9416C2B4D958A006D6965 /* PictureInPicture */ = {
39994010
isa = PBXGroup;
40004011
children = (
4012+
40B575D52DCCEFB500F489B8 /* StreamPictureInPictureControllerProtocol.swift */,
4013+
40B575CD2DCCEA7D00F489B8 /* PictureInPictureEnforcedStopAdapter.swift */,
40014014
409AF6E72DAFC80200EE7BF6 /* PictureInPictureContent.swift */,
40024015
4072A5952DAF99E000108E8F /* PictureInPictureContentProvider.swift */,
40034016
4072A5932DAF992400108E8F /* PictureInPictureParticipantModifier.swift */,
@@ -7960,6 +7973,7 @@
79607973
files = (
79617974
8434C542289BC0B00001490A /* L10n.swift in Sources */,
79627975
846FBE8628AA696900147F6E /* ColorExtensions.swift in Sources */,
7976+
40B575D82DCCF00200F489B8 /* StreamPictureInPictureControllerProtocol.swift in Sources */,
79637977
84231E4728B2506B007985EF /* VideoRenderer.swift in Sources */,
79647978
4072A5922DAEB41500108E8F /* PictureInPictureDelegateProxy.swift in Sources */,
79657979
40A941742B4D97A1006D6965 /* PictureInPictureVideoRenderer.swift in Sources */,
@@ -8061,6 +8075,7 @@
80618075
840626722A37A431004B8748 /* IncomingCall.swift in Sources */,
80628076
40AA2EDC2ADFEB01000DCA5C /* HorizontalParticipantsListView.swift in Sources */,
80638077
846FBE8F28AAEC5D00147F6E /* KeyboardReadable.swift in Sources */,
8078+
40B575CE2DCCEA7D00F489B8 /* PictureInPictureEnforcedStopAdapter.swift in Sources */,
80648079
40245F362BE26D6700FCF075 /* StatelessToggleCameraIconView.swift in Sources */,
80658080
8490032D29D4774500AD9BB4 /* JoiningCallView.swift in Sources */,
80668081
403FF3F42BA1DC690092CE8A /* StreamYUVToARGBConversion.swift in Sources */,
@@ -8103,6 +8118,7 @@
81038118
isa = PBXSourcesBuildPhase;
81048119
buildActionMask = 2147483647;
81058120
files = (
8121+
40B575D02DCCEBA900F489B8 /* PictureInPictureEnforcedStopAdapterTests.swift in Sources */,
81068122
82FF40BE2A17C73500B4D95E /* CallConnectingView_Tests.swift in Sources */,
81078123
407AF7182B61619900E9E3E7 /* ParticipantListButton_Tests.swift in Sources */,
81088124
409AF6EF2DAFE7D000EE7BF6 /* PeerConnectionFactory+Mock.swift in Sources */,
@@ -8163,6 +8179,7 @@
81638179
40245F562BE2746300FCF075 /* ScreensharingSettings+Dummy.swift in Sources */,
81648180
40245F572BE2746300FCF075 /* EgressRTMPResponse+Dummy.swift in Sources */,
81658181
40245F582BE2746300FCF075 /* EgressResponse+Dummy.swift in Sources */,
8182+
40B575D42DCCECE800F489B8 /* MockAVPictureInPictureController.swift in Sources */,
81668183
409AF6F92DAFF67200EE7BF6 /* PictureInPictureContentViewTests.swift in Sources */,
81678184
40245F592BE2746300FCF075 /* BackstageSettings+Dummy.swift in Sources */,
81688185
40245F5A2BE2746300FCF075 /* CallAcceptedEvent+Dummy.swift in Sources */,
@@ -8214,6 +8231,7 @@
82148231
82FF40C42A17C74D00B4D95E /* IncomingCallView_Tests.swift in Sources */,
82158232
404C27CB2BF2552800DF2937 /* XCTestCase+PredicateFulfillment.swift in Sources */,
82168233
409AF6F12DAFEFA200EE7BF6 /* PictureInPictureParticipantModifierTests.swift in Sources */,
8234+
40B575D12DCCEC4500F489B8 /* MockAppStateAdapter.swift in Sources */,
82178235
4045D9DD2DAD5B110077A660 /* Resource_Tests.swift in Sources */,
82188236
408CE0F82BD95F170052EC3A /* VideoConfig+Dummy.swift in Sources */,
82198237
829F7BFA29FABC0E003EBACE /* ViewFactory.swift in Sources */,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Combine
6+
import Foundation
7+
@testable import StreamVideo
8+
@testable import StreamVideoSwiftUI
9+
10+
final class MockAVPictureInPictureController: StreamPictureInPictureControllerProtocol, Mockable {
11+
// MARK: - Mockable
12+
13+
typealias FunctionKey = MockFunctionKey
14+
typealias FunctionInputKey = MockFunctionInputKey
15+
var stubbedProperty: [String: Any] = [:]
16+
var stubbedFunction: [FunctionKey: Any] = [:]
17+
@Atomic var stubbedFunctionInput: [FunctionKey: [FunctionInputKey]] = FunctionKey
18+
.allCases
19+
.reduce(into: [FunctionKey: [FunctionInputKey]]()) { $0[$1] = [] }
20+
func stub<T>(for keyPath: KeyPath<MockAVPictureInPictureController, T>, with value: T) {
21+
stubbedProperty[propertyKey(for: keyPath)] = value
22+
}
23+
24+
func stub<T>(for function: FunctionKey, with value: T) {}
25+
26+
enum MockFunctionKey: Hashable, CaseIterable {
27+
case stopPictureInPicture
28+
}
29+
30+
enum MockFunctionInputKey: Payloadable {
31+
case stopPictureInPicture
32+
33+
var payload: Any {
34+
switch self {
35+
case .stopPictureInPicture:
36+
return ()
37+
}
38+
}
39+
}
40+
41+
var isPictureInPictureActivePublisher: AnyPublisher<Bool, Never> {
42+
get { self[dynamicMember: \.isPictureInPictureActivePublisher] }
43+
set { stub(for: \.isPictureInPictureActivePublisher, with: newValue) }
44+
}
45+
46+
func stopPictureInPicture() {
47+
stubbedFunctionInput[.stopPictureInPicture]?.append(.stopPictureInPicture)
48+
}
49+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import AVKit
6+
import Combine
7+
@testable import StreamVideo
8+
@testable import StreamVideoSwiftUI
9+
import StreamWebRTC
10+
import XCTest
11+
12+
final class PictureInPictureEnforcedStopAdapterTests: XCTestCase, @unchecked Sendable {
13+
14+
private lazy var mockPictureInPictureController: MockAVPictureInPictureController! = .init()
15+
private lazy var mockAppStateAdapter: MockAppStateAdapter! = .init()
16+
private lazy var subject: PictureInPictureEnforcedStopAdapter! = .init(mockPictureInPictureController)
17+
private var originalStateAdapter: AppStateProviding! = AppStateProviderKey.currentValue
18+
19+
override func setUp() async throws {
20+
try await super.setUp()
21+
_ = AppStateProviderKey.currentValue
22+
await wait(for: 0.1)
23+
AppStateProviderKey.currentValue = mockAppStateAdapter
24+
}
25+
26+
override func tearDown() {
27+
AppStateProviderKey.currentValue = originalStateAdapter
28+
mockPictureInPictureController = nil
29+
mockAppStateAdapter = nil
30+
subject = nil
31+
originalStateAdapter = nil
32+
super.tearDown()
33+
}
34+
35+
// MARK: - didUpdate
36+
37+
func test_didUpdate_appStateForeground_isPictureInPictureActiveTrue_stopWasCalledonPictureInPictureController() async {
38+
await assertStopPictureInPicture(
39+
applicationState: .foreground,
40+
isPictureInPictureActive: true,
41+
expectedCall: false
42+
)
43+
}
44+
45+
func test_didUpdate_appStateForeground_isPictureInPictureActiveFalse_stopWasNotCalledonPictureInPictureController() async {
46+
await assertStopPictureInPicture(
47+
applicationState: .foreground,
48+
isPictureInPictureActive: false,
49+
expectedCall: false
50+
)
51+
}
52+
53+
func test_didUpdate_appStateBackground_isPictureInPictureActiveTrue_stopWasNotCalledonPictureInPictureController() async {
54+
await assertStopPictureInPicture(
55+
applicationState: .background,
56+
isPictureInPictureActive: true,
57+
expectedCall: false
58+
)
59+
}
60+
61+
func test_didUpdate_appStateBackground_isPictureInPictureActiveFalse_stopWasNotCalledonPictureInPictureController() async {
62+
await assertStopPictureInPicture(
63+
applicationState: .background,
64+
isPictureInPictureActive: false,
65+
expectedCall: false
66+
)
67+
}
68+
69+
private func assertStopPictureInPicture(
70+
applicationState: ApplicationState,
71+
isPictureInPictureActive: Bool,
72+
expectedCall: Bool,
73+
file: StaticString = #file,
74+
function: StaticString = #function,
75+
line: UInt = #line
76+
) async {
77+
mockAppStateAdapter.stubbedState = applicationState
78+
let subject: CurrentValueSubject<Bool, Never> = .init(isPictureInPictureActive)
79+
mockPictureInPictureController.stub(
80+
for: \.isPictureInPictureActivePublisher,
81+
with: subject.eraseToAnyPublisher()
82+
)
83+
84+
_ = subject
85+
86+
await wait(for: 0.2)
87+
XCTAssertEqual(mockPictureInPictureController.timesCalled(.stopPictureInPicture) > 0, expectedCall)
88+
}
89+
}

0 commit comments

Comments
 (0)