diff --git a/Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift b/Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift index 4440e25198..e05c8f6551 100644 --- a/Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift +++ b/Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift @@ -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 @@ -73,13 +75,13 @@ struct CameraPlayerView: View { } } - ToolbarItem(placement: .cancellationAction) { + ToolbarItem(placement: .topBarLeading) { CloseButton { dismiss() } } - ToolbarItem(placement: .principal) { + ToolbarItem(placement: .topBarLeading) { nameBadge } } @@ -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 { @@ -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) } } diff --git a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCClient.swift b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCClient.swift index f3ec68cbf2..da6dba6de2 100644 --- a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCClient.swift +++ b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCClient.swift @@ -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( @@ -283,7 +287,44 @@ final class WebRTCClient: NSObject { ) }() + private static let recordingFactory: RTCPeerConnectionFactory = { + _ = sslInitialized + let videoEncoderFactory = RTCDefaultVideoEncoderFactory() + let videoDecoderFactory = RTCDefaultVideoDecoderFactory() + return RTCPeerConnectionFactory( + encoderFactory: videoEncoderFactory, + decoderFactory: videoDecoderFactory + ) + }() + + 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, @@ -291,6 +332,7 @@ final class WebRTCClient: NSObject { ] private var remoteVideoTrack: RTCVideoTrack? private var remoteAudioTrack: RTCAudioTrack? + private var localAudioTrack: RTCAudioTrack? private var remoteDataChannel: RTCDataChannel? @available(*, unavailable) @@ -298,7 +340,14 @@ final class WebRTCClient: NSObject { 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)] @@ -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 @@ -333,6 +382,8 @@ final class WebRTCClient: NSObject { func closeConnection() { peerConnection.close() + guard supportsTalkback else { return } + WebRTCClient.restorePlaybackAudioSession() } // MARK: Signaling @@ -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 } @@ -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") } } diff --git a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerControlsView.swift b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerControlsView.swift new file mode 100644 index 0000000000..e3fd3b1f90 --- /dev/null +++ b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerControlsView.swift @@ -0,0 +1,147 @@ +import HADesignSystem +import SFSafeSymbols +import Shared +import SwiftUI + +struct WebRTCVideoPlayerControlsView: 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, + 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 diff --git a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerView.swift b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerView.swift index 37a1aadd53..3fe227f731 100644 --- a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerView.swift +++ b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerView.swift @@ -1,7 +1,6 @@ import SFSafeSymbols import Shared import SwiftUI -import WebRTC protocol AppCameraView { var controlsVisible: Binding { get set } @@ -52,44 +51,33 @@ struct WebRTCVideoPlayerView: View, AppCameraView { var body: some View { GeometryReader { geometry in - ZStack { - player - errorView - CameraZoomGestureOverlay( - onPinchBegan: { _ in - previousFrameScale = lastScale - }, - onPinchChanged: { factor, midpoint in - handlePinchChanged(factor: factor, midpoint: midpoint, in: geometry.size) - }, - onPinchEnded: { - handlePinchEnded(in: geometry.size) - }, - onDoubleTap: { location in - handleDoubleTap(at: location, in: geometry.size) - } - ) - } - .background(.black) - .overlay { - // Fade shade behind the bottom controls so the talkback (mic) button keeps contrast - // against a bright camera image. Only shown when the mic button is present. The - // container ignores the safe area so the shade extends under the mic button/home - // indicator rather than stopping above it. - if controlsVisible.wrappedValue, viewModel.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) + WebRTCVideoPlayerControlsView( + controlsVisible: controlsVisible, + isTalkbackSupported: viewModel.isTalkbackSupported, + isTalking: viewModel.isTalking, + isMuted: viewModel.isMuted, + onToggleTalkback: viewModel.toggleTalkback, + onToggleMute: viewModel.toggleMute + ) { + ZStack { + player + errorView + CameraZoomGestureOverlay( + onPinchBegan: { _ in + previousFrameScale = lastScale + }, + onPinchChanged: { factor, midpoint in + handlePinchChanged(factor: factor, midpoint: midpoint, in: geometry.size) + }, + onPinchEnded: { + handlePinchEnded(in: geometry.size) + }, + onDoubleTap: { location in + handleDoubleTap(at: location, in: geometry.size) + } + ) } + .background(.black) } .simultaneousGesture( dragGesture(geometry: geometry) @@ -108,43 +96,6 @@ struct WebRTCVideoPlayerView: View, AppCameraView { self.showLoader.wrappedValue = showLoader } } - .toolbar { - ToolbarItem(placement: .bottomBar) { - if controlsVisible.wrappedValue, viewModel.isTalkbackSupported { - Button(action: { - viewModel.toggleTalkback() - }) { - HStack { - if viewModel.isTalking { - Text(L10n.CameraPlayer.Talkback.stop) - } - Image(systemSymbol: viewModel.isTalking ? .micSlash : .micFill) - } - .transition(.opacity.combined(with: .scale)) - } - .controlSize({ - if #available(iOS 17.0, *) { - return .extraLarge - } else { - return .large - } - }()) - .tint(viewModel.isTalking ? Color.orange : Color.haPrimary) - .accessibilityLabel( - viewModel.isTalking ? L10n.CameraPlayer.Talkback.stop : L10n.CameraPlayer.Talkback.start - ) - } - } - ToolbarItem(placement: .topBarTrailing) { - if controlsVisible.wrappedValue { - Button(action: { - viewModel.toggleMute() - }) { - Image(systemSymbol: viewModel.isMuted ? .speakerSlashFill : .speakerWave3) - } - } - } - } } private var errorView: some View { @@ -277,40 +228,16 @@ struct WebRTCVideoPlayerView: View, AppCameraView { } } -struct WebRTCVideoPlayerViewControllerWrapper: UIViewControllerRepresentable { - private let viewModel: WebRTCViewPlayerViewModel - @Binding var isVideoPlaying: Bool - - init(viewModel: WebRTCViewPlayerViewModel, isVideoPlaying: Binding) { - self.viewModel = viewModel - self._isVideoPlaying = isVideoPlaying - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - func makeUIViewController(context: Context) -> WebRTCVideoPlayerViewController { - let vc = WebRTCVideoPlayerViewController(viewModel: viewModel) - vc.onVideoStarted = { [weak coordinator = context.coordinator] in - coordinator?.videoDidStart() - } - return vc - } - - func updateUIViewController(_ uiViewController: WebRTCVideoPlayerViewController, context: Context) { - /* no-op */ - } - - class Coordinator { - var parent: WebRTCVideoPlayerViewControllerWrapper - - init(parent: WebRTCVideoPlayerViewControllerWrapper) { - self.parent = parent - } - - func videoDidStart() { - parent.isVideoPlaying = true - } +#if DEBUG +#Preview { + NavigationStack { + WebRTCVideoPlayerView( + server: ServerFixture.standard, + cameraEntityId: "camera.front_door", + cameraName: "Front Door", + controlsVisible: .constant(true), + showLoader: .constant(false) + ) } } +#endif diff --git a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewController.swift b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewController.swift index 893afcae59..8c27dadf9d 100644 --- a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewController.swift +++ b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewController.swift @@ -23,10 +23,14 @@ class WebRTCVideoPlayerViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() setupVideoView() - viewModel.start() - if let client = viewModel.webRTCClient { - client.renderRemoteVideo(to: remoteVideoView) + viewModel.onClientReady = { [weak self] in + self?.attachRenderer() } + viewModel.start() + } + + private func attachRenderer() { + viewModel.webRTCClient?.renderRemoteVideo(to: remoteVideoView) } override func viewWillDisappear(_ animated: Bool) { diff --git a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewControllerWrapper.swift b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewControllerWrapper.swift new file mode 100644 index 0000000000..1a039e4acc --- /dev/null +++ b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerViewControllerWrapper.swift @@ -0,0 +1,40 @@ +import SwiftUI +import WebRTC + +struct WebRTCVideoPlayerViewControllerWrapper: UIViewControllerRepresentable { + private let viewModel: WebRTCViewPlayerViewModel + @Binding var isVideoPlaying: Bool + + init(viewModel: WebRTCViewPlayerViewModel, isVideoPlaying: Binding) { + self.viewModel = viewModel + self._isVideoPlaying = isVideoPlaying + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIViewController(context: Context) -> WebRTCVideoPlayerViewController { + let vc = WebRTCVideoPlayerViewController(viewModel: viewModel) + vc.onVideoStarted = { [weak coordinator = context.coordinator] in + coordinator?.videoDidStart() + } + return vc + } + + func updateUIViewController(_ uiViewController: WebRTCVideoPlayerViewController, context: Context) { + /* no-op */ + } + + class Coordinator { + var parent: WebRTCVideoPlayerViewControllerWrapper + + init(parent: WebRTCVideoPlayerViewControllerWrapper) { + self.parent = parent + } + + func videoDidStart() { + parent.isVideoPlaying = true + } + } +} diff --git a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCViewPlayerViewModel.swift b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCViewPlayerViewModel.swift index 1550c51cdb..8a532c451e 100644 --- a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCViewPlayerViewModel.swift +++ b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCViewPlayerViewModel.swift @@ -1,3 +1,4 @@ +import AVFoundation import Foundation import HAKit import HAKit_PromiseKit @@ -5,6 +6,10 @@ import Shared import SwiftUI import WebRTC +private enum CameraEntityFeature { + static let twoWayAudio = 4 +} + enum WebRTCSignalType: String { case session case answer @@ -28,13 +33,16 @@ final class WebRTCViewPlayerViewModel: ObservableObject { private let server: Server private let cameraEntityId: String private let supportsTalkback: Bool + private var statesToken: HACancellable? + + var onClientReady: (() -> Void)? @Published var failureReason: String? @Published var showLoader: Bool = true @Published var isMuted: Bool = true @Published var isWebRTCUnsupported: Bool = false - @Published var isTalkbackSupported: Bool = false @Published var isTalking: Bool = false + @Published var isTalkbackSupported: Bool = false /// Invoked on offer rejection or ICE failure. Used by the notification extension to fall back; /// the SwiftUI player leaves it `nil` and observes the published properties instead. @@ -46,7 +54,9 @@ final class WebRTCViewPlayerViewModel: ObservableObject { self.supportsTalkback = supportsTalkback } - func toggleTalkback() {} + deinit { + statesToken?.cancel() + } func toggleMute() { guard let webRTCClient else { return } @@ -62,13 +72,29 @@ final class WebRTCViewPlayerViewModel: ObservableObject { // MARK: - WebRTC func start() { - webRTCClient = nil - webRTCClient = WebRTCClient(iceServers: AppConstants.WebRTC.iceServers) + guard supportsTalkback else { + connect(withTalkback: false) + return + } + determineTalkbackSupport { [weak self] supported in + self?.connect(withTalkback: supported) + } + } + + private func connect(withTalkback: Bool) { + webRTCClient?.closeConnection() + sessionId = nil + pendingCandidates.removeAll() + webRTCClient = WebRTCClient( + iceServers: AppConstants.WebRTC.iceServers, + supportsTalkback: withTalkback + ) guard let webRTCClient else { assertionFailure("WebRTCClient initialization failed") return } webRTCClient.delegate = self + onClientReady?() webRTCClient.offer { [weak self] sdp in guard let self else { assertionFailure("Self is nil in WebRTCViewPlayerViewModel.start") @@ -98,26 +124,29 @@ final class WebRTCViewPlayerViewModel: ObservableObject { self?.onFailure?() } } handler: { [weak self] _, data in - guard let self else { return } - guard let typeString: String = try? data.decode("type") else { - assertionFailure("Failed to decode type from data") - return - } - let type = WebRTCSignalType(typeString) - switch type { - case .session: - handleSession(data) - case .answer: - handleAnswer(data) - case .candidate: - handleCandidate(data) - case .unknown: - debugPrint("Unknown type: \(typeString)") - } + self?.handleSignal(data) } } } + private func handleSignal(_ data: HAData) { + guard let typeString: String = try? data.decode("type") else { + assertionFailure("Failed to decode type from data") + return + } + let type = WebRTCSignalType(typeString) + switch type { + case .session: + handleSession(data) + case .answer: + handleAnswer(data) + case .candidate: + handleCandidate(data) + case .unknown: + debugPrint("Unknown type: \(typeString)") + } + } + private func handleSession(_ data: HAData) { guard let sessionId: String = try? data.decode("session_id") else { assertionFailure("Failed to decode session_id from data") @@ -194,6 +223,79 @@ final class WebRTCViewPlayerViewModel: ObservableObject { } } } + + // MARK: - Talkback + + func toggleTalkback() { + if isTalking { + stopTalkback() + } else { + startTalkback() + } + } + + private func startTalkback() { + Task { @MainActor [weak self] in + guard let self else { return } + let granted = await self.requestMicrophonePermission() + guard granted else { + self.failureReason = L10n.CameraPlayer.Talkback.microphoneDenied + return + } + self.webRTCClient?.setMicrophoneEnabled(true) + self.isTalking = true + } + } + + private func stopTalkback() { + webRTCClient?.setMicrophoneEnabled(false) + isTalking = false + } + + private func requestMicrophonePermission() async -> Bool { + await withCheckedContinuation { continuation in + if #available(iOS 17.0, *) { + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } else { + AVAudioSession.sharedInstance().requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + } + } + + private func determineTalkbackSupport(completion: @escaping (Bool) -> Void) { + guard let api = Current.api(for: server) else { + completion(false) + return + } + var finished = false + let finish: (Bool) -> Void = { [weak self] supported in + Task { @MainActor [weak self] in + guard !finished else { return } + finished = true + self?.statesToken?.cancel() + self?.statesToken = nil + self?.isTalkbackSupported = supported + completion(supported) + } + } + statesToken = api.connection.caches.states().subscribe { [weak self] token, states in + guard let self else { + token.cancel() + return + } + guard let entity = states[cameraEntityId] else { return } + let features = (entity.attributes["supported_features"] as? Int) ?? 0 + finish((features & CameraEntityFeature.twoWayAudio) != 0) + } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 3_000_000_000) + finish(false) + } + } } extension WebRTCViewPlayerViewModel: WebRTCClientDelegate { diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 30fb65b8f9..768e61970b 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -198,6 +198,7 @@ "camera_player.errors.unable_to_connect_to_server" = "Unable to connect to Home Assistant"; "camera_player.errors.unknown" = "Unknown error"; "camera_player.notification.body" = "Tap to open camera"; +"camera_player.talkback.microphone_denied" = "Microphone access is required to talk through this camera."; "camera_player.talkback.start" = "Start talking"; "camera_player.talkback.stop" = "Stop talking"; "cameras.no_server_found" = "No server found for camera: %@"; diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 392cc334de..85355b2874 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -816,6 +816,8 @@ public enum L10n { public static var body: String { return L10n.tr("Localizable", "camera_player.notification.body") } } public enum Talkback { + /// Microphone access is required to talk through this camera. + public static var microphoneDenied: String { return L10n.tr("Localizable", "camera_player.talkback.microphone_denied") } /// Start talking public static var start: String { return L10n.tr("Localizable", "camera_player.talkback.start") } /// Stop talking