diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index b1ec7f9a35..7fe27f84e5 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -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 */; }; @@ -2755,6 +2758,9 @@ 42A47A892C452DB500C9B43D /* MockWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockWebViewController.swift; sourceTree = ""; }; 42A47A8F2C4548E100C9B43D /* ImprovDiscoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImprovDiscoverView.swift; sourceTree = ""; }; 42A47D4A2C9AEF10003C597D /* DataWidgetsUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataWidgetsUpdater.swift; sourceTree = ""; }; + 42A72D302FFF12600040C852 /* CamerasDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CamerasDebugView.swift; sourceTree = ""; }; + 42A72D322FFF12670040C852 /* CamerasDebugServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CamerasDebugServerView.swift; sourceTree = ""; }; + 42A72D342FFF158A0040C852 /* ZoomableCameraContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableCameraContainer.swift; sourceTree = ""; }; 42A746942E832FB8005E0332 /* bug_report_iOS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = bug_report_iOS.md; sourceTree = ""; }; 42A746952E832FB8005E0332 /* bug_report_mac.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = bug_report_mac.md; sourceTree = ""; }; 42A746962E832FB8005E0332 /* config.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = config.yml; sourceTree = ""; }; @@ -5198,6 +5204,7 @@ 4210CCEA2F152AD000B71FB9 /* CameraPlayerView.swift */, 4210CCEE2F152ED800B71FB9 /* CameraStreamHLSView.swift */, 4210CCF22F1532C400B71FB9 /* CameraMJPEGPlayerView.swift */, + 42A72D342FFF158A0040C852 /* ZoomableCameraContainer.swift */, ); path = CameraPlayer; sourceTree = ""; @@ -5643,6 +5650,15 @@ path = Improv; sourceTree = ""; }; + 42A72D2F2FFF12600040C852 /* CamerasDebug */ = { + isa = PBXGroup; + children = ( + 42A72D302FFF12600040C852 /* CamerasDebugView.swift */, + 42A72D322FFF12670040C852 /* CamerasDebugServerView.swift */, + ); + path = CamerasDebug; + sourceTree = ""; + }; 42A746972E832FB8005E0332 /* ISSUE_TEMPLATE */ = { isa = PBXGroup; children = ( @@ -7087,6 +7103,7 @@ D75BA669B0E8C771B69ABE41 /* Components */, 42886D222FFE701C004EB33B /* AcknowledgementsView.swift */, 42886D262FFE701C004EB33B /* AcknowledgementPackage.swift */, + 42A72D2F2FFF12600040C852 /* CamerasDebug */, ); path = Settings; sourceTree = ""; @@ -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 */, @@ -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 */, @@ -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 */, diff --git a/Sources/App/Cameras/CameraPlayer/CameraMJPEGPlayerView.swift b/Sources/App/Cameras/CameraPlayer/CameraMJPEGPlayerView.swift index 490f9da3b0..d5ea59500e 100644 --- a/Sources/App/Cameras/CameraPlayer/CameraMJPEGPlayerView.swift +++ b/Sources/App/Cameras/CameraPlayer/CameraMJPEGPlayerView.swift @@ -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 { diff --git a/Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift b/Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift index 213c4d2d9f..e6b6a13efc 100644 --- a/Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift +++ b/Sources/App/Cameras/CameraPlayer/CameraPlayerView.swift @@ -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? @@ -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 { @@ -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 @@ -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 { diff --git a/Sources/App/Cameras/CameraPlayer/CameraStreamHLSView.swift b/Sources/App/Cameras/CameraPlayer/CameraStreamHLSView.swift index e52ac15eb2..d6fe1b5463 100644 --- a/Sources/App/Cameras/CameraPlayer/CameraStreamHLSView.swift +++ b/Sources/App/Cameras/CameraPlayer/CameraStreamHLSView.swift @@ -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 { diff --git a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerView.swift b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerView.swift index af916d5194..e56c9ec42d 100644 --- a/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerView.swift +++ b/Sources/App/Cameras/CameraPlayer/WebRTC/WebRTCVideoPlayerView.swift @@ -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 @@ -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) { @@ -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() { @@ -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 { diff --git a/Sources/App/Cameras/CameraPlayer/ZoomableCameraContainer.swift b/Sources/App/Cameras/CameraPlayer/ZoomableCameraContainer.swift new file mode 100644 index 0000000000..a37d8a86f7 --- /dev/null +++ b/Sources/App/Cameras/CameraPlayer/ZoomableCameraContainer.swift @@ -0,0 +1,165 @@ +import SwiftUI + +/// A reusable container that adds pinch-to-zoom, double-tap-to-zoom and pan +/// support to any camera player content. The zoom/pan math is shared across all +/// camera player types (WebRTC, HLS, MJPEG) so they behave identically. +/// +/// - `onInteraction` is called on any zoom/pan gesture (used, e.g., to keep the +/// player controls visible while the user is interacting). +/// - `onSwipeDown` is called when the user swipes down while not zoomed in +/// (used to dismiss the player). +struct ZoomableCameraContainer: View { + @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 + + private let onInteraction: (() -> Void)? + private let onSwipeDown: (() -> Void)? + private let content: Content + + init( + onInteraction: (() -> Void)? = nil, + onSwipeDown: (() -> Void)? = nil, + @ViewBuilder content: () -> Content + ) { + self.onInteraction = onInteraction + self.onSwipeDown = onSwipeDown + self.content = content() + } + + var body: some View { + GeometryReader { geometry in + ZStack { + content + .scaleEffect(.init(floatLiteral: scale >= 1.0 ? scale : 1.0)) + .offset(offset) + 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) + } + ) + } + .simultaneousGesture(dragGesture(geometry: geometry)) + } + } + + 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 + onInteraction?() + } + + private func handlePinchEnded(in containerSize: CGSize) { + lastScale = scale + previousFrameScale = scale + onInteraction?() + 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 + } + onInteraction?() + } + } + + 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) + onInteraction?() + } + .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 { + onSwipeDown?() + } + return + } + withAnimation(.spring()) { + offset = clampedOffset(for: offset, in: geometry.size) + lastOffset = offset + } + onInteraction?() + } + } + + /// 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) + } +} diff --git a/Sources/App/Settings/CamerasDebug/CamerasDebugServerView.swift b/Sources/App/Settings/CamerasDebug/CamerasDebugServerView.swift new file mode 100644 index 0000000000..f4710ec3f3 --- /dev/null +++ b/Sources/App/Settings/CamerasDebug/CamerasDebugServerView.swift @@ -0,0 +1,87 @@ +import Shared +import SwiftUI + +/// Debug screen listing the camera entities for a given server. Tapping a camera +/// opens the live `CameraPlayerView` (WebRTC/HLS/MJPEG) for that entity. +struct CamerasDebugServerView: View { + let server: Server + + @State private var cameras: [HAAppEntity] = [] + @State private var selectedCamera: HAAppEntity? + + var body: some View { + List { + if cameras.isEmpty { + Text(verbatim: "No camera entities found for this server.") + .foregroundStyle(.secondary) + } else { + ForEach(cameras) { camera in + Section { + Button { + selectedCamera = camera + } label: { + VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) { + Text(camera.name) + .foregroundStyle(Color(uiColor: .label)) + Text(verbatim: camera.entityId) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Button { + copyDeeplink(for: camera) + } label: { + HStack(spacing: DesignSystem.Spaces.two) { + Image(systemSymbol: .docOnDoc) + .foregroundStyle(Color.haPrimary) + Text(verbatim: "Copy deeplink") + .foregroundStyle(Color(uiColor: .label)) + } + } + } + } + } + } + .navigationTitle(server.info.name) + .navigationBarTitleDisplayMode(.inline) + .onAppear(perform: loadCameras) + .fullScreenCover(item: $selectedCamera) { camera in + CameraPlayerView( + server: server, + cameraEntityId: camera.entityId, + cameraName: camera.name, + showsDebugOverlay: true + ) + } + } + + private func copyDeeplink(for camera: HAAppEntity) { + guard let url = AppConstants.openCameraDeeplinkURL( + entityId: camera.entityId, + serverId: server.identifier.rawValue + ) else { + Current.Log.error("Failed to build camera deeplink for \(camera.entityId)") + return + } + UIPasteboard.general.string = url.absoluteString + } + + private func loadCameras() { + do { + cameras = try HAAppEntity.config() + .filter { $0.serverId == server.identifier.rawValue && $0.domain == Domain.camera.rawValue } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } catch { + Current.Log.error("Failed to load cameras for debug view, error: \(error)") + } + } +} + +#if DEBUG +#Preview { + NavigationView { + CamerasDebugServerView(server: ServerFixture.standard) + } +} +#endif diff --git a/Sources/App/Settings/CamerasDebug/CamerasDebugView.swift b/Sources/App/Settings/CamerasDebug/CamerasDebugView.swift new file mode 100644 index 0000000000..3bad6eef81 --- /dev/null +++ b/Sources/App/Settings/CamerasDebug/CamerasDebugView.swift @@ -0,0 +1,30 @@ +import Shared +import SwiftUI + +/// Debug screen listing every configured server. Selecting a server drills into +/// its camera entities so their live streams can be previewed for troubleshooting. +struct CamerasDebugView: View { + @StateObject private var observer = ServersObserver() + + var body: some View { + List { + Section { + ForEach(observer.servers, id: \.identifier) { server in + NavigationLink(destination: CamerasDebugServerView(server: server)) { + HomeAssistantAccountRowView(server: server) + } + } + } footer: { + Text(verbatim: "Select a server to browse its camera entities and preview their live streams.") + } + } + .navigationTitle("Cameras Debug") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationView { + CamerasDebugView() + } +} diff --git a/Sources/App/Settings/DebugView.swift b/Sources/App/Settings/DebugView.swift index d14e64f54b..e1b5bad3ae 100644 --- a/Sources/App/Settings/DebugView.swift +++ b/Sources/App/Settings/DebugView.swift @@ -143,6 +143,15 @@ struct DebugView: View { ) } + NavigationLink { + CamerasDebugView() + } label: { + linkContent( + image: .init(systemSymbol: .videoFill), + title: "Cameras Debug" + ) + } + #if DEBUG NavigationLink { KeychainExplorerView()