Skip to content

Commit 84188e7

Browse files
authored
chore(ui): Picture in Picture improvements (#986)
* pip improvements * tweaks + changelog * fix * added ui configuration for ios pip * tweak * fixed avatar placeholder
1 parent 4a0e880 commit 84188e7

File tree

7 files changed

+454
-39
lines changed

7 files changed

+454
-39
lines changed

packages/stream_video_flutter/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
✅ Added
44
* Added `handleCallInterruptionCallbacks` method to `RtcMediaDeviceNotifier` that provides an option to handle system audio interruption like incoming calls, or other media playing
5+
* Improved the Picture-in-Picture (PiP) implementation for video calls
6+
* (iOS) Shows participant avatar instead of black screen when video track is disabled.
7+
* (iOS) Added overlay with participant name, microphone indicator and connection qualit indicator.
8+
* (iOS/Android) Added `sort` in `PictureInPictureConfiguration` that enables customization of PiP participant selection.
59

610
🐞 Fixed
711
* Fixed the handling of user blocking event to disconnect the blocked user with a proper reason.

packages/stream_video_flutter/ios/Classes/PictureInPicture/StreamAVPictureInPictureVideoCallViewController.swift

Lines changed: 335 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import AVKit
66
import Foundation
7+
import SwiftUI
78
import 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

Comments
 (0)