mirror of
https://github.com/home-assistant/iOS.git
synced 2026-04-12 15:26:45 -05:00
<!-- 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>
199 lines
6.0 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|