181 lines
6.6 KiB
Swift
181 lines
6.6 KiB
Swift
import Alamofire
|
|
import AVFoundation
|
|
import AVKit
|
|
import KeychainAccess
|
|
import PromiseKit
|
|
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
|
|
|
|
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")
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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] result -> Promise<Void> in
|
|
let controllers = Self.possibleControllers
|
|
.compactMap { controllerClass -> () -> Promise<UIViewController & CameraStreamHandler> in
|
|
{
|
|
do {
|
|
return try .value(controllerClass.init(api: api, response: result))
|
|
} catch {
|
|
return Promise(error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
return self?.viewController(from: controllers).asVoid() ?? .value(())
|
|
}
|
|
}
|
|
|
|
var mediaPlayPauseButtonType: UNNotificationContentExtensionMediaPlayPauseButtonType {
|
|
.overlay
|
|
}
|
|
|
|
var mediaPlayPauseButtonFrame: CGRect? { nil }
|
|
|
|
func mediaPlay() {
|
|
activeViewController?.play()
|
|
}
|
|
|
|
func mediaPause() {
|
|
activeViewController?.pause()
|
|
}
|
|
|
|
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 = { state in
|
|
guard lastState != state else {
|
|
return
|
|
}
|
|
|
|
switch state {
|
|
case .playing:
|
|
extensionContext?.mediaPlayingStarted()
|
|
case .paused:
|
|
extensionContext?.mediaPlayingPaused()
|
|
}
|
|
|
|
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])
|
|
}
|
|
}
|
|
}
|