mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-25 07:32:12 -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 --> ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> ## 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. -->
322 lines
12 KiB
Swift
322 lines
12 KiB
Swift
import Alamofire
|
|
import AVFoundation
|
|
import AVKit
|
|
import KeychainAccess
|
|
import PromiseKit
|
|
import SFSafeSymbols
|
|
import Shared
|
|
import UIKit
|
|
import UserNotifications
|
|
import UserNotificationsUI
|
|
|
|
class CameraViewController: UIViewController, NotificationCategory {
|
|
enum CameraError: LocalizedError {
|
|
case missingEntityId
|
|
case missingAPI
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .missingEntityId:
|
|
return L10n.Extensions.NotificationContent.Error.noEntityId
|
|
case .missingAPI:
|
|
return HomeAssistantAPI.APIError.notConfigured.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
let entityId: String
|
|
let api: HomeAssistantAPI
|
|
|
|
private var isMuted = true
|
|
|
|
private lazy var muteButton: UIButton = {
|
|
let button = UIButton(type: .system)
|
|
button.tintColor = .white
|
|
button.backgroundColor = UIColor.black.withAlphaComponent(0.4)
|
|
button.layer.cornerRadius = 18
|
|
button.setPreferredSymbolConfiguration(.init(pointSize: 15, weight: .semibold), forImageIn: .normal)
|
|
button.translatesAutoresizingMaskIntoConstraints = false
|
|
button.addTarget(self, action: #selector(toggleMute), for: .touchUpInside)
|
|
return button
|
|
}()
|
|
|
|
private lazy var loadingIndicator: UIActivityIndicatorView = {
|
|
let indicator = UIActivityIndicatorView(style: .large)
|
|
indicator.color = .white
|
|
indicator.hidesWhenStopped = true
|
|
indicator.translatesAutoresizingMaskIntoConstraints = false
|
|
return indicator
|
|
}()
|
|
|
|
#if DEBUG
|
|
private lazy var streamTypeLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.textColor = .white
|
|
label.font = .systemFont(ofSize: 11, weight: .semibold)
|
|
label.backgroundColor = UIColor.black.withAlphaComponent(0.4)
|
|
label.textAlignment = .center
|
|
label.layer.cornerRadius = 4
|
|
label.clipsToBounds = true
|
|
label.translatesAutoresizingMaskIntoConstraints = false
|
|
return label
|
|
}()
|
|
#endif
|
|
|
|
required init(api: HomeAssistantAPI, notification: UNNotification, attachmentURL: URL?) throws {
|
|
guard let entityId = notification.request.content.userInfo["entity_id"] as? String,
|
|
entityId.starts(with: "camera.") else {
|
|
throw CameraError.missingEntityId
|
|
}
|
|
|
|
self.entityId = entityId
|
|
self.api = api
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
activeViewController?.pause()
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
view.addSubview(loadingIndicator)
|
|
NSLayoutConstraint.activate([
|
|
loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
])
|
|
loadingIndicator.startAnimating()
|
|
|
|
view.addSubview(muteButton)
|
|
muteButton.isHidden = true
|
|
NSLayoutConstraint.activate([
|
|
muteButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
|
|
muteButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -8),
|
|
muteButton.widthAnchor.constraint(equalToConstant: 36),
|
|
muteButton.heightAnchor.constraint(equalToConstant: 36),
|
|
])
|
|
|
|
#if DEBUG
|
|
view.addSubview(streamTypeLabel)
|
|
NSLayoutConstraint.activate([
|
|
streamTypeLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
|
|
streamTypeLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 8),
|
|
streamTypeLabel.heightAnchor.constraint(equalToConstant: 22),
|
|
])
|
|
#endif
|
|
}
|
|
|
|
var activeViewController: (UIViewController & CameraStreamHandler)? {
|
|
willSet {
|
|
activeViewController?.willMove(toParent: nil)
|
|
newValue.flatMap { addChild($0) }
|
|
}
|
|
didSet {
|
|
oldValue?.view.removeFromSuperview()
|
|
oldValue?.removeFromParent()
|
|
|
|
if let viewController = activeViewController {
|
|
view.addSubview(viewController.view)
|
|
viewController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
viewController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
])
|
|
|
|
viewController.didMove(toParent: self)
|
|
|
|
view.bringSubviewToFront(loadingIndicator)
|
|
view.bringSubviewToFront(muteButton)
|
|
#if DEBUG
|
|
view.bringSubviewToFront(streamTypeLabel)
|
|
#endif
|
|
updateOverlays()
|
|
}
|
|
}
|
|
}
|
|
|
|
func start() -> Promise<Void> {
|
|
firstly {
|
|
api.StreamCamera(entityId: entityId)
|
|
}.recover { [entityId] error -> Promise<StreamCameraResponse> in
|
|
Current.Log.info("falling back due to no streaming info for \(entityId) due to \(error)")
|
|
return .value(StreamCameraResponse(fallbackEntityID: entityId))
|
|
}.then { [weak self, api, entityId] result -> Promise<Void> in
|
|
var controllers = Self.possibleControllers
|
|
.compactMap { controllerClass -> () -> Promise<UIViewController & CameraStreamHandler> in
|
|
{
|
|
do {
|
|
return try .value(controllerClass.init(api: api, response: result))
|
|
} catch {
|
|
return Promise(error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prefer WebRTC; it rejects when unsupported so the chain falls through to HLS then MJPEG.
|
|
if #available(iOS 16.0, *) {
|
|
controllers.insert({ () -> Promise<UIViewController & CameraStreamHandler> in
|
|
.value(CameraStreamWebRTCViewController(api: api, cameraEntityId: entityId))
|
|
}, at: 0)
|
|
}
|
|
|
|
return self?.viewController(from: controllers).asVoid() ?? .value(())
|
|
}
|
|
}
|
|
|
|
// No system play/pause button: the stream auto-plays once it starts. The only control is the
|
|
// mute/unmute button overlaid in the top-trailing corner.
|
|
var mediaPlayPauseButtonType: UNNotificationContentExtensionMediaPlayPauseButtonType {
|
|
.none
|
|
}
|
|
|
|
// We draw our own centered loader, so suppress the system one.
|
|
var hidesSystemLoadingIndicator: Bool { true }
|
|
|
|
var mediaPlayPauseButtonFrame: CGRect? { nil }
|
|
|
|
func mediaPlay() {
|
|
activeViewController?.play()
|
|
}
|
|
|
|
func mediaPause() {
|
|
activeViewController?.pause()
|
|
}
|
|
|
|
private func updateOverlays() {
|
|
guard let active = activeViewController else {
|
|
muteButton.isHidden = true
|
|
return
|
|
}
|
|
active.setMuted(isMuted)
|
|
muteButton.isHidden = !active.hasAudio
|
|
updateMuteIcon()
|
|
|
|
#if DEBUG
|
|
streamTypeLabel.text = " \(debugStreamName(for: active)) "
|
|
#endif
|
|
}
|
|
|
|
private func updateMuteIcon() {
|
|
muteButton.setImage(UIImage(systemSymbol: isMuted ? .speakerSlashFill : .speakerWave3), for: .normal)
|
|
// Label reflects the action the button performs, so VoiceOver conveys both purpose and state.
|
|
muteButton.accessibilityLabel = isMuted
|
|
? L10n.Extensions.NotificationContent.Camera.unmute
|
|
: L10n.Extensions.NotificationContent.Camera.mute
|
|
}
|
|
|
|
private func setLoading(_ loading: Bool) {
|
|
if loading {
|
|
loadingIndicator.startAnimating()
|
|
} else {
|
|
loadingIndicator.stopAnimating()
|
|
}
|
|
}
|
|
|
|
@objc private func toggleMute() {
|
|
isMuted.toggle()
|
|
activeViewController?.setMuted(isMuted)
|
|
updateMuteIcon()
|
|
}
|
|
|
|
#if DEBUG
|
|
private func debugStreamName(for controller: UIViewController & CameraStreamHandler) -> String {
|
|
if #available(iOS 16.0, *), controller is CameraStreamWebRTCViewController {
|
|
return "WebRTC"
|
|
}
|
|
if controller is CameraStreamHLSViewController {
|
|
return "AVPlayer (HLS)"
|
|
}
|
|
if controller is CameraStreamMJPEGViewController {
|
|
return "MJPEG"
|
|
}
|
|
return String(describing: type(of: controller))
|
|
}
|
|
#endif
|
|
|
|
enum CameraViewControllerError: LocalizedError {
|
|
case noControllers
|
|
case accumulated([Error])
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .noControllers:
|
|
return nil
|
|
case let .accumulated(errors):
|
|
return errors.map { error in
|
|
// $0. syntax crashes the swift compiler, at least in xcode 12.4
|
|
error.localizedDescription
|
|
}.joined(separator: "\n\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
private static var possibleControllers: [(UIViewController & CameraStreamHandler).Type] { [
|
|
CameraStreamHLSViewController.self,
|
|
CameraStreamMJPEGViewController.self,
|
|
] }
|
|
|
|
private func viewController(
|
|
from controllerPromises: [() -> Promise<UIViewController & CameraStreamHandler>]
|
|
) -> Promise<UIViewController & CameraStreamHandler> {
|
|
var accumulatedErrors = [Error]()
|
|
var promise: Promise<UIViewController & CameraStreamHandler> = .init(
|
|
error: CameraViewControllerError.noControllers
|
|
)
|
|
|
|
for nextPromise in controllerPromises {
|
|
promise = promise.recover { [extensionContext] error -> Promise<UIViewController & CameraStreamHandler> in
|
|
// always tell the extension context the previous one failed, aka go back to showing pause
|
|
extensionContext?.mediaPlayingPaused()
|
|
// accumulate the error
|
|
if case CameraViewControllerError.noControllers = error {
|
|
// except the empty one that we started with to make this code nicer
|
|
} else {
|
|
accumulatedErrors.append(error)
|
|
}
|
|
|
|
return firstly {
|
|
// now try this latest one
|
|
nextPromise()
|
|
}.get { [weak self, extensionContext] controller in
|
|
// configure it -- this isn't part of the one-level-up chain because it would run for each one
|
|
var lastState: CameraStreamHandlerState?
|
|
controller.didUpdateState = { [weak self] state in
|
|
guard lastState != state else {
|
|
return
|
|
}
|
|
|
|
switch state {
|
|
case .playing:
|
|
extensionContext?.mediaPlayingStarted()
|
|
self?.setLoading(false)
|
|
case .paused:
|
|
extensionContext?.mediaPlayingPaused()
|
|
self?.setLoading(true)
|
|
}
|
|
|
|
lastState = state
|
|
}
|
|
|
|
// add it to hirearchy and constrain
|
|
self?.activeViewController = controller
|
|
}.then { value in
|
|
// make sure we wait until the controller figures out if it started or failed
|
|
value.promise.map { value }
|
|
}
|
|
}
|
|
}
|
|
|
|
return promise.recover { nextError -> Promise<UIViewController & CameraStreamHandler> in
|
|
throw CameraViewControllerError.accumulated(accumulatedErrors + [nextError])
|
|
}
|
|
}
|
|
}
|