Skip to content
Closed
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
20 changes: 20 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,9 @@
42A47A872C452D5400C9B43D /* WebViewExternalMessageHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A47A862C452D5400C9B43D /* WebViewExternalMessageHandlerTests.swift */; };
42A47A8A2C452DB500C9B43D /* MockWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A47A892C452DB500C9B43D /* MockWebViewController.swift */; };
42A47A902C4548E100C9B43D /* ImprovDiscoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A47A8F2C4548E100C9B43D /* ImprovDiscoverView.swift */; };
42A72D312FFF12600040C852 /* CamerasDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A72D302FFF12600040C852 /* CamerasDebugView.swift */; };
42A72D332FFF12670040C852 /* CamerasDebugServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A72D322FFF12670040C852 /* CamerasDebugServerView.swift */; };
42A72D352FFF158A0040C852 /* ZoomableCameraContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A72D342FFF158A0040C852 /* ZoomableCameraContainer.swift */; };
42A7F1012FBC000100BEEF01 /* Database/AllowedTag.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A7F1002FBC000100BEEF01 /* Database/AllowedTag.test.swift */; };
42A7F1212FBC000100BEEF01 /* NFCTagApproval.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A7F1202FBC000100BEEF01 /* NFCTagApproval.test.swift */; };
42A818E02BBEA8150083D045 /* AssistViewModel.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A818DF2BBEA8150083D045 /* AssistViewModel.test.swift */; };
Expand Down Expand Up @@ -2755,6 +2758,9 @@
42A47A892C452DB500C9B43D /* MockWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockWebViewController.swift; sourceTree = "<group>"; };
42A47A8F2C4548E100C9B43D /* ImprovDiscoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImprovDiscoverView.swift; sourceTree = "<group>"; };
42A47D4A2C9AEF10003C597D /* DataWidgetsUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataWidgetsUpdater.swift; sourceTree = "<group>"; };
42A72D302FFF12600040C852 /* CamerasDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CamerasDebugView.swift; sourceTree = "<group>"; };
42A72D322FFF12670040C852 /* CamerasDebugServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CamerasDebugServerView.swift; sourceTree = "<group>"; };
42A72D342FFF158A0040C852 /* ZoomableCameraContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableCameraContainer.swift; sourceTree = "<group>"; };
42A746942E832FB8005E0332 /* bug_report_iOS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = bug_report_iOS.md; sourceTree = "<group>"; };
42A746952E832FB8005E0332 /* bug_report_mac.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = bug_report_mac.md; sourceTree = "<group>"; };
42A746962E832FB8005E0332 /* config.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = config.yml; sourceTree = "<group>"; };
Expand Down Expand Up @@ -5198,6 +5204,7 @@
4210CCEA2F152AD000B71FB9 /* CameraPlayerView.swift */,
4210CCEE2F152ED800B71FB9 /* CameraStreamHLSView.swift */,
4210CCF22F1532C400B71FB9 /* CameraMJPEGPlayerView.swift */,
42A72D342FFF158A0040C852 /* ZoomableCameraContainer.swift */,
);
path = CameraPlayer;
sourceTree = "<group>";
Expand Down Expand Up @@ -5643,6 +5650,15 @@
path = Improv;
sourceTree = "<group>";
};
42A72D2F2FFF12600040C852 /* CamerasDebug */ = {
isa = PBXGroup;
children = (
42A72D302FFF12600040C852 /* CamerasDebugView.swift */,
42A72D322FFF12670040C852 /* CamerasDebugServerView.swift */,
);
path = CamerasDebug;
sourceTree = "<group>";
};
42A746972E832FB8005E0332 /* ISSUE_TEMPLATE */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -7087,6 +7103,7 @@
D75BA669B0E8C771B69ABE41 /* Components */,
42886D222FFE701C004EB33B /* AcknowledgementsView.swift */,
42886D262FFE701C004EB33B /* AcknowledgementPackage.swift */,
42A72D2F2FFF12600040C852 /* CamerasDebug */,
);
path = Settings;
sourceTree = "<group>";
Expand Down Expand Up @@ -9184,6 +9201,7 @@
42266B252B7A4BA900E94A71 /* BarcodeScannerViewModel.swift in Sources */,
42E00D132E1E7807006D140D /* NotificationPermissionRequestView.swift in Sources */,
42FCCFFB2B9B1C310057783F /* ThreadTransferCredentialToHAViewModel.swift in Sources */,
42A72D332FFF12670040C852 /* CamerasDebugServerView.swift in Sources */,
4210CD102F16906800B71FB9 /* EntityRowView.swift in Sources */,
42B980D32DC24E1300BC5C08 /* SensorDetailLabelRowView.swift in Sources */,
42ABB0BB2C888BB10081461D /* CarPlayConfigurationViewModel.swift in Sources */,
Expand Down Expand Up @@ -9449,6 +9467,7 @@
429481ED2DA943E700A8B468 /* ListPicker.swift in Sources */,
42F1DA5D2B4BF85F002729BC /* WindowScenesManager.swift in Sources */,
42FF5E942E22D83800BDF5EF /* WidgetTodoListProvider.swift in Sources */,
42A72D352FFF158A0040C852 /* ZoomableCameraContainer.swift in Sources */,
42991E5E2FF3EB7400C2C76B /* MacWebViewTitleBar.swift in Sources */,
JARVIS0001000000000003 /* TodoListAppEntity.swift in Sources */,
JARVIS0002000000000003 /* TodoListRefreshAppIntent.swift in Sources */,
Expand Down Expand Up @@ -9512,6 +9531,7 @@
A596C4D1E125E6863C7D2034 /* ComplicationEditViewModel.swift in Sources */,
F0D1DD41A8F55F6D767EBF37 /* TemplatePreviewSection.swift in Sources */,
E3A02409794174F002C8BB4F /* IconSearchPicker.swift in Sources */,
42A72D312FFF12600040C852 /* CamerasDebugView.swift in Sources */,
DB54626ADCE0C32094C8C0B9 /* LoadingButton.swift in Sources */,
9D57ECBD5431BC00BDC16F1E /* NotificationActionEditorView.swift in Sources */,
1E9DE26E93988560ACF01A4A /* NotificationCategoryEditorView.swift in Sources */,
Expand Down
18 changes: 11 additions & 7 deletions Sources/App/Cameras/CameraPlayer/CameraMJPEGPlayerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ struct CameraMJPEGPlayerView: View {
Color.black.edgesIgnoringSafeArea(.all)

if errorMessage == nil {
MJPEGStreamContainerView(
server: server,
cameraEntityId: cameraEntityId,
isLoading: $isLoading,
errorMessage: $errorMessage
)
.ignoresSafeArea()
ZoomableCameraContainer(onSwipeDown: {
dismiss()
}, content: {
MJPEGStreamContainerView(
server: server,
cameraEntityId: cameraEntityId,
isLoading: $isLoading,
errorMessage: $errorMessage
)
.ignoresSafeArea()
})
}

if isLoading {
Expand Down
29 changes: 28 additions & 1 deletion Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct CameraPlayerView: View {
private let server: Server
private let cameraEntityId: String
private let cameraName: String?
private let showsDebugOverlay: Bool

@State private var playerType: PlayerType = .webRTC
@State private var appEntity: HAAppEntity?
Expand All @@ -21,12 +22,21 @@ struct CameraPlayerView: View {
case webRTC
case hls
case mjpeg

var debugTitle: String {
switch self {
case .webRTC: return "WebRTC"
case .hls: return "HLS"
case .mjpeg: return "MJPEG"
}
}
}

init(server: Server, cameraEntityId: String, cameraName: String? = nil) {
init(server: Server, cameraEntityId: String, cameraName: String? = nil, showsDebugOverlay: Bool = false) {
self.server = server
self.cameraEntityId = cameraEntityId
self.cameraName = cameraName
self.showsDebugOverlay = showsDebugOverlay
}

var body: some View {
Expand Down Expand Up @@ -68,6 +78,9 @@ struct CameraPlayerView: View {
HAProgressView(style: .extraLarge)
}
}
.overlay(alignment: .bottomLeading) {
playerTypeBadge
}
.onAppear {
appEntity = HAAppEntity.entity(id: cameraEntityId, serverId: server.identifier.rawValue)
name = appEntity?.name ?? cameraName
Expand All @@ -76,6 +89,20 @@ struct CameraPlayerView: View {
.persistentSystemOverlays(.hidden)
}

@ViewBuilder
private var playerTypeBadge: some View {
if showsDebugOverlay {
Text(verbatim: playerType.debugTitle)
.font(.caption.monospaced())
.padding(.horizontal, DesignSystem.Spaces.one)
.padding(.vertical, DesignSystem.Spaces.half)
.background(.regularMaterial)
.clipShape(.capsule)
.padding(.leading, DesignSystem.Spaces.two)
.padding(.bottom, DesignSystem.Spaces.two)
}
}

@ViewBuilder
private var nameBadge: some View {
if let name, controlsVisible {
Expand Down
8 changes: 6 additions & 2 deletions Sources/App/Cameras/CameraPlayer/CameraStreamHLSView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@ struct CameraStreamHLSView: View {
Color.black.edgesIgnoringSafeArea(.all)

if let player {
VideoPlayer(player: player)
.edgesIgnoringSafeArea(.all)
ZoomableCameraContainer(onSwipeDown: {
dismiss()
}, content: {
VideoPlayer(player: player)
.edgesIgnoringSafeArea(.all)
})
}

if isLoading {
Expand Down
186 changes: 30 additions & 156 deletions Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,6 @@ struct WebRTCVideoPlayerView: View, AppCameraView {

@StateObject private var viewModel: WebRTCViewPlayerViewModel

@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
@State private var previousFrameScale: 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
var controlsVisible: Binding<Bool>
Expand Down Expand Up @@ -48,47 +42,38 @@ 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)
.onAppear {
showControlsTemporarily()
}
.onDisappear {
hideControlsWorkItem?.cancel()
hideControlsWorkItem = nil
}
.simultaneousGesture(
dragGesture(geometry: geometry)
)
.onTapGesture {
showControlsTemporarily()
}
.onChange(of: viewModel.isWebRTCUnsupported) { isUnsupported in
if isUnsupported {
onWebRTCUnsupported?()
ZStack {
ZoomableCameraContainer(
onInteraction: {
showControlsTemporarily()
},
onSwipeDown: {
dismiss()
},
content: {
player
}
)
errorView
}
.background(.black)
.onAppear {
showControlsTemporarily()
}
.onDisappear {
hideControlsWorkItem?.cancel()
hideControlsWorkItem = nil
}
.onTapGesture {
showControlsTemporarily()
}
.onChange(of: viewModel.isWebRTCUnsupported) { isUnsupported in
if isUnsupported {
onWebRTCUnsupported?()
}
.onChange(of: viewModel.showLoader) { showLoader in
self.showLoader.wrappedValue = showLoader
}
}
.onChange(of: viewModel.showLoader) { showLoader in
self.showLoader.wrappedValue = showLoader
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Expand Down Expand Up @@ -118,100 +103,12 @@ struct WebRTCVideoPlayerView: View, AppCameraView {
.animation(.easeInOut, value: viewModel.failureReason)
}

private func handlePinchChanged(factor: CGFloat, midpoint: CGPoint, in containerSize: CGSize) {
let proposedScale = max(lastScale * factor, 1.0)
let oldScale = max(previousFrameScale, 1.0)
let ratio = proposedScale / oldScale

let pX = midpoint.x - containerSize.width / 2
let pY = midpoint.y - containerSize.height / 2

let newOffset = CGSize(
width: pX * (1 - ratio) + offset.width * ratio,
height: pY * (1 - ratio) + offset.height * ratio
)

scale = proposedScale
offset = clampedOffset(for: newOffset, in: containerSize)
previousFrameScale = proposedScale
showControlsTemporarily()
}

private func handlePinchEnded(in containerSize: CGSize) {
lastScale = scale
previousFrameScale = scale
showControlsTemporarily()
if scale <= 1.0 {
withAnimation {
offset = .zero
lastOffset = .zero
}
} else {
withAnimation {
offset = clampedOffset(for: offset, in: containerSize)
lastOffset = offset
}
}
}

private func handleDoubleTap(at location: CGPoint, in containerSize: CGSize) {
withAnimation(.spring()) {
if scale > 1.0 {
scale = 1.0
lastScale = 1.0
previousFrameScale = 1.0
offset = .zero
lastOffset = .zero
} else {
let target: CGFloat = 2.0
let pX = location.x - containerSize.width / 2
let pY = location.y - containerSize.height / 2
let newOffset = CGSize(width: pX * (1 - target), height: pY * (1 - target))
scale = target
lastScale = target
previousFrameScale = target
offset = clampedOffset(for: newOffset, in: containerSize)
lastOffset = offset
}
showControlsTemporarily()
}
}

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)
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
}
showControlsTemporarily()
}
}

private var player: some View {
WebRTCVideoPlayerViewControllerWrapper(
viewModel: viewModel,
isVideoPlaying: $isVideoPlaying
)
.edgesIgnoringSafeArea(.all)
.scaleEffect(.init(floatLiteral: scale >= 1.0 ? scale : 1.0))
.offset(offset)
}

private func showControlsTemporarily() {
Expand All @@ -223,29 +120,6 @@ struct WebRTCVideoPlayerView: View, AppCameraView {
hideControlsWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: workItem)
}

/// 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 {
Expand Down
Loading
Loading