205 lines
7.4 KiB
Swift
205 lines
7.4 KiB
Swift
import AVKit
|
|
import Foundation
|
|
import PromiseKit
|
|
import Shared
|
|
import UIKit
|
|
|
|
class CameraStreamHLSViewController: UIViewController, CameraStreamHandler {
|
|
let api: HomeAssistantAPI
|
|
let url: URL
|
|
let playerViewController: AVPlayerViewController
|
|
let promise: Promise<Void>
|
|
var didUpdateState: (CameraStreamHandlerState) -> Void = { _ in }
|
|
private let seal: Resolver<Void>
|
|
private var observationTokens: [NSKeyValueObservation] = []
|
|
|
|
enum HLSError: LocalizedError {
|
|
case noPath
|
|
case avPlayer(Error?)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .noPath:
|
|
return L10n.Extensions.NotificationContent.Error.Request.hlsUnavailable
|
|
case let .avPlayer(error):
|
|
return error?.localizedDescription ?? L10n.Extensions.NotificationContent.Error.Request.other(-1)
|
|
}
|
|
}
|
|
}
|
|
|
|
required convenience init(api: HomeAssistantAPI, response: StreamCameraResponse) throws {
|
|
guard let path = response.hlsPath else {
|
|
throw HLSError.noPath
|
|
}
|
|
|
|
guard let url = api.server.info.connection.activeURL()?.appendingPathComponent(path) else {
|
|
throw ServerConnectionError.noActiveURL
|
|
}
|
|
self.init(api: api, url: url)
|
|
}
|
|
|
|
init(api: HomeAssistantAPI, url: URL) {
|
|
self.api = api
|
|
self.url = url
|
|
self.playerViewController = AVPlayerViewController()
|
|
(self.promise, self.seal) = Promise<Void>.pending()
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
addChild(playerViewController)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
observationTokens.forEach { $0.invalidate() }
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
view.addSubview(playerViewController.view)
|
|
playerViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
playerViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
playerViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
playerViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
playerViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
])
|
|
|
|
playerViewController.didMove(toParent: self)
|
|
|
|
setupVideo()
|
|
}
|
|
|
|
func pause() {
|
|
playerViewController.player?.pause()
|
|
}
|
|
|
|
func play() {
|
|
setupVideo()
|
|
}
|
|
|
|
private var aspectRatioConstraint: NSLayoutConstraint? {
|
|
willSet {
|
|
aspectRatioConstraint?.isActive = false
|
|
}
|
|
didSet {
|
|
aspectRatioConstraint?.isActive = true
|
|
}
|
|
}
|
|
|
|
private var lastSize: CGSize? {
|
|
didSet {
|
|
if oldValue != lastSize, let size = lastSize {
|
|
aspectRatioConstraint = NSLayoutConstraint.aspectRatioConstraint(
|
|
on: playerViewController.view,
|
|
size: size
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setupVideo() {
|
|
try? AVAudioSession.sharedInstance().setCategory(.playback)
|
|
|
|
let asset: AVURLAsset
|
|
|
|
if !url.isFileURL, api.server.info.connection.securityExceptions.hasExceptions {
|
|
asset = .init(url: url, options: [
|
|
// from WebKit, which has the same behavioral requirements we have
|
|
// see
|
|
// https://cs.github.com/WebKit/WebKit/blob/f822d46cdb31d1d3df1915a99c0413acbcb06fd1/Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateAVFoundationObjC.mm?q=resourceloaderdelegate#L894
|
|
// without this, we can't load the video content (non-playlists) of the hls stream, which means
|
|
// we cannot support security exceptions, because auth challenges do not occur
|
|
"AVURLAssetUseClientURLLoadingExclusively": true,
|
|
"AVURLAssetRequiresCustomURLLoadingKey": true,
|
|
])
|
|
} else {
|
|
asset = .init(url: url)
|
|
}
|
|
|
|
asset.resourceLoader.setDelegate(self, queue: .main)
|
|
|
|
let playerItem = AVPlayerItem(asset: asset)
|
|
let videoPlayer = AVPlayer(playerItem: playerItem)
|
|
playerViewController.player = videoPlayer
|
|
|
|
// assume 16:9
|
|
lastSize = CGSize(width: 16, height: 9)
|
|
|
|
videoPlayer.play()
|
|
|
|
observationTokens.append(videoPlayer.observe(\.status) { [weak self] player, _ in
|
|
Current.Log.error("player status: \(player.status.rawValue) error: \(String(describing: player.error))")
|
|
switch player.status {
|
|
case .readyToPlay:
|
|
// we won't get a rate update on initial play, but it's _happening_!
|
|
// the system UI for loading/spinning will take over from here.
|
|
self?.didUpdateState(.playing)
|
|
self?.seal.fulfill(())
|
|
case .failed:
|
|
self?.seal.reject(HLSError.avPlayer(player.error))
|
|
case .unknown:
|
|
break
|
|
@unknown default:
|
|
break
|
|
}
|
|
})
|
|
|
|
observationTokens.append(videoPlayer.observe(\.rate) { [weak self] player, _ in
|
|
// these still fire if the user manually pauses/plays in the video player itself
|
|
self?.didUpdateState(player.rate > 0 ? .playing : .paused)
|
|
})
|
|
|
|
observationTokens.append(videoPlayer.observe(\AVPlayer.currentItem?.tracks) { [weak self] item, _ in
|
|
let sizes = item.currentItem?
|
|
.tracks
|
|
.compactMap({ $0.assetTrack?.naturalSize })
|
|
.filter {
|
|
// hls streams occasionally bounce between (0, 0); (1, 1); and the real size
|
|
$0.width > 1 && $0.height > 1
|
|
}
|
|
|
|
self?.lastSize = sizes?.first
|
|
})
|
|
}
|
|
}
|
|
|
|
extension CameraStreamHLSViewController: AVAssetResourceLoaderDelegate {
|
|
func resourceLoader(
|
|
_ resourceLoader: AVAssetResourceLoader,
|
|
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
|
|
) -> Bool {
|
|
// this is only invoked when we force custom url handling above, for use with auth challenges, because
|
|
// auth challenges do not work in AVFoundation (for as many years as i can find dev forums posts)
|
|
// not happening here: taking the loadingRequest.dataRequest.requestedOffset and handling it + requestedLength
|
|
api.manager.streamRequest(loadingRequest.request).validate().responseStream(stream: { stream in
|
|
switch stream.event {
|
|
case let .complete(completion):
|
|
// not happening here: contentInformationRequest handling
|
|
if let error = completion.error {
|
|
loadingRequest.finishLoading(with: error)
|
|
} else {
|
|
loadingRequest.finishLoading()
|
|
}
|
|
case let .stream(.success(data)):
|
|
loadingRequest.dataRequest?.respond(with: data)
|
|
}
|
|
})
|
|
|
|
return true
|
|
}
|
|
|
|
@objc public func resourceLoader(
|
|
_ resourceLoader: AVAssetResourceLoader,
|
|
shouldWaitForResponseTo authenticationChallenge: URLAuthenticationChallenge
|
|
) -> Bool {
|
|
// this method is not invoked in any situation. though it probably should be.
|
|
// if this starts working, we can stop doing custom resource loading above
|
|
false
|
|
}
|
|
}
|