Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 7 additions & 14 deletions Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ struct CameraPlayerView: View {
@State private var controlsVisible = true
@State private var showLoader = true

private let maxTitleTextWidth: CGFloat = 100

enum PlayerType {
case webRTC
case hls
Expand Down Expand Up @@ -73,13 +75,13 @@ struct CameraPlayerView: View {
}
}

ToolbarItem(placement: .cancellationAction) {
ToolbarItem(placement: .topBarLeading) {
CloseButton {
dismiss()
}
}

ToolbarItem(placement: .principal) {
ToolbarItem(placement: .topBarLeading) {
nameBadge
}
}
Expand Down Expand Up @@ -121,11 +123,14 @@ struct CameraPlayerView: View {
Text(name)
.font(DesignSystem.Font.caption.bold())
.foregroundStyle(.primary)
.frame(maxWidth: maxTitleTextWidth, alignment: .leading)
.truncationMode(.middle)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(DesignSystem.Font.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: maxTitleTextWidth, alignment: .leading)
.truncationMode(.middle)
}
}
if cameras.count > 1 {
Expand All @@ -136,21 +141,9 @@ struct CameraPlayerView: View {
}
.padding(.horizontal, DesignSystem.Spaces.two)
.padding(.vertical, DesignSystem.Spaces.one)
.modify { view in
if #available(iOS 26.0, *) {
view
.glassEffect(.regular.interactive(), in: .capsule)
.contentShape(Capsule())
} else {
view
.background(.regularMaterial)
.clipShape(.capsule)
}
}
}
.menuOrder(.fixed)
.disabled(cameras.count <= 1)
.frame(maxWidth: .infinity, alignment: .leading)
}
}

