44
55import AVKit
66import Foundation
7+ import SwiftUI
78import stream_webrtc_flutter
89
910/// Describes an object that can be used to present picture-in-picture content.
@@ -22,6 +23,19 @@ protocol StreamAVPictureInPictureViewControlling: AnyObject {
2223
2324 /// The layer that renders the incoming frames from WebRTC.
2425 var displayLayer : CALayer { get }
26+
27+ /// Removes the current overlay from the picture-in-picture view
28+ func removeOverlay( )
29+
30+ /// Updates overlay with participant information
31+ func updateParticipantOverlay(
32+ name: String ? , imageUrl: String ? , connectionQuality: String , isMuted: Bool , hasVideo: Bool )
33+
34+ /// Updates overlay with participant information and configuration options
35+ func updateParticipantOverlay(
36+ name: String ? , imageUrl: String ? , connectionQuality: String , isMuted: Bool , hasVideo: Bool ,
37+ showParticipantName: Bool , showMicrophoneIndicator: Bool ,
38+ showConnectionQualityIndicator: Bool )
2539}
2640
2741@available ( iOS 15 . 0 , * )
@@ -35,9 +49,26 @@ final class StreamAVPictureInPictureVideoCallViewController:
3549
3650 var onSizeUpdate : ( ( CGSize ) -> Void ) ?
3751
52+ // Overlay support
53+ private var overlayHostingController : UIHostingController < AnyView > ?
54+
55+ // Track state to avoid unnecessary updates
56+ private var currentName : String ?
57+ private var currentImageUrl : String ?
58+ private var currentHasVideo : Bool ?
59+
3860 var track : RTCVideoTrack ? {
3961 get { contentView. track }
40- set { contentView. track = newValue }
62+ set {
63+ contentView. track = newValue
64+ contentView. isHidden = ( newValue == nil )
65+
66+ // Bring video content to front when new track is set, then overlay on top
67+ view. bringSubviewToFront ( contentView)
68+ if let overlayController = overlayHostingController {
69+ view. bringSubviewToFront ( overlayController. view)
70+ }
71+ }
4172 }
4273
4374 var displayLayer : CALayer { contentView. displayLayer }
@@ -63,6 +94,114 @@ final class StreamAVPictureInPictureVideoCallViewController:
6394 super. viewDidLayoutSubviews ( )
6495 contentView. bounds = view. bounds
6596 onSizeUpdate ? ( contentView. bounds. size)
97+
98+ // Update overlay layout if it exists
99+ if let overlayController = overlayHostingController {
100+ overlayController. view. frame = view. bounds
101+ }
102+ }
103+
104+ // MARK: - Overlay Support
105+
106+ func removeOverlay( ) {
107+ if let overlayController = overlayHostingController {
108+ overlayController. willMove ( toParent: nil )
109+ overlayController. view. removeFromSuperview ( )
110+ overlayController. removeFromParent ( )
111+ overlayHostingController = nil
112+ }
113+ currentName = nil
114+ currentImageUrl = nil
115+ currentHasVideo = nil
116+ }
117+
118+ private func addOverlay< Content: View > ( @ViewBuilder content: ( ) -> Content ) {
119+ let swiftUIView = AnyView ( content ( ) )
120+ let hostingController = UIHostingController ( rootView: swiftUIView)
121+ overlayHostingController = hostingController
122+
123+ addChild ( hostingController)
124+ view. addSubview ( hostingController. view)
125+
126+ hostingController. view. translatesAutoresizingMaskIntoConstraints = false
127+ hostingController. view. backgroundColor = . clear
128+ hostingController. view. isUserInteractionEnabled = false // Allow touch events to pass through
129+
130+ NSLayoutConstraint . activate ( [
131+ hostingController. view. leadingAnchor. constraint ( equalTo: view. leadingAnchor) ,
132+ hostingController. view. trailingAnchor. constraint ( equalTo: view. trailingAnchor) ,
133+ hostingController. view. topAnchor. constraint ( equalTo: view. topAnchor) ,
134+ hostingController. view. bottomAnchor. constraint ( equalTo: view. bottomAnchor) ,
135+ ] )
136+
137+ hostingController. didMove ( toParent: self )
138+ }
139+
140+ func updateParticipantOverlay(
141+ name: String ? , imageUrl: String ? , connectionQuality: String , isMuted: Bool , hasVideo: Bool
142+ ) {
143+ updateParticipantOverlay (
144+ name: name, imageUrl: imageUrl, connectionQuality: connectionQuality,
145+ isMuted: isMuted, hasVideo: hasVideo,
146+ showParticipantName: true , showMicrophoneIndicator: true ,
147+ showConnectionQualityIndicator: true
148+ )
149+ }
150+
151+ func updateParticipantOverlay(
152+ name: String ? , imageUrl: String ? , connectionQuality: String , isMuted: Bool , hasVideo: Bool ,
153+ showParticipantName: Bool , showMicrophoneIndicator: Bool ,
154+ showConnectionQualityIndicator: Bool
155+ ) {
156+ let participantName = name ?? " "
157+
158+ let needsUpdate =
159+ currentName != participantName || currentImageUrl != imageUrl
160+ || currentHasVideo != hasVideo
161+
162+ if needsUpdate || overlayHostingController == nil {
163+ removeOverlay ( )
164+ addOverlay {
165+ PictureInPictureOverlayView (
166+ name: participantName,
167+ imageUrl: imageUrl,
168+ connectionQuality: connectionQuality,
169+ isMuted: isMuted,
170+ hasVideo: hasVideo,
171+ showParticipantName: showParticipantName,
172+ showMicrophoneIndicator: showMicrophoneIndicator,
173+ showConnectionQualityIndicator: showConnectionQualityIndicator
174+ )
175+ }
176+ currentName = participantName
177+ currentImageUrl = imageUrl
178+ currentHasVideo = hasVideo
179+
180+ // Ensure proper layering after overlay update
181+ if hasVideo {
182+ view. bringSubviewToFront ( contentView)
183+ if let overlayController = overlayHostingController {
184+ view. bringSubviewToFront ( overlayController. view)
185+ }
186+ }
187+ } else {
188+ // Just update the overlay's data without recreating the view
189+ if let controller = overlayHostingController {
190+ let updatedView = AnyView (
191+ PictureInPictureOverlayView (
192+ name: participantName,
193+ imageUrl: imageUrl,
194+ connectionQuality: connectionQuality,
195+ isMuted: isMuted,
196+ hasVideo: hasVideo,
197+ showParticipantName: showParticipantName,
198+ showMicrophoneIndicator: showMicrophoneIndicator,
199+ showConnectionQualityIndicator: showConnectionQualityIndicator
200+ )
201+ )
202+ controller. rootView = updatedView
203+ }
204+ }
66205 }
67206
68207 // MARK: - Private helpers
@@ -83,3 +222,198 @@ final class StreamAVPictureInPictureVideoCallViewController:
83222 contentView. bounds = view. bounds
84223 }
85224}
225+
226+ // MARK: - SwiftUI Overlay Views
227+
228+ @available ( iOS 15 . 0 , * )
229+ struct PictureInPictureOverlayView : View {
230+ let name : String
231+ let imageUrl : String ?
232+ let connectionQuality : String
233+ let isMuted : Bool
234+ let hasVideo : Bool
235+ let showParticipantName : Bool
236+ let showMicrophoneIndicator : Bool
237+ let showConnectionQualityIndicator : Bool
238+
239+ var body : some View {
240+ ZStack {
241+ if !hasVideo {
242+ Color . black
243+ . frame ( maxWidth: . infinity, maxHeight: . infinity)
244+ }
245+
246+ if !hasVideo {
247+ if let urlString = imageUrl,
248+ !urlString. isEmpty,
249+ let url = URL ( string: urlString)
250+ {
251+ AsyncImage ( url: url) { image in
252+ image
253+ . resizable ( )
254+ . aspectRatio ( contentMode: . fill)
255+ } placeholder: {
256+ avatarPlaceholder
257+ }
258+ . frame ( width: 80 , height: 80 )
259+ . clipShape ( Circle ( ) )
260+ } else {
261+ avatarPlaceholder
262+ }
263+ }
264+
265+ VStack {
266+ Spacer ( )
267+ HStack {
268+ HStack ( spacing: 4 ) {
269+ if showParticipantName {
270+ Text ( name)
271+ . foregroundColor ( . white)
272+ . multilineTextAlignment ( . leading)
273+ . lineLimit ( 1 )
274+ . font ( Font . caption)
275+ . minimumScaleFactor ( 0.7 )
276+ }
277+
278+ if showMicrophoneIndicator {
279+ SoundIndicator ( isMuted: isMuted)
280+ }
281+ }
282+ . padding ( . horizontal, 4 )
283+ . frame ( height: 28 )
284+ . padding ( 4 )
285+ . background ( Color . black. opacity ( 0.6 ) )
286+ . cornerRadius ( 8 )
287+
288+ Spacer ( )
289+
290+ // Connection quality indicator on the right
291+ if showConnectionQualityIndicator {
292+ ConnectionQualityIndicator ( connectionQuality: connectionQuality)
293+ }
294+ }
295+ }
296+ // .padding(8)
297+ }
298+ }
299+
300+ @ViewBuilder
301+ var avatarPlaceholder : some View {
302+ Circle ( )
303+ . fill ( Color . blue. opacity ( 0.8 ) )
304+ . frame ( width: 80 , height: 80 )
305+ . overlay (
306+ Text ( getInitials ( from: name) )
307+ . foregroundColor ( . white)
308+ . font ( . title2)
309+ . fontWeight ( . medium)
310+ )
311+ }
312+
313+ private func getInitials( from name: String ) -> String {
314+ let words = name. trimmingCharacters ( in: . whitespacesAndNewlines)
315+ . components ( separatedBy: . whitespaces)
316+ . filter { !$0. isEmpty }
317+
318+ if words. isEmpty {
319+ return " N/A "
320+ } else if words. count == 1 {
321+ return String ( words [ 0 ] . prefix ( 2 ) ) . uppercased ( )
322+ } else {
323+ let firstInitial = String ( words. first? . first ?? Character ( " ? " ) )
324+ let lastInitial = String ( words. last? . first ?? Character ( " " ) )
325+ return " \( firstInitial) \( lastInitial) " . uppercased ( )
326+ }
327+ }
328+ }
329+
330+ @available ( iOS 15 . 0 , * )
331+ struct ConnectionQualityIndicator : View {
332+
333+ private var size : CGFloat = 28
334+ private var width : CGFloat = 3
335+
336+ var connectionQuality : String
337+
338+ init ( connectionQuality: String , size: CGFloat = 28 , width: CGFloat = 3 ) {
339+ self . connectionQuality = connectionQuality
340+ self . size = size
341+ self . width = width
342+ }
343+
344+ var body : some View {
345+ HStack ( alignment: . bottom, spacing: 2 ) {
346+ ForEach ( 1 ..< 4 , id: \. self) { index in
347+ IndicatorPart (
348+ width: width,
349+ height: height ( for: index) ,
350+ color: color ( for: index)
351+ )
352+ }
353+ }
354+ . frame ( width: size, height: size)
355+ . padding ( 4 )
356+ . background (
357+ connectionQuality == " unspecified " ? Color . clear : Color . black. opacity ( 0.6 )
358+ )
359+ . cornerRadius ( 8 )
360+ . accessibility ( identifier: " connectionQualityIndicator " )
361+ }
362+
363+ private func color( for index: Int ) -> Color {
364+ switch connectionQuality. lowercased ( ) {
365+ case " excellent " :
366+ return . green
367+ case " good " :
368+ return index == 3 ? Color . white. opacity ( 0.3 ) : . green
369+ case " poor " :
370+ return index == 1 ? . red : Color . white. opacity ( 0.3 )
371+ default : // "unspecified"
372+ return . gray. opacity ( 0.8 )
373+ }
374+ }
375+
376+ private func height( for part: Int ) -> CGFloat {
377+ switch part {
378+ case 1 :
379+ return width * 2
380+ case 2 :
381+ return width * 3
382+ default :
383+ return width * 4
384+ }
385+ }
386+ }
387+
388+ @available ( iOS 15 . 0 , * )
389+ struct IndicatorPart : View {
390+
391+ var width : CGFloat
392+ var height : CGFloat
393+ var color : Color
394+
395+ var body : some View {
396+ RoundedRectangle ( cornerSize: . init( width: 2 , height: 2 ) )
397+ . fill ( color)
398+ . frame ( width: width, height: height)
399+ }
400+ }
401+
402+ public struct SoundIndicator : View {
403+
404+ let isMuted : Bool
405+
406+ public init ( isMuted: Bool ) {
407+ self . isMuted = isMuted
408+ }
409+
410+ public var body : some View {
411+ ( !isMuted ? Image ( systemName: " mic.fill " ) : Image ( systemName: " mic.slash.fill " ) )
412+ . resizable ( )
413+ . aspectRatio ( contentMode: . fit)
414+ . frame ( width: 12 , height: 12 )
415+ . foregroundColor ( !isMuted ? . white : Color ( red: 1.0 , green: 0.216 , blue: 0.259 ) )
416+ . accessibility ( identifier: " participantMic " )
417+ . accessibilityValue ( !isMuted ? " 1 " : " 0 " )
418+ }
419+ }
0 commit comments