diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index f03a39640e..e598cbb585 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1,4 +1,4 @@ -"WebRTC_player.experimental.disclaimer" = "Note: Native WebRTC video player is currently an experimental feature, audio may not work and microphone permission and usage may be requested even though not in use. Please use the web player interface for advanced options and reliable playback."; +"WebRTC_player.experimental.disclaimer" = "Note: Native WebRTC video player is currently an experimental feature. Please use the web player interface for advanced options and reliable playback."; "about.acknowledgements.title" = "Acknowledgements"; "about.beta.title" = "Join Beta"; "about.chat.title" = "Chat"; diff --git a/Sources/App/WebView/WebRTC/WebRTCClient.swift b/Sources/App/WebView/WebRTC/WebRTCClient.swift index a096a41bdb..e396cc61f2 100644 --- a/Sources/App/WebView/WebRTC/WebRTCClient.swift +++ b/Sources/App/WebView/WebRTC/WebRTCClient.swift @@ -1,3 +1,4 @@ +import AVFoundation import Foundation import Shared import WebRTC @@ -34,6 +35,7 @@ final class WebRTCClient: NSObject { ] private var videoCapturer: RTCVideoCapturer? private var remoteVideoTrack: RTCVideoTrack? + private var remoteAudioTrack: RTCAudioTrack? private var remoteDataChannel: RTCDataChannel? @available(*, unavailable) @@ -70,10 +72,7 @@ final class WebRTCClient: NSObject { self.peerConnection = peerConnection super.init() createMediaTracks() - - // This is currently disable since the library does not offer a way to disable just the microphone usage. - // TODO: Find a workaround so audio can be receveid without using microphone in parallel - RTCAudioSession.sharedInstance().useManualAudio = true + configureAudioSession() self.peerConnection.delegate = self } @@ -117,7 +116,14 @@ final class WebRTCClient: NSObject { } func set(remoteSdp: RTCSessionDescription, completion: @escaping (Error?) -> Void) { - peerConnection.setRemoteDescription(remoteSdp, completionHandler: completion) + peerConnection.setRemoteDescription(remoteSdp) { [weak self] error in + if let error { + Current.Log.error("Failed to set remote description: \(error.localizedDescription)") + } else { + self?.setRemoteAudioTrack() + } + completion(error) + } } func set(remoteCandidate: RTCIceCandidate, completion: @escaping (Error?) -> Void) { @@ -128,6 +134,35 @@ final class WebRTCClient: NSObject { remoteVideoTrack?.add(renderer) } + func muteAudio() { + remoteAudioTrack?.isEnabled = false + } + + func unmuteAudio() { + remoteAudioTrack?.isEnabled = true + } + + func isAudioMuted() -> Bool { + guard let remoteAudioTrack else { return true } + return !remoteAudioTrack.isEnabled + } + + private func configureAudioSession() { + let audioSession = RTCAudioSession.sharedInstance() + audioSession.lockForConfiguration() + defer { + audioSession.unlockForConfiguration() + } + do { + // Configure for playback only (receive audio without microphone) + try audioSession.setCategory(.playback) + try audioSession.setMode(.spokenAudio) + try audioSession.setActive(true) + } catch { + Current.Log.error("Failed to configure audio session: \(error.localizedDescription)") + } + } + private func createMediaTracks() { let streamId = "stream" let videoTrack = createVideoTrack() @@ -148,6 +183,19 @@ final class WebRTCClient: NSObject { let videoTrack = WebRTCClient.factory.videoTrack(with: videoSource, trackId: "video0") return videoTrack } + + private func setRemoteAudioTrack() { + guard let audioTransceiver = peerConnection.transceivers.first(where: { $0.mediaType == .audio }) else { + Current.Log.warning("No audio transceiver found") + return + } + guard let audioTrack = audioTransceiver.receiver.track as? RTCAudioTrack else { + Current.Log.warning("Remote track is not an RTCAudioTrack") + return + } + remoteAudioTrack = audioTrack + Current.Log.info("Remote audio track set successfully") + } } // MARK: - RTCPeerConnectionDelegate diff --git a/Sources/App/WebView/WebRTC/WebRTCVideoPlayerView.swift b/Sources/App/WebView/WebRTC/WebRTCVideoPlayerView.swift index fbe77784bd..1b8eb614bf 100644 --- a/Sources/App/WebView/WebRTC/WebRTCVideoPlayerView.swift +++ b/Sources/App/WebView/WebRTC/WebRTCVideoPlayerView.swift @@ -161,9 +161,11 @@ struct WebRTCVideoPlayerView: View { } private var controls: some View { - WebRTCVideoPlayerViewControls { - dismiss() - } + WebRTCVideoPlayerViewControls( + close: { dismiss() }, + isMuted: viewModel.isMuted, + toggleMute: { viewModel.toggleMute() } + ) .transition(.opacity) .animation(.easeInOut, value: viewModel.controlsVisible) .opacity(viewModel.controlsVisible || !isVideoPlaying ? 1.0 : 0.0) diff --git a/Sources/App/WebView/WebRTC/WebRTCVideoPlayerViewControls.swift b/Sources/App/WebView/WebRTC/WebRTCVideoPlayerViewControls.swift index 53c3bd587b..e278e10e62 100644 --- a/Sources/App/WebView/WebRTC/WebRTCVideoPlayerViewControls.swift +++ b/Sources/App/WebView/WebRTC/WebRTCVideoPlayerViewControls.swift @@ -1,19 +1,18 @@ +import SFSafeSymbols import Shared import SwiftUI struct WebRTCVideoPlayerViewControls: View { let close: () -> Void + let isMuted: Bool + let toggleMute: () -> Void - // TODO: Include more player controls var body: some View { ZStack { VStack { HStack { Spacer() - ModalCloseButton(tint: .white) { - close() - } - .padding(16) + topButtons } Spacer() Text(L10n.WebRTCPlayer.Experimental.disclaimer) @@ -33,10 +32,37 @@ struct WebRTCVideoPlayerViewControls: View { .opacity(0.5) ) } + + @ViewBuilder + private var topButtons: some View { + Button(action: toggleMute) { + Image(systemSymbol: isMuted ? .speakerSlashFill : .speakerWave3) + .resizable() + .frame(width: 16, height: 16) + .foregroundStyle(.white) + .padding(DesignSystem.Spaces.oneAndHalf) + .modify { view in + if #available(iOS 26.0, *) { + view.glassEffect(.clear.interactive(), in: .circle) + } else { + view + .background(Color.black.opacity(0.5)) + .clipShape(Circle()) + } + } + } + .buttonStyle(.plain) + ModalCloseButton(tint: .white) { + close() + } + .padding(16) + } } #Preview { WebRTCVideoPlayerViewControls( - close: {} + close: {}, + isMuted: false, + toggleMute: {} ) } diff --git a/Sources/App/WebView/WebRTC/WebRTCViewPlayerViewModel.swift b/Sources/App/WebView/WebRTC/WebRTCViewPlayerViewModel.swift index d5d13b5bbf..55516c8f0d 100644 --- a/Sources/App/WebView/WebRTC/WebRTCViewPlayerViewModel.swift +++ b/Sources/App/WebView/WebRTC/WebRTCViewPlayerViewModel.swift @@ -29,6 +29,7 @@ final class WebRTCViewPlayerViewModel: ObservableObject { @Published var failureReason: String? @Published var showLoader: Bool = true @Published var controlsVisible: Bool = true + @Published var isMuted: Bool = true var hideControlsWorkItem: DispatchWorkItem? @@ -49,6 +50,17 @@ final class WebRTCViewPlayerViewModel: ObservableObject { DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: workItem) } + func toggleMute() { + guard let webRTCClient else { return } + if webRTCClient.isAudioMuted() { + webRTCClient.unmuteAudio() + } else { + webRTCClient.muteAudio() + } + // Always get the final state from the client to ensure consistency + isMuted = webRTCClient.isAudioMuted() + } + // MARK: - WebRTC func start() { diff --git a/Sources/Shared/DesignSystem/Components/ModalCloseButton.swift b/Sources/Shared/DesignSystem/Components/ModalCloseButton.swift index f820cbd515..ba4278fa5b 100644 --- a/Sources/Shared/DesignSystem/Components/ModalCloseButton.swift +++ b/Sources/Shared/DesignSystem/Components/ModalCloseButton.swift @@ -22,6 +22,15 @@ public struct ModalCloseButton: View { Image(systemSymbol: .xmark) .resizable() .frame(width: 16, height: 16) + .modify { view in + if #available(iOS 26.0, *) { + view + .padding(DesignSystem.Spaces.oneAndHalf) + .glassEffect(.clear.interactive(), in: .circle) + } else { + view + } + } }) .buttonStyle(.plain) .foregroundStyle(tint) diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index b98f1daa6e..d10b8a33b6 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -69,7 +69,7 @@ public enum L10n { public enum WebRTCPlayer { public enum Experimental { - /// Note: Native WebRTC video player is currently an experimental feature, audio may not work and microphone permission and usage may be requested even though not in use. Please use the web player interface for advanced options and reliable playback. + /// Note: Native WebRTC video player is currently an experimental feature. Please use the web player interface for advanced options and reliable playback. public static var disclaimer: String { return L10n.tr("Localizable", "WebRTC_player.experimental.disclaimer") } } }