Files
iOS/Sources/App/Cameras/CameraList/CameraCardView.swift
Bruno Pantaleão Gonçalves 3f2727411c Improve cameras list view (#4210)
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->
- Add fallback support to HLS and MJPEG
- Improve UI
- Move WebRTC known issues disclaimer to a sheet view

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->

<img width="3160" height="1068" alt="CleanShot 2026-01-12 at 15 43
49@2x"
src="https://github.com/user-attachments/assets/77d445d7-88f0-40fb-8b0d-b1eab3c56e3d"
/>


## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-12 17:05:13 +01:00

199 lines
6.0 KiB
Swift

import Shared
import SwiftUI
struct CameraCardView: View {
@StateObject private var viewModel: CameraCardViewModel
private let cameraName: String
init(serverId: String, entityId: String, cameraName: String) {
self._viewModel = .init(wrappedValue: CameraCardViewModel(serverId: serverId, entityId: entityId))
self.cameraName = cameraName
}
var body: some View {
VStack {
ZStack {
contentView
.frame(maxWidth: .infinity, maxHeight: .infinity)
reloadButtonOverlay
bottomShadow
timestampOverlay
cameraNameOverlay
}
}
.background(.black)
.clipShape(RoundedRectangle(cornerRadius: DesignSystem.CornerRadius.two))
.onAppear {
viewModel.viewDidAppear()
}
.onDisappear {
viewModel.viewDidDisappear()
}
}
@ViewBuilder
private var contentView: some View {
if let image = viewModel.image {
image
.resizable()
.aspectRatio(contentMode: .fit)
} else if let errorMessage = viewModel.errorMessage {
errorView(message: errorMessage)
} else if viewModel.isLoading {
loadingView
} else {
Rectangle()
}
}
private func errorView(message: String) -> some View {
VStack(spacing: DesignSystem.Spaces.one) {
Image(systemSymbol: .exclamationmarkTriangleFill)
.font(.largeTitle)
.foregroundStyle(.red)
Text(message)
.font(.caption)
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.modifier(GlassBackgroundModifier())
}
}
private var loadingView: some View {
ProgressView()
.progressViewStyle(.circular)
.tint(.white)
.scaleEffect(1.5)
}
private var reloadButtonOverlay: some View {
VStack {
HStack {
Spacer()
Button(action: {
viewModel.forceReload()
}) {
Image(systemSymbol: .arrowClockwise)
.font(.caption)
.foregroundStyle(.primary)
.padding(DesignSystem.Spaces.one)
.modifier(GlassBackgroundModifier(shape: .circle))
}
.padding(DesignSystem.Spaces.one)
}
Spacer()
}
}
private var bottomShadow: some View {
VStack {
Spacer()
Rectangle()
.fill(.ultraThinMaterial)
.mask {
LinearGradient(
colors: [
Color.black.opacity(0),
Color.black.opacity(1),
],
startPoint: .top,
endPoint: .bottom
)
}
.frame(height: 60)
}
}
@ViewBuilder
private var timestampOverlay: some View {
VStack {
Spacer()
HStack {
Spacer()
if let snapshotDate = viewModel.snapshotDate {
Text(snapshotDate, style: .relative)
.font(.caption2)
.foregroundStyle(.primary)
.padding(.horizontal, DesignSystem.Spaces.one)
.padding(.vertical, DesignSystem.Spaces.half)
.modifier(GlassBackgroundModifier(shape: .capsule))
.padding(DesignSystem.Spaces.one)
}
}
}
}
private var cameraNameOverlay: some View {
VStack {
Spacer()
HStack {
Text(cameraName)
.font(.caption)
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.middle)
.padding(.horizontal, DesignSystem.Spaces.one)
.padding(.vertical, DesignSystem.Spaces.half)
.modifier(GlassBackgroundModifier(shape: .capsule))
.padding(DesignSystem.Spaces.one)
Spacer()
}
}
}
}
// MARK: - Glass Background Modifier
private struct GlassBackgroundModifier: ViewModifier {
enum BackgroundShape {
case roundedRectangle
case circle
case capsule
}
let shape: BackgroundShape
let cornerRadius: CGFloat?
init(shape: BackgroundShape = .roundedRectangle, cornerRadius: CGFloat? = nil) {
self.shape = shape
self.cornerRadius = cornerRadius
}
func body(content: Content) -> some View {
if #available(iOS 26, *) {
// Use modern Liquid Glass effect
content
.glassEffect(in: shapeForGlass)
} else {
let color = Color(uiColor: .systemBackground).opacity(0.6)
// Fallback for older iOS versions
switch shape {
case .roundedRectangle:
content
.background(color)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius ?? DesignSystem.CornerRadius.one))
case .circle:
content
.background(color)
.clipShape(Circle())
case .capsule:
content
.background(color)
.clipShape(Capsule())
}
}
}
@available(iOS 26, *)
private var shapeForGlass: AnyShape {
switch shape {
case .roundedRectangle:
return AnyShape(.rect(cornerRadius: cornerRadius ?? DesignSystem.CornerRadius.one))
case .circle:
return AnyShape(.circle)
case .capsule:
return AnyShape(.capsule)
}
}
}