@stream-io/video-react-native-sdk 1.29.4 → 1.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/commonjs/components/Call/CallContent/CallContent.js +13 -7
  3. package/dist/commonjs/components/Call/CallContent/CallContent.js.map +1 -1
  4. package/dist/commonjs/components/Call/CallContent/RTCViewPipIOS.js +50 -14
  5. package/dist/commonjs/components/Call/CallContent/RTCViewPipIOS.js.map +1 -1
  6. package/dist/commonjs/components/Call/CallContent/RTCViewPipNative.js +27 -0
  7. package/dist/commonjs/components/Call/CallContent/RTCViewPipNative.js.map +1 -1
  8. package/dist/commonjs/components/Call/CallLayout/CallParticipantsGrid.js +19 -10
  9. package/dist/commonjs/components/Call/CallLayout/CallParticipantsGrid.js.map +1 -1
  10. package/dist/commonjs/components/Call/CallLayout/CallParticipantsSpotlight.js +12 -9
  11. package/dist/commonjs/components/Call/CallLayout/CallParticipantsSpotlight.js.map +1 -1
  12. package/dist/commonjs/components/Call/CallParticipantsList/CallParticipantsList.js +19 -4
  13. package/dist/commonjs/components/Call/CallParticipantsList/CallParticipantsList.js.map +1 -1
  14. package/dist/commonjs/utils/hooks/index.js +0 -11
  15. package/dist/commonjs/utils/hooks/index.js.map +1 -1
  16. package/dist/commonjs/version.js +1 -1
  17. package/dist/module/components/Call/CallContent/CallContent.js +10 -4
  18. package/dist/module/components/Call/CallContent/CallContent.js.map +1 -1
  19. package/dist/module/components/Call/CallContent/RTCViewPipIOS.js +52 -16
  20. package/dist/module/components/Call/CallContent/RTCViewPipIOS.js.map +1 -1
  21. package/dist/module/components/Call/CallContent/RTCViewPipNative.js +27 -0
  22. package/dist/module/components/Call/CallContent/RTCViewPipNative.js.map +1 -1
  23. package/dist/module/components/Call/CallLayout/CallParticipantsGrid.js +19 -10
  24. package/dist/module/components/Call/CallLayout/CallParticipantsGrid.js.map +1 -1
  25. package/dist/module/components/Call/CallLayout/CallParticipantsSpotlight.js +15 -12
  26. package/dist/module/components/Call/CallLayout/CallParticipantsSpotlight.js.map +1 -1
  27. package/dist/module/components/Call/CallParticipantsList/CallParticipantsList.js +20 -5
  28. package/dist/module/components/Call/CallParticipantsList/CallParticipantsList.js.map +1 -1
  29. package/dist/module/utils/hooks/index.js +0 -1
  30. package/dist/module/utils/hooks/index.js.map +1 -1
  31. package/dist/module/version.js +1 -1
  32. package/dist/typescript/components/Call/CallContent/CallContent.d.ts.map +1 -1
  33. package/dist/typescript/components/Call/CallContent/RTCViewPipIOS.d.ts.map +1 -1
  34. package/dist/typescript/components/Call/CallContent/RTCViewPipNative.d.ts +18 -0
  35. package/dist/typescript/components/Call/CallContent/RTCViewPipNative.d.ts.map +1 -1
  36. package/dist/typescript/components/Call/CallLayout/CallParticipantsGrid.d.ts.map +1 -1
  37. package/dist/typescript/components/Call/CallLayout/CallParticipantsSpotlight.d.ts.map +1 -1
  38. package/dist/typescript/components/Call/CallParticipantsList/CallParticipantsList.d.ts.map +1 -1
  39. package/dist/typescript/utils/hooks/index.d.ts +0 -1
  40. package/dist/typescript/utils/hooks/index.d.ts.map +1 -1
  41. package/dist/typescript/version.d.ts +1 -1
  42. package/ios/PictureInPicture/PictureInPictureAvatarView.swift +273 -0
  43. package/ios/PictureInPicture/PictureInPictureConnectionQualityIndicator.swift +162 -0
  44. package/ios/PictureInPicture/PictureInPictureContent.swift +173 -0
  45. package/ios/PictureInPicture/PictureInPictureContentState.swift +123 -0
  46. package/ios/PictureInPicture/PictureInPictureDelegateProxy.swift +89 -0
  47. package/ios/PictureInPicture/PictureInPictureEnforcedStopAdapter.swift +166 -0
  48. package/ios/PictureInPicture/PictureInPictureLogger.swift +16 -0
  49. package/ios/PictureInPicture/PictureInPictureParticipantOverlayView.swift +217 -0
  50. package/ios/PictureInPicture/PictureInPictureReconnectionView.swift +193 -0
  51. package/ios/PictureInPicture/StreamAVPictureInPictureVideoCallViewController.swift +125 -7
  52. package/ios/PictureInPicture/StreamPictureInPictureController.swift +237 -63
  53. package/ios/PictureInPicture/StreamPictureInPictureControllerProtocol.swift +30 -0
  54. package/ios/PictureInPicture/StreamPictureInPictureVideoRenderer.swift +384 -12
  55. package/ios/RTCViewPip.swift +187 -21
  56. package/ios/RTCViewPipManager.mm +9 -0
  57. package/ios/RTCViewPipManager.swift +3 -3
  58. package/package.json +3 -3
  59. package/src/components/Call/CallContent/CallContent.tsx +16 -8
  60. package/src/components/Call/CallContent/RTCViewPipIOS.tsx +81 -15
  61. package/src/components/Call/CallContent/RTCViewPipNative.tsx +36 -0
  62. package/src/components/Call/CallLayout/CallParticipantsGrid.tsx +28 -14
  63. package/src/components/Call/CallLayout/CallParticipantsSpotlight.tsx +19 -10
  64. package/src/components/Call/CallParticipantsList/CallParticipantsList.tsx +20 -5
  65. package/src/utils/hooks/index.ts +0 -1
  66. package/src/version.ts +1 -1
  67. package/dist/commonjs/utils/hooks/useDebouncedValue.js +0 -24
  68. package/dist/commonjs/utils/hooks/useDebouncedValue.js.map +0 -1
  69. package/dist/module/utils/hooks/useDebouncedValue.js +0 -19
  70. package/dist/module/utils/hooks/useDebouncedValue.js.map +0 -1
  71. package/dist/typescript/utils/hooks/useDebouncedValue.d.ts +0 -8
  72. package/dist/typescript/utils/hooks/useDebouncedValue.d.ts.map +0 -1
  73. package/src/utils/hooks/useDebouncedValue.ts +0 -21
