Skip to content

Commit 6fda423

Browse files
authored
[Fix]Ensure PiP deactivation when leaving the call (#774)
1 parent a7aa56c commit 6fda423

10 files changed

+155
-79
lines changed

Sources/StreamVideoSwiftUI/CallViewModel.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,11 @@ open class CallViewModel: ObservableObject {
614614
recordingUpdates?.cancel()
615615
recordingUpdates = nil
616616
call?.leave()
617+
618+
pictureInPictureAdapter.call = nil
619+
pictureInPictureAdapter.sourceView = nil
620+
isPictureInPictureEnabled = false
621+
617622
call = nil
618623
callParticipants = [:]
619624
outgoingCallMembers = []

Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureContentProvider.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ final class PictureInPictureContentProvider: @unchecked Sendable {
3737
participantUpdateCancellable = nil
3838

3939
guard let call else {
40+
store.dispatch(.setContent(.inactive))
4041
return
4142
}
4243

@@ -110,7 +111,7 @@ final class PictureInPictureContentProvider: @unchecked Sendable {
110111

111112
/// Updates the preferred content size for Picture-in-Picture if needed.
112113
private func updatePreferredContentSizeIfRequired(for participant: CallParticipant) {
113-
guard !store.state.isActive, participant.hasVideo, participant.trackSize != .zero else {
114+
guard participant.hasVideo, participant.trackSize != .zero else {
114115
return
115116
}
116117
store.dispatch(.setPreferredContentSize(participant.trackSize))

Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureController.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ final class PictureInPictureController: @unchecked Sendable {
7070
.$state
7171
.filter { $0 == .foreground }
7272
.debounce(for: .milliseconds(250), scheduler: RunLoop.main)
73+
.receive(on: DispatchQueue.main)
7374
.sink { [weak self] _ in self?.pictureInPictureController?.stopPictureInPicture() }
7475
.store(in: disposableBag)
7576
}

Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureStore.swift

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import UIKit
1111
/// Manages the state of the Picture-in-Picture window.
1212
///
1313
/// Handles all state changes and provides a reactive interface for observing updates.
14-
final class PictureInPictureStore: ObservableObject {
14+
final class PictureInPictureStore: ObservableObject, @unchecked Sendable {
1515

1616
/// The current state of the Picture-in-Picture window.
1717
struct State: Sendable {
@@ -59,7 +59,7 @@ final class PictureInPictureStore: ObservableObject {
5959
private let subject: CurrentValueSubject<State, Never>
6060
var state: State { subject.value }
6161

62-
private let processingQueue = UnfairQueue()
62+
private let processingQueue = SerialActorQueue()
6363

6464
@MainActor
6565
init() {
@@ -70,32 +70,32 @@ final class PictureInPictureStore: ObservableObject {
7070
///
7171
/// - Parameter action: The action to process
7272
func dispatch(_ action: Action) {
73-
processingQueue.sync { [weak self] in
73+
processingQueue.async { [weak self] in
7474
guard let self else {
7575
return
7676
}
7777

78-
var currentState = state
78+
var updatedState = state
7979
switch action {
8080
case let .setActive(value):
81-
currentState.isActive = value
81+
updatedState.isActive = value
8282
case let .setCall(value):
83-
currentState.call = value
83+
updatedState.call = value
8484
case let .setSourceView(value):
85-
currentState.sourceView = value
85+
updatedState.sourceView = value
8686
case let .setViewFactory(value):
87-
currentState.viewFactory = value
87+
updatedState.viewFactory = value
8888
case let .setContent(value):
89-
currentState.content = value
89+
updatedState.content = value
9090
case let .setPreferredContentSize(value):
91-
currentState.preferredContentSize = value
91+
updatedState.preferredContentSize = value
9292
case let .setContentSize(value):
93-
currentState.contentSize = value
93+
updatedState.contentSize = value
9494
case let .setCanStartPictureInPictureAutomaticallyFromInline(value):
95-
currentState.canStartPictureInPictureAutomaticallyFromInline = value
95+
updatedState.canStartPictureInPictureAutomaticallyFromInline = value
9696
}
9797

98-
self.subject.send(currentState)
98+
self.subject.send(updatedState)
9999
}
100100
}
101101

StreamVideoSwiftUITests/Utils/PictureInPicture/PictureInPictureContentProviderTests.swift

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,14 @@ final class PictureInPictureContentProviderTests: XCTestCase, @unchecked Sendabl
154154
}
155155

156156
func test_participantsUpdates_screenSharingInactive_localParticipant_contentUpdates() async throws {
157-
let participant = CallParticipant.dummy(trackSize: .init(width: 1, height: 1))
157+
var participant: CallParticipant!
158158
try await assertContentUpdate {
159+
participant = CallParticipant.dummy(
160+
trackSize: .init(width: 1, height: 1),
161+
sessionId: $0.state.sessionId
162+
)
159163
$0.state.localParticipant = participant
164+
$0.state.participants = [participant]
160165
} validation: {
161166
switch $0 {
162167
case let .participant(_, contentParticipant, _):
@@ -217,12 +222,15 @@ final class PictureInPictureContentProviderTests: XCTestCase, @unchecked Sendabl
217222
func test_preferredContentSizeUpdates_pipIsNotActive_screenSharingInactive_localParticipant_preferredContentSizeUpdates(
218223
) async throws {
219224
let expected = CGSize(width: 1, height: 1)
220-
let participant = CallParticipant.dummy(
221-
hasVideo: true,
222-
trackSize: expected
223-
)
225+
224226
try await assertPreferredContentSizeUpdate(isActive: false, expected: expected) {
227+
let participant = CallParticipant.dummy(
228+
hasVideo: true,
229+
trackSize: expected,
230+
sessionId: $0.state.sessionId
231+
)
225232
$0.state.localParticipant = participant
233+
$0.state.participants = [participant]
226234
}
227235
}
228236

@@ -266,12 +274,14 @@ final class PictureInPictureContentProviderTests: XCTestCase, @unchecked Sendabl
266274
func test_preferredContentSizeUpdates_pipIsActive_screenSharingInactive_localParticipant_preferredContentSizeUpdates(
267275
) async throws {
268276
let expected = CGSize(width: 1, height: 1)
269-
let participant = CallParticipant.dummy(
270-
hasVideo: true,
271-
trackSize: expected
272-
)
273277
try await assertPreferredContentSizeUpdate(isActive: true, expected: expected) {
278+
let participant = CallParticipant.dummy(
279+
hasVideo: true,
280+
trackSize: expected,
281+
sessionId: $0.state.sessionId
282+
)
274283
$0.state.localParticipant = participant
284+
$0.state.participants = [participant]
275285
}
276286
}
277287

@@ -287,6 +297,7 @@ final class PictureInPictureContentProviderTests: XCTestCase, @unchecked Sendabl
287297
// Given
288298
let call: MockCall = MockCall(.dummy(callController: .dummy(videoConfig: Self.videoConfig)))
289299
store.dispatch(.setCall(call))
300+
await fulfilmentInMainActor { self.store.state.call?.cId == call.cId }
290301

291302
_ = await Task { @MainActor in
292303
operation(call)
@@ -308,22 +319,14 @@ final class PictureInPictureContentProviderTests: XCTestCase, @unchecked Sendabl
308319
line: UInt = #line
309320
) async throws {
310321
// Given
311-
store.dispatch(.setActive(isActive))
312322
let call: MockCall = MockCall(.dummy(callController: .dummy(videoConfig: Self.videoConfig)))
313323
store.dispatch(.setCall(call))
324+
await fulfilmentInMainActor { self.store.state.call?.cId == call.cId }
314325

315326
_ = await Task { @MainActor in
316327
operation(call)
317328
}.result
318329

319-
if isActive {
320-
await wait(for: 0.5)
321-
XCTAssertEqual(store.state.preferredContentSize, .init(width: 640, height: 480))
322-
} else {
323-
_ = try await store
324-
.publisher(for: \.preferredContentSize)
325-
.filter { $0 == expected }
326-
.nextValue(timeout: defaultTimeout)
327-
}
330+
await fulfilmentInMainActor { self.store.state.preferredContentSize == expected }
328331
}
329332
}

StreamVideoSwiftUITests/Utils/PictureInPicture/PictureInPictureContentViewTests.swift

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,25 @@ final class PictureInPictureContentViewTests: StreamVideoUITestCase, @unchecked
1515

1616
private lazy var targetSize: CGSize = .init(width: 400, height: 200)
1717

18-
func test_content_inactive() {
18+
func test_content_inactive() async {
1919
AssertSnapshot(
20-
makeSubject(.inactive),
20+
await makeSubject(.inactive),
2121
variants: snapshotVariants,
2222
size: targetSize
2323
)
2424
}
2525

26-
func test_content_participant() {
26+
func test_content_participant() async {
2727
AssertSnapshot(
28-
makeSubject(.participant(MockCall(.dummy()), .dummy(name: "Get Stream"), nil)),
28+
await makeSubject(.participant(MockCall(.dummy()), .dummy(name: "Get Stream"), nil)),
2929
variants: snapshotVariants,
3030
size: targetSize
3131
)
3232
}
3333

34-
func test_content_screenSharing() {
34+
func test_content_screenSharing() async {
3535
AssertSnapshot(
36-
makeSubject(
36+
await makeSubject(
3737
.screenSharing(
3838
MockCall(.dummy()),
3939
.dummy(name: "Get Stream"),
@@ -45,19 +45,22 @@ final class PictureInPictureContentViewTests: StreamVideoUITestCase, @unchecked
4545
)
4646
}
4747

48-
func test_content_reconnecting() {
48+
func test_content_reconnecting() async {
4949
AssertSnapshot(
50-
makeSubject(.reconnecting),
50+
await makeSubject(.reconnecting),
5151
variants: snapshotVariants,
5252
size: targetSize
5353
)
5454
}
5555

5656
// MARK: - Private Helpers
5757

58-
private func makeSubject(_ content: PictureInPictureContent) -> some View {
58+
private func makeSubject(_ content: PictureInPictureContent) async -> some View {
5959
let store = PictureInPictureStore()
6060
store.dispatch(.setContent(content))
61+
await fulfilmentInMainActor {
62+
store.state.content == content
63+
}
6164
return PictureInPictureContentView(store: store)
6265
.frame(width: targetSize.width, height: targetSize.height)
6366
}

StreamVideoSwiftUITests/Utils/PictureInPicture/PictureInPictureStoreTests.swift

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -34,52 +34,52 @@ final class PictureInPictureStoreTests: XCTestCase, @unchecked Sendable {
3434

3535
// MARK: - Action Tests
3636

37-
func test_setActive() {
37+
func test_setActive() async {
3838
// When
3939
subject.dispatch(.setActive(true))
4040

4141
// Then
42-
XCTAssertTrue(subject.state.isActive)
42+
await fulfilmentInMainActor { self.subject.state.isActive }
4343
}
4444

4545
@MainActor
46-
func test_setCall() {
46+
func test_setCall() async {
4747
// Given
4848
let call = MockCall(.dummy())
4949

5050
// When
5151
subject.dispatch(.setCall(call))
5252

5353
// Then
54-
XCTAssertEqual(subject.state.call?.cId, call.cId)
54+
await fulfilmentInMainActor { self.subject.state.call?.cId == call.cId }
5555
}
5656

5757
@MainActor
58-
func test_setSourceView() {
58+
func test_setSourceView() async {
5959
// Given
6060
let view = UIView()
6161

6262
// When
6363
subject.dispatch(.setSourceView(view))
6464

6565
// Then
66-
XCTAssertTrue(subject.state.sourceView === view)
66+
await fulfilmentInMainActor { self.subject.state.sourceView === view }
6767
}
6868

6969
@MainActor
70-
func test_setViewFactory() {
70+
func test_setViewFactory() async {
7171
// Given
7272
let factory = PictureInPictureViewFactory(DefaultViewFactory.shared)
7373

7474
// When
7575
subject.dispatch(.setViewFactory(factory))
7676

7777
// Then
78-
XCTAssertTrue(subject.state.viewFactory === factory)
78+
await fulfilmentInMainActor { self.subject.state.viewFactory === factory }
7979
}
8080

8181
@MainActor
82-
func test_setContent() {
82+
func test_setContent() async {
8383
// Given
8484
let call = MockCall(.dummy())
8585
let participant = CallParticipant.dummy()
@@ -89,37 +89,37 @@ final class PictureInPictureStoreTests: XCTestCase, @unchecked Sendable {
8989
subject.dispatch(.setContent(content))
9090

9191
// Then
92-
XCTAssertEqual(subject.state.content, content)
92+
await fulfilmentInMainActor { self.subject.state.content == content }
9393
}
9494

95-
func test_setPreferredContentSize() {
95+
func test_setPreferredContentSize() async {
9696
// Given
9797
let size = CGSize(width: 800, height: 600)
9898

9999
// When
100100
subject.dispatch(.setPreferredContentSize(size))
101101

102102
// Then
103-
XCTAssertEqual(subject.state.preferredContentSize, size)
103+
await fulfilmentInMainActor { self.subject.state.preferredContentSize == size }
104104
}
105105

106-
func test_setContentSize() {
106+
func test_setContentSize() async {
107107
// Given
108108
let size = CGSize(width: 400, height: 300)
109109

110110
// When
111111
subject.dispatch(.setContentSize(size))
112112

113113
// Then
114-
XCTAssertEqual(subject.state.contentSize, size)
114+
await fulfilmentInMainActor { self.subject.state.contentSize == size }
115115
}
116116

117-
func test_setCanStartPictureInPictureAutomaticallyFromInline() {
117+
func test_setCanStartPictureInPictureAutomaticallyFromInline() async {
118118
// When
119119
subject.dispatch(.setCanStartPictureInPictureAutomaticallyFromInline(false))
120120

121121
// Then
122-
XCTAssertFalse(subject.state.canStartPictureInPictureAutomaticallyFromInline)
122+
await fulfilmentInMainActor { self.subject.state.canStartPictureInPictureAutomaticallyFromInline == false }
123123
}
124124

125125
// MARK: - Publisher Tests
@@ -157,7 +157,7 @@ final class PictureInPictureStoreTests: XCTestCase, @unchecked Sendable {
157157
}
158158

159159
@MainActor
160-
func test_multipleActions() {
160+
func test_multipleActions() async {
161161
// Given
162162
let call = MockCall(.dummy())
163163
let view = UIView()
@@ -170,9 +170,9 @@ final class PictureInPictureStoreTests: XCTestCase, @unchecked Sendable {
170170
subject.dispatch(.setActive(true))
171171

172172
// Then
173-
XCTAssertEqual(subject.state.call?.callId, call.callId)
174-
XCTAssertEqual(subject.state.sourceView, view)
175-
XCTAssertEqual(subject.state.contentSize, size)
176-
XCTAssertTrue(subject.state.isActive)
173+
await fulfilmentInMainActor { self.subject.state.call?.callId == call.callId }
174+
await fulfilmentInMainActor { self.subject.state.sourceView == view }
175+
await fulfilmentInMainActor { self.subject.state.contentSize == size }
176+
await fulfilmentInMainActor { self.subject.state.isActive }
177177
}
178178
}

0 commit comments

Comments
 (0)