mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-18 11:15:36 -05:00
## Summary
Enables audio playback in the native WebRTC video player and adds
mute/unmute controls. The previous implementation had audio disabled via
`useManualAudio = true` to avoid microphone permission requests.
**Key changes:**
- **Audio session configuration**: Uses
`AVAudioSession.Category.playback` for receive-only audio (no microphone
access required)
- **Remote audio track management**: Properly extracts audio track from
peer connection transceivers after remote SDP is set
- **Mute/unmute UI**: Added button at bottom-left of player using SF
Symbols (`speaker.wave.fill` / `speaker.slash.fill`)
- **State synchronization**: ViewModel mute state always reflects actual
audio track state
**Technical details:**
```swift
// Audio session configured for playback only
audioSession.setCategory(AVAudioSession.Category.playback.rawValue)
audioSession.setMode(AVAudioSession.Mode.spokenAudio.rawValue)
// Remote audio track extracted after SDP negotiation
remoteAudioTrack = peerConnection.transceivers
.first(where: { $0.mediaType == .audio })?
.receiver.track as? RTCAudioTrack
```
The implementation removes microphone-related warnings from the
experimental disclaimer.
## Screenshots
N/A - Audio feature, no visual changes beyond the mute button icon which
follows existing control patterns.
## Link to pull request in Documentation repository
Documentation: home-assistant/companion.home-assistant#
## Any other notes
Audio mode uses `.spokenAudio` for optimal voice/camera audio playback.
The mute button follows the same auto-hide behavior as other player
controls (3-second timeout).
<!-- START COPILOT CODING AGENT SUFFIX -->
<!-- START COPILOT ORIGINAL PROMPT -->
<details>
<summary>Original prompt</summary>
> Let's start supporting audio on WebRTCVideoPlayerView, including
control mute/unmute
</details>
<!-- START COPILOT CODING AGENT TIPS -->
---
✨ Let Copilot coding agent [set things up for
you](https://github.com/home-assistant/iOS/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot)
— coding agent works faster and does higher quality work when set up for
your repo.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com>
219 lines
7.4 KiB
Swift
219 lines
7.4 KiB
Swift
import SFSafeSymbols
|
|
import Shared
|
|
import SwiftUI
|
|
import WebRTC
|
|
|
|
struct WebRTCVideoPlayerView: View {
|
|
@Environment(\.dismiss) var dismiss
|
|
|
|
@StateObject private var viewModel: WebRTCViewPlayerViewModel
|
|
|
|
@State private var scale: CGFloat = 1.0
|
|
@State private var lastScale: CGFloat = 1.0
|
|
@State private var offset: CGSize = .zero
|
|
@State private var lastOffset: CGSize = .zero
|
|
|
|
@State private var isPlaying: Bool = false
|
|
@State private var isVideoPlaying: Bool = false
|
|
|
|
private let server: Server
|
|
private let cameraEntityId: String
|
|
|
|
init(server: Server, cameraEntityId: String) {
|
|
self.server = server
|
|
self.cameraEntityId = cameraEntityId
|
|
self._viewModel = .init(wrappedValue: WebRTCViewPlayerViewModel(server: server, cameraEntityId: cameraEntityId))
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
ZStack {
|
|
ZStack(alignment: .topTrailing) {
|
|
player
|
|
controls
|
|
}
|
|
HAProgressView(style: .large)
|
|
.opacity(viewModel.showLoader ? 1.0 : 0.0)
|
|
errorView
|
|
}
|
|
.background(.black)
|
|
.statusBarHidden(true)
|
|
.onAppear {
|
|
viewModel.showControlsTemporarily()
|
|
}
|
|
.onDisappear {
|
|
viewModel.hideControlsWorkItem?.cancel()
|
|
viewModel.hideControlsWorkItem = nil
|
|
}
|
|
.gesture(
|
|
magnificationGesture(geometry: geometry)
|
|
)
|
|
.simultaneousGesture(
|
|
dragGesture(geometry: geometry)
|
|
)
|
|
.gesture(
|
|
tapGesture
|
|
)
|
|
}
|
|
.modify { view in
|
|
if #available(iOS 16.0, *) {
|
|
view.persistentSystemOverlays(.hidden)
|
|
} else {
|
|
view
|
|
}
|
|
}
|
|
}
|
|
|
|
private var errorView: some View {
|
|
VStack {
|
|
Image(systemSymbol: .exclamationmarkTriangle)
|
|
.font(.title)
|
|
.foregroundStyle(.white)
|
|
Text(viewModel.failureReason ?? "")
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(.gray)
|
|
.padding()
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.opacity(viewModel.failureReason != nil ? 1.0 : 0.0)
|
|
.animation(.easeInOut, value: viewModel.failureReason)
|
|
}
|
|
|
|
private func magnificationGesture(geometry: GeometryProxy) -> some Gesture {
|
|
MagnificationGesture()
|
|
.onChanged { value in
|
|
scale = lastScale * value
|
|
viewModel.showControlsTemporarily()
|
|
}
|
|
.onEnded { _ in
|
|
lastScale = scale
|
|
viewModel.showControlsTemporarily()
|
|
if scale <= 1.0 {
|
|
withAnimation {
|
|
offset = .zero
|
|
lastOffset = .zero
|
|
}
|
|
} else {
|
|
withAnimation {
|
|
offset = clampedOffset(for: offset, in: geometry.size)
|
|
lastOffset = offset
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func dragGesture(geometry: GeometryProxy) -> some Gesture {
|
|
DragGesture()
|
|
.onChanged { value in
|
|
guard scale > 1.0 else { return }
|
|
let newOffset = CGSize(
|
|
width: lastOffset.width + value.translation.width,
|
|
height: lastOffset.height + value.translation.height
|
|
)
|
|
offset = clampedOffset(for: newOffset, in: geometry.size)
|
|
viewModel.showControlsTemporarily()
|
|
}
|
|
.onEnded { value in
|
|
// If user is not zoomed in, allow dismissing the view with a swipe down
|
|
guard scale > 1.0 else {
|
|
if value.translation.height > 100 {
|
|
dismiss()
|
|
}
|
|
return
|
|
}
|
|
withAnimation(.spring()) {
|
|
offset = clampedOffset(for: offset, in: geometry.size)
|
|
lastOffset = offset
|
|
}
|
|
viewModel.showControlsTemporarily()
|
|
}
|
|
}
|
|
|
|
private var tapGesture: some Gesture {
|
|
TapGesture(count: 2).onEnded {
|
|
withAnimation {
|
|
if scale > 1.0 {
|
|
scale = 1.0
|
|
lastScale = 1.0
|
|
offset = .zero
|
|
lastOffset = .zero
|
|
} else {
|
|
scale = 2.0
|
|
lastScale = 2.0
|
|
}
|
|
viewModel.showControlsTemporarily()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var player: some View {
|
|
WebRTCVideoPlayerViewControllerWrapper(
|
|
viewModel: viewModel,
|
|
isVideoPlaying: $isVideoPlaying
|
|
)
|
|
.edgesIgnoringSafeArea(.all)
|
|
.scaleEffect(.init(floatLiteral: scale >= 1.0 ? scale : 1.0))
|
|
.offset(offset)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
viewModel.showControlsTemporarily()
|
|
}
|
|
}
|
|
|
|
private var controls: some View {
|
|
WebRTCVideoPlayerViewControls(
|
|
close: { dismiss() },
|
|
isMuted: viewModel.isMuted,
|
|
toggleMute: { viewModel.toggleMute() }
|
|
)
|
|
.transition(.opacity)
|
|
.animation(.easeInOut, value: viewModel.controlsVisible)
|
|
.opacity(viewModel.controlsVisible || !isVideoPlaying ? 1.0 : 0.0)
|
|
}
|
|
|
|
/// Clamps the dragging offset to prevent the zoomed content from being moved
|
|
/// beyond the visible area. This ensures that when the user pans a zoomed-in view,
|
|
/// it stays within the bounds of the container and avoids showing any empty space
|
|
/// around the edges.
|
|
///
|
|
/// - Parameters:
|
|
/// - offset: The proposed offset resulting from the user's drag gesture.
|
|
/// - containerSize: The size of the visible container (i.e., screen or view bounds).
|
|
/// - Returns: A `CGSize` representing the adjusted offset that keeps the content
|
|
/// within valid boundaries.
|
|
private func clampedOffset(for offset: CGSize, in containerSize: CGSize) -> CGSize {
|
|
guard scale > 1.0 else { return .zero }
|
|
let width = containerSize.width
|
|
let height = containerSize.height
|
|
let scaledWidth = width * scale
|
|
let scaledHeight = height * scale
|
|
let maxX = (scaledWidth - width) / 2
|
|
let maxY = (scaledHeight - height) / 2
|
|
let clampedX = min(max(offset.width, -maxX), maxX)
|
|
let clampedY = min(max(offset.height, -maxY), maxY)
|
|
return CGSize(width: clampedX, height: clampedY)
|
|
}
|
|
}
|
|
|
|
struct WebRTCVideoPlayerViewControllerWrapper: UIViewControllerRepresentable {
|
|
private let viewModel: WebRTCViewPlayerViewModel
|
|
@Binding var isVideoPlaying: Bool
|
|
|
|
init(viewModel: WebRTCViewPlayerViewModel, isVideoPlaying: Binding<Bool>) {
|
|
self.viewModel = viewModel
|
|
self._isVideoPlaying = isVideoPlaying
|
|
}
|
|
|
|
func makeUIViewController(context: Context) -> WebRTCVideoPlayerViewController {
|
|
let vc = WebRTCVideoPlayerViewController(viewModel: viewModel)
|
|
vc.onVideoStarted = {
|
|
isVideoPlaying = true
|
|
}
|
|
return vc
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: WebRTCVideoPlayerViewController, context: Context) {
|
|
/* no-op */
|
|
}
|
|
}
|