@@ -7,8 +7,42 @@ import Foundation
7
7
  import UIKit
8
8
 
9
9
  /// A view that can be used to render an instance of `RTCVideoTrack`
10
+ ///
11
+ /// This view manages the display of different content types in the PiP window:
12
+ /// - Video content from a participant's camera or screen share
13
+ /// - Avatar placeholder when video is disabled
14
+ /// - Screen sharing indicator overlay
15
+ /// - Reconnection view during connection recovery
16
+ ///
17
+ /// The content can be managed either through individual properties (legacy approach)
18
+ /// or through the unified `content` property using `PictureInPictureContent` enum.
10
19
  final class StreamPictureInPictureVideoRenderer: UIView, RTCVideoRenderer {
11
-
20
+
21
+ // MARK: - Content State (New unified approach)
22
+
23
+ /// The current content being displayed, using the unified content enum.
24
+ /// Setting this property automatically updates all overlay views and the video track.
25
+ var content: PictureInPictureContent = .inactive {
26
+ didSet {
27
+ guard content != oldValue else { return }
28
+ applyContent(content)
29
+ }
30
+ }
31
+
32
+ /// The content state manager for reactive state updates.
33
+ /// When set, the renderer subscribes to content changes automatically.
34
+ var contentState: PictureInPictureContentState? {
35
+ didSet {
36
+ subscribeToContentState()
37
+ }
38
+ }
39
+
40
+ /// Cancellable for content state subscription
41
+ private var contentStateCancellable: AnyCancellable?
42
+ private var isApplyingContentBatch = false
43
+
44
+ // MARK: - Individual Properties (Legacy approach - still supported)
45
+
12
46
  /// The rendering track.
13
47
  var track: RTCVideoTrack? {
14
48
  didSet {
@@ -16,11 +50,17 @@ final class StreamPictureInPictureVideoRenderer: UIView, RTCVideoRenderer {
16
50
  // - stopFrameStreaming for the old track
17
51
  // - startFrameStreaming for the new track and only if we are already
18
52
  // in picture-in-picture.
19
- guard oldValue != track else { return }
53
+ PictureInPictureLogger.log("Renderer: track changed from \(oldValue?.trackId ?? "nil") to \(track?.trackId ?? "nil")")
54
+ guard !isSameTrackInstance(oldValue, track) else { return }
55
+ trackSize = .zero
20
56
  prepareForTrackRendering(oldValue)
57
+ if !isApplyingContentBatch {
58
+ // Track changes coming from non-content flows should still refresh overlays immediately.
59
+ updateOverlayVisibility()
60
+ }
21
61
  }
22
62
  }
23
-
63
+
24
64
  /// The layer that renders the track's frames.
25
65
  var displayLayer: CALayer { contentView.layer }
26
66
 
@@ -36,7 +76,90 @@ final class StreamPictureInPictureVideoRenderer: UIView, RTCVideoRenderer {
36
76
  /// A policy defining how the Picture in Picture window should be resized in order to better fit
37
77
  /// the rendering frame size.
38
78
  var pictureInPictureWindowSizePolicy: PictureInPictureWindowSizePolicy
39
-
79
+
80
+ // MARK: - Avatar Placeholder Properties
81
+
82
+ /// The participant's name for the avatar and overlay
83
+ var participantName: String? {
84
+ didSet {
85
+ PictureInPictureLogger.log("Renderer.participantName didSet: '\(participantName ?? "nil")', forwarding to avatarView")
86
+ avatarView.participantName = participantName
87
+ participantOverlayView.participantName = participantName
88
+ }
89
+ }
90
+
91
+ /// The URL string for the participant's profile image
92
+ var participantImageURL: String? {
93
+ didSet {
94
+ avatarView.imageURL = participantImageURL
95
+ }
96
+ }
97
+
98
+ /// Whether video is enabled - when false, shows avatar placeholder
99
+ var isVideoEnabled: Bool = true {
100
+ didSet {
101
+ PictureInPictureLogger.log("Renderer: isVideoEnabled changed from \(oldValue) to \(isVideoEnabled), avatarView.participantName='\(avatarView.participantName ?? "nil")'")
102
+ if !isApplyingContentBatch {
103
+ updateOverlayVisibility()
104
+ }
105
+ }
106
+ }
107
+
108
+ /// Whether the call is reconnecting - when true, shows reconnection view
109
+ var isReconnecting: Bool = false {
110
+ didSet {
111
+ reconnectionView.isReconnecting = isReconnecting
112
+ if !isApplyingContentBatch {
113
+ updateOverlayVisibility()
114
+ }
115
+ }
116
+ }
117
+
118
+ /// Whether screen sharing is active (used for content state tracking)
119
+ var isScreenSharing: Bool = false
120
+
121
+ /// Whether the participant has audio enabled (shown in participant overlay)
122
+ var hasAudio: Bool = true {
123
+ didSet {
124
+ participantOverlayView.hasAudio = hasAudio
125
+ }
126
+ }
127
+
128
+ /// Whether the video track is paused (shown in participant overlay)
129
+ var isTrackPaused: Bool = false {
130
+ didSet {
131
+ participantOverlayView.isTrackPaused = isTrackPaused
132
+ }
133
+ }
134
+
135
+ /// Whether the participant is pinned (shown in participant overlay)
136
+ var isPinned: Bool = false {
137
+ didSet {
138
+ participantOverlayView.isPinned = isPinned
139
+ }
140
+ }
141
+
142
+ /// Whether the participant is currently speaking (shows border highlight)
143
+ var isSpeaking: Bool = false {
144
+ didSet {
145
+ updateSpeakingIndicator()
146
+ }
147
+ }
148
+
149
+ /// The connection quality level (0: unknown, 1: poor, 2: good, 3: excellent)
150
+ var connectionQuality: Int = 0 {
151
+ didSet {
152
+ connectionQualityIndicator.connectionQuality = PictureInPictureConnectionQualityIndicator.ConnectionQuality(rawValue: connectionQuality) ?? .unspecified
153
+ }
154
+ }
155
+
156
+ /// Whether the participant overlay is enabled
157
+ var isParticipantOverlayEnabled: Bool = true {
158
+ didSet {
159
+ participantOverlayView.isOverlayEnabled = isParticipantOverlayEnabled
160
+ }
161
+ }
162
+
40
163
  /// The publisher which is used to streamline the frames received from the track.
41
164
  private let bufferPublisher: PassthroughSubject<CMSampleBuffer, Never> = .init()
42
165
 
@@ -68,7 +191,7 @@ final class StreamPictureInPictureVideoRenderer: UIView, RTCVideoRenderer {
68
191
  didUpdateTrackSize()
69
192
  }
70
193
  }
71
-
194
+
72
195
  /// A property that defines if the RTCVideoFrame instances that will be rendered need to be resized
73
196
  /// to fid the view's contentSize.
74
197
  private var requiresResize = false {
@@ -95,7 +218,59 @@ final class StreamPictureInPictureVideoRenderer: UIView, RTCVideoRenderer {
95
218
 
96
219
  /// A size ratio threshold used to determine if skipping frames is required.
97
220
  private let sizeRatioThreshold: CGFloat = 15
98
-
221
+
222
+ /// The avatar view shown when video is disabled
223
+ /// Note: Uses alpha=0 for visibility instead of isHidden to match upstream SwiftUI behavior
224
+ /// and ensure layoutSubviews is always called for proper constraint layout.
225
+ private lazy var avatarView: PictureInPictureAvatarView = {
226
+ let view = PictureInPictureAvatarView()
227
+ view.translatesAutoresizingMaskIntoConstraints = false
228
+ view.alpha = 0 // Initially invisible (video enabled by default)
229
+ return view
230
+ }()
231
+
232
+ /// The reconnection view shown when connection is being recovered
233
+ private lazy var reconnectionView: PictureInPictureReconnectionView = {
234
+ let view = PictureInPictureReconnectionView()
235
+ view.translatesAutoresizingMaskIntoConstraints = false
236
+ view.isHidden = true // Initially hidden (not reconnecting by default)
237
+ return view
238
+ }()
239
+
240
+
241
+ /// The participant overlay view showing name and mute status
242
+ private lazy var participantOverlayView: PictureInPictureParticipantOverlayView = {
243
+ let view = PictureInPictureParticipantOverlayView()
244
+ view.translatesAutoresizingMaskIntoConstraints = false
245
+ return view
246
+ }()
247
+
248
+ /// Connection quality indicator view (bottom-right)
249
+ private lazy var connectionQualityIndicator: PictureInPictureConnectionQualityIndicator = {
250
+ let view = PictureInPictureConnectionQualityIndicator()
251
+ view.translatesAutoresizingMaskIntoConstraints = false
252
+ return view
253
+ }()
254
+
255
+ /// Speaking indicator border layer
256
+ private lazy var speakingBorderLayer: CAShapeLayer = {
257
+ let layer = CAShapeLayer()
258
+ layer.fillColor = UIColor.clear.cgColor
259
+ layer.strokeColor = UIColor(red: 0.0, green: 0.8, blue: 0.6, alpha: 1.0).cgColor // Teal green
260
+ layer.lineWidth = 2
261
+ layer.isHidden = true
262
+ return layer
263
+ }()
264
+
265
+ /// The speaking indicator corner radius (matches upstream)
266
+ private var speakingCornerRadius: CGFloat {
267
+ if #available(iOS 26.0, *) {
268
+ return 32
269
+ } else {
270
+ return 16
271
+ }
272
+ }
273
+
99
274
  // MARK: - Lifecycle
100
275
 
101
276
  @available(*, unavailable)
@@ -112,15 +287,28 @@ final class StreamPictureInPictureVideoRenderer: UIView, RTCVideoRenderer {
112
287
  // Depending on the window we are moving we either start or stop
113
288
  // streaming frames from the track.
114
289
  if newWindow != nil {
290
+ PictureInPictureLogger.log("Renderer: willMove(toWindow:) - added to window, track=\(track?.trackId ?? "nil"), isVideoEnabled=\(isVideoEnabled)")
291
+ trackSize = .zero
292
+ updateOverlayVisibility()
115
293
  startFrameStreaming(for: track, on: newWindow)
116
294
  } else {
295
+ PictureInPictureLogger.log("Renderer: willMove(toWindow:) - removed from window")
117
296
  stopFrameStreaming(for: track)
297
+ trackSize = .zero
298
+ updateOverlayVisibility()
118
299
  }
119
300
  }
120
301
 
121
302
  override func layoutSubviews() {
122
303
  super.layoutSubviews()
123
304
  contentSize = frame.size
305
+
306
+ // Update speaking border frame
307
+ CATransaction.begin()
308
+ CATransaction.setDisableActions(true)
309
+ speakingBorderLayer.frame = bounds
310
+ speakingBorderLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), cornerRadius: speakingCornerRadius).cgPath
311
+ CATransaction.commit()
124
312
  }
125
313
 
126
314
  // MARK: - Rendering lifecycle
@@ -134,7 +322,12 @@ final class StreamPictureInPictureVideoRenderer: UIView, RTCVideoRenderer {
134
322
  guard let frame = frame else {
135
323
  return
136
324
  }
137
-
325
+
326
+ // Ignore empty frames
327
+ if frame.width <= 0 || frame.height <= 0 {
328
+ return
329
+ }
330
+
138
331
  // Update the trackSize and re-calculate rendering properties if the size
139
332
  // has changed.
140
333
  trackSize = .init(width: Int(frame.width), height: Int(frame.height))
@@ -159,15 +352,96 @@ final class StreamPictureInPictureVideoRenderer: UIView, RTCVideoRenderer {
159
352
 
160
353
  /// Set up the view's hierarchy.
161
354
  private func setUp() {
355
+ // Add speaking border layer first (behind everything else)
356
+ layer.addSublayer(speakingBorderLayer)
357
+
162
358
  addSubview(contentView)
359
+ addSubview(avatarView)
360
+ addSubview(reconnectionView)
361
+ addSubview(participantOverlayView)
362
+ addSubview(connectionQualityIndicator)
363
+
163
364
  NSLayoutConstraint.activate([
164
365
  contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
165
366
  contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
166
367
  contentView.topAnchor.constraint(equalTo: topAnchor),
167
- contentView.bottomAnchor.constraint(equalTo: bottomAnchor)
368
+ contentView.bottomAnchor.constraint(equalTo: bottomAnchor),
369
+
370
+ avatarView.leadingAnchor.constraint(equalTo: leadingAnchor),
371
+ avatarView.trailingAnchor.constraint(equalTo: trailingAnchor),
372
+ avatarView.topAnchor.constraint(equalTo: topAnchor),
373
+ avatarView.bottomAnchor.constraint(equalTo: bottomAnchor),
374
+
375
+ reconnectionView.leadingAnchor.constraint(equalTo: leadingAnchor),
376
+ reconnectionView.trailingAnchor.constraint(equalTo: trailingAnchor),
377
+ reconnectionView.topAnchor.constraint(equalTo: topAnchor),
378
+ reconnectionView.bottomAnchor.constraint(equalTo: bottomAnchor),
379
+
380
+ // Participant overlay positioned at bottom
381
+ participantOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
382
+ participantOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
383
+ participantOverlayView.topAnchor.constraint(equalTo: topAnchor),
384
+ participantOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor),
385
+
386
+ // Connection quality indicator at bottom-right
387
+ connectionQualityIndicator.trailingAnchor.constraint(equalTo: trailingAnchor),
388
+ connectionQualityIndicator.bottomAnchor.constraint(equalTo: bottomAnchor),
389
+ connectionQualityIndicator.widthAnchor.constraint(equalToConstant: 28),
390
+ connectionQualityIndicator.heightAnchor.constraint(equalToConstant: 28)
168
391
  ])
169
392
  }
170
-
393
+
394
+ /// Updates the visibility of overlay views based on current state.
395
+ /// Priority: reconnection view > avatar view > video content
396
+ ///
397
+ /// The avatar view is shown when:
398
+ /// - Video is explicitly disabled (isVideoEnabled = false), OR
399
+ /// - Track is nil
400
+ ///
401
+ /// IMPORTANT: Participant overlay (name, mic, connection quality) is shown on top of BOTH
402
+ /// video AND avatar views, matching the upstream stream-video-swift implementation.
403
+ /// The overlay is only hidden during reconnection.
404
+ private func updateOverlayVisibility() {
405
+ // Reconnection view takes highest priority
406
+ if isReconnecting {
407
+ PictureInPictureLogger.log("updateOverlayVisibility: isReconnecting=true, hiding avatar, showing reconnection")
408
+ reconnectionView.isHidden = false
409
+ avatarView.alpha = 0
410
+ avatarView.isVideoEnabled = true
411
+ // Hide participant overlay ONLY during reconnection (matches upstream)
412
+ participantOverlayView.isOverlayEnabled = false
413
+ } else {
414
+ reconnectionView.isHidden = true
415
+ // Avatar view shows when video is disabled OR when we don't have a track
416
+ let shouldShowVideo = isVideoEnabled && track != nil
417
+ let shouldShowAvatar = !shouldShowVideo
418
+ PictureInPictureLogger.log("updateOverlayVisibility: isVideoEnabled=\(isVideoEnabled), track=\(track?.trackId ?? "nil"), shouldShowAvatar=\(shouldShowAvatar)")
419
+
420
+ // Update avatar visibility - setting isVideoEnabled triggers internal layout
421
+ avatarView.isVideoEnabled = !shouldShowAvatar
422
+ avatarView.alpha = shouldShowAvatar ? 1 : 0
423
+
424
+ // Force layout when avatar becomes visible to ensure proper sizing
425
+ if shouldShowAvatar {
426
+ PictureInPictureLogger.log("updateOverlayVisibility: showing avatar, forcing layout. participantName=\(participantName ?? "nil"), avatarView.participantName='\(avatarView.participantName ?? "nil")'")
427
+ avatarView.setNeedsLayout()
428
+ avatarView.layoutIfNeeded()
429
+ }
430
+
431
+ // Participant overlay shows on BOTH video and avatar (matches upstream)
432
+ // Only hide during reconnection
433
+ participantOverlayView.isOverlayEnabled = true
434
+ }
435
+ }
436
+
437
+ /// Updates the speaking indicator border visibility based on isSpeaking state.
438
+ /// The border is shown when the participant is speaking, on BOTH video and avatar views
439
+ /// (matching upstream behavior). Only hidden during reconnection.
440
+ private func updateSpeakingIndicator() {
441
+ let shouldShowBorder = isSpeaking && !isReconnecting
442
+ speakingBorderLayer.isHidden = !shouldShowBorder
443
+ }
444
+
171
445
  /// A method used to process the frame's buffer and enqueue on the rendering view.
172
446
  private func process(_ buffer: CMSampleBuffer) {
173
447
  guard
@@ -198,14 +472,14 @@ final class StreamPictureInPictureVideoRenderer: UIView, RTCVideoRenderer {
198
472
  on window: UIWindow?
199
473
  ) {
200
474
  guard window != nil, let track else { return }
201
-
475
+
202
476
  bufferUpdatesCancellable = bufferPublisher
203
477
  .receive(on: DispatchQueue.main)
204
478
  .sink { [weak self] in self?.process($0) }
205
-
479
+
206
480
  track.add(self)
207
481
  }
208
-
482
+
209
483
  /// A method that stops the frame consumption from the track. Used automatically when the rendering
210
484
  /// view move's away from the window or when the track changes.
211
485
  private func stopFrameStreaming(for track: RTCVideoTrack?) {
@@ -257,4 +531,102 @@ final class StreamPictureInPictureVideoRenderer: UIView, RTCVideoRenderer {
257
531
  requiresResize = false
258
532
  startFrameStreaming(for: track, on: window)
259
533
  }
534
+
535
+ private func isSameTrackInstance(_ lhs: RTCVideoTrack?, _ rhs: RTCVideoTrack?) -> Bool {
536
+ switch (lhs, rhs) {
537
+ case (nil, nil):
538
+ return true
539
+ case let (lhsTrack?, rhsTrack?):
540
+ return lhsTrack === rhsTrack
541
+ default:
542
+ return false
543
+ }
544
+ }
545
+
546
+ // MARK: - Content State System
547
+
548
+ /// Subscribes to the content state manager for reactive updates.
549
+ private func subscribeToContentState() {
550
+ contentStateCancellable?.cancel()
551
+ contentStateCancellable = nil
552
+
553
+ guard let contentState = contentState else { return }
554
+
555
+ contentStateCancellable = contentState.contentPublisher
556
+ .receive(on: DispatchQueue.main)
557
+ .sink { [weak self] newContent in
558
+ self?.content = newContent
559
+ }
560
+ }
561
+
562
+ /// Applies the given content state to update all view components.
563
+ /// This method synchronizes the unified content enum with the individual properties
564
+ /// for backward compatibility while providing a cleaner API.
565
+ private func applyContent(_ content: PictureInPictureContent) {
566
+ isApplyingContentBatch = true
567
+ defer {
568
+ isApplyingContentBatch = false
569
+ updateOverlayVisibility()
570
+ }
571
+
572
+ switch content {
573
+ case .inactive:
574
+ // Clear everything
575
+ track = nil
576
+ participantName = nil
577
+ participantImageURL = nil
578
+ isVideoEnabled = true
579
+ isReconnecting = false
580
+ isScreenSharing = false
581
+
582
+ case let .video(newTrack, name, imageURL):
583
+ // Show video content
584
+ track = newTrack
585
+ participantName = name
586
+ participantImageURL = imageURL
587
+ isVideoEnabled = true
588
+ isReconnecting = false
589
+ isScreenSharing = false
590
+
591
+ case let .screenSharing(newTrack, name):
592
+ // Show screen sharing content with indicator
593
+ track = newTrack
594
+ participantName = name
595
+ participantImageURL = nil
596
+ isVideoEnabled = true
597
+ isReconnecting = false
598
+ isScreenSharing = true
599
+
600
+ case let .avatar(name, imageURL):
601
+ // Show avatar placeholder (video disabled)
602
+ // Keep existing track for potential quick re-enable
603
+ participantName = name
604
+ participantImageURL = imageURL
605
+ isVideoEnabled = false
606
+ isReconnecting = false
607
+ isScreenSharing = false
608
+
609
+ case .reconnecting:
610
+ // Show reconnection view
611
+ // Keep existing track and participant info for recovery
612
+ isReconnecting = true
613
+ isScreenSharing = false
614
+ }
615
+ }
616
+
617
+ /// Returns the current content as a `PictureInPictureContent` enum value.
618
+ /// This is useful for reading the current state in a unified way.
619
+ func getCurrentContent() -> PictureInPictureContent {
620
+ if isReconnecting {
621
+ return .reconnecting
622
+ } else if !isVideoEnabled {
623
+ return .avatar(participantName: participantName, participantImageURL: participantImageURL)
624
+ } else if isScreenSharing {
625
+ return .screenSharing(track: track, participantName: participantName)
626
+ } else if track != nil {
627
+ return .video(track: track, participantName: participantName, participantImageURL: participantImageURL)
628
+ } else {
629
+ return .avatar(participantName: participantName, participantImageURL: participantImageURL)
630
+ }
631
+ }
260
632
  }