Expand Down
75 changes: 68 additions & 7 deletions Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,14 @@ final class PlaybackOnlyRTCAudioDevice: NSObject, RTCAudioDevice {
final class WebRTCClient: NSObject {
private static let playbackOnlyAudioDevice = PlaybackOnlyRTCAudioDevice()

private static let sslInitialized: Void = {
RTCInitializeSSL()
}()

// The `RTCPeerConnectionFactory` is in charge of creating new RTCPeerConnection instances.
// A new RTCPeerConnection should be created every new call, but the factory is shared.
private static let factory: RTCPeerConnectionFactory = {
RTCInitializeSSL()
private static let playbackFactory: RTCPeerConnectionFactory = {
_ = sslInitialized
let videoEncoderFactory = RTCDefaultVideoEncoderFactory()
let videoDecoderFactory = RTCDefaultVideoDecoderFactory()
return RTCPeerConnectionFactory(
Expand All @@ -283,22 +287,67 @@ final class WebRTCClient: NSObject {
)
}()

private static let recordingFactory: RTCPeerConnectionFactory = {
_ = sslInitialized
let videoEncoderFactory = RTCDefaultVideoEncoderFactory()
let videoDecoderFactory = RTCDefaultVideoDecoderFactory()
return RTCPeerConnectionFactory(
encoderFactory: videoEncoderFactory,
decoderFactory: videoDecoderFactory
)
}()
Comment thread
bgoncal marked this conversation as resolved.

private static func configureRecordingAudioSession() {
let configuration = RTCAudioSessionConfiguration.webRTC()
configuration.category = AVAudioSession.Category.playAndRecord.rawValue
configuration.mode = AVAudioSession.Mode.videoChat.rawValue
configuration.categoryOptions = [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP]
RTCAudioSessionConfiguration.setWebRTC(configuration)
}

private static func restorePlaybackAudioSession() {
let configuration = RTCAudioSessionConfiguration.webRTC()
configuration.category = AVAudioSession.Category.playback.rawValue
configuration.mode = AVAudioSession.Mode.moviePlayback.rawValue
configuration.categoryOptions = [.mixWithOthers]
RTCAudioSessionConfiguration.setWebRTC(configuration)

let session = RTCAudioSession.sharedInstance()
session.lockForConfiguration()
defer { session.unlockForConfiguration() }
do {
try session.setActive(false)
} catch {
Current.Log.error("Failed to release talkback audio session on close: \(error.localizedDescription)")
}
}

weak var delegate: WebRTCClientDelegate?
private let factory: RTCPeerConnectionFactory
private let supportsTalkback: Bool
private let peerConnection: RTCPeerConnection
private let mediaConstrains = [
kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue,
kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue,
]
private var remoteVideoTrack: RTCVideoTrack?
private var remoteAudioTrack: RTCAudioTrack?
private var localAudioTrack: RTCAudioTrack?
private var remoteDataChannel: RTCDataChannel?

@available(*, unavailable)
override init() {
fatalError("WebRTCClient:init is unavailable")
}

required init(iceServers: [String]) {
required init(iceServers: [String], supportsTalkback: Bool = false) {
self.supportsTalkback = supportsTalkback
let factory = supportsTalkback ? WebRTCClient.recordingFactory : WebRTCClient.playbackFactory
self.factory = factory
if supportsTalkback {
WebRTCClient.configureRecordingAudioSession()
}

let config = RTCConfiguration()
config.iceServers = [RTCIceServer(urlStrings: iceServers)]

Expand All @@ -316,7 +365,7 @@ final class WebRTCClient: NSObject {
optionalConstraints: ["DtlsSrtpKeyAgreement": kRTCMediaConstraintsValueTrue]
)

guard let peerConnection = WebRTCClient.factory.peerConnection(
guard let peerConnection = factory.peerConnection(
with: config,
constraints: constraints,
delegate: nil
Expand All @@ -333,6 +382,8 @@ final class WebRTCClient: NSObject {

func closeConnection() {
peerConnection.close()
guard supportsTalkback else { return }
WebRTCClient.restorePlaybackAudioSession()
}

// MARK: Signaling
Expand Down Expand Up @@ -401,20 +452,29 @@ final class WebRTCClient: NSObject {
return !remoteAudioTrack.isEnabled
}

func setMicrophoneEnabled(_ enabled: Bool) {
localAudioTrack?.isEnabled = enabled
}

private func createMediaTracks() {
let streamId = "stream"
let videoTrack = createVideoTrack()
peerConnection.add(videoTrack, streamIds: [streamId])
remoteVideoTrack = peerConnection.transceivers.first { $0.mediaType == .video }?.receiver
.track as? RTCVideoTrack
guard supportsTalkback else { return }
let audioTrack = factory.audioTrack(with: factory.audioSource(with: nil), trackId: "audio0")
audioTrack.isEnabled = false
localAudioTrack = audioTrack
peerConnection.add(audioTrack, streamIds: [streamId])
}

private func createVideoTrack() -> RTCVideoTrack {
// The local track only exists to establish the transceiver we read the remote track from;
// we never send video, so there's no capturer. RTCCameraVideoCapturer is also unavailable in
// app extensions, which is why a capturer here broke the device build of the notification ext.
let videoSource = WebRTCClient.factory.videoSource()
let videoTrack = WebRTCClient.factory.videoTrack(with: videoSource, trackId: "video0")
let videoSource = factory.videoSource()
let videoTrack = factory.videoTrack(with: videoSource, trackId: "video0")
return videoTrack
}

Expand All @@ -427,8 +487,9 @@ final class WebRTCClient: NSObject {
Current.Log.warning("Remote track is not an RTCAudioTrack")
return
}
let wasMuted = remoteAudioTrack.map { !$0.isEnabled } ?? true
remoteAudioTrack = audioTrack
remoteAudioTrack?.isEnabled = false
remoteAudioTrack?.isEnabled = !wasMuted
Current.Log.info("Remote audio track set successfully")
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import HADesignSystem
import SFSafeSymbols
import Shared
import SwiftUI

struct WebRTCVideoPlayerControlsView<Content: View>: View {
@Binding private var controlsVisible: Bool

private let isTalkbackSupported: Bool
private let isTalking: Bool
private let isMuted: Bool
private let onToggleTalkback: () -> Void
private let onToggleMute: () -> Void
private let content: Content

init(
controlsVisible: Binding<Bool>,
isTalkbackSupported: Bool,
isTalking: Bool,
isMuted: Bool,
onToggleTalkback: @escaping () -> Void,
onToggleMute: @escaping () -> Void,
@ViewBuilder content: () -> Content
) {
self._controlsVisible = controlsVisible
self.isTalkbackSupported = isTalkbackSupported
self.isTalking = isTalking
self.isMuted = isMuted
self.onToggleTalkback = onToggleTalkback
self.onToggleMute = onToggleMute
self.content = content()
}

var body: some View {
content
.overlay {
if controlsVisible, isTalkbackSupported {
VStack(spacing: 0) {
Spacer(minLength: 0)
LinearGradient(
colors: [.clear, .black.opacity(0.6)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 180)
}
.allowsHitTesting(false)
.ignoresSafeArea()
.transition(.opacity)
}
}
.toolbar {
talkbackToolbarItem
muteToolbarItem
}
}

@ToolbarContentBuilder
private var talkbackToolbarItem: some ToolbarContent {
ToolbarItem(placement: .bottomBar) {
if controlsVisible, isTalkbackSupported {
Button(action: onToggleTalkback) {
HStack {
if isTalking {
Text(L10n.CameraPlayer.Talkback.stop)
.font(.headline)
.padding(.trailing, DesignSystem.Spaces.one)
}
Image(systemSymbol: isTalking ? .micSlash : .micFill)
.font(DesignSystem.Font.title3)
}
.padding(.vertical, DesignSystem.Spaces.four)
.padding(.horizontal, isTalking ? DesignSystem.Spaces.one : DesignSystem.Spaces.four)
.transition(.move(edge: .trailing).combined(with: .scale))
}
.modify({ view in
if #available(iOS 26.0, *) {
view
.buttonStyle(.glassProminent)
} else {
view
}
})
.tint(isTalking ? Color.orange : Color.haPrimary)
.contentShape(.capsule)
.accessibilityLabel(
isTalking ? L10n.CameraPlayer.Talkback.stop : L10n.CameraPlayer.Talkback.start
)
}
}
}

@ToolbarContentBuilder
private var muteToolbarItem: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
if controlsVisible {
Button(action: onToggleMute) {
Image(systemSymbol: isMuted ? .speakerSlashFill : .speakerWave3)
}
}
}
}
}

#if DEBUG
#Preview("Mic standby") {
NavigationStack {
WebRTCVideoPlayerControlsView(
controlsVisible: .constant(true),
isTalkbackSupported: true,
isTalking: false,
isMuted: false,
onToggleTalkback: {},
onToggleMute: {}
) {
Rectangle()
.fill(.black)
.overlay {
Text("Camera preview")
.foregroundStyle(.white)
}
.ignoresSafeArea()
}
}
}

#Preview("Mic on") {
NavigationStack {
WebRTCVideoPlayerControlsView(
controlsVisible: .constant(true),
isTalkbackSupported: true,
isTalking: true,
isMuted: false,
onToggleTalkback: {},
onToggleMute: {}
) {
Rectangle()
.fill(.black)
.overlay {
Text("Camera preview")
.foregroundStyle(.white)
}
.ignoresSafeArea()
}
}
}
#endif
Loading