Files
UnrealEngine/Engine/Extras/VirtualProduction/LiveLinkVCAM/vcam/StartViewController.swift
2025-05-18 13:04:45 +08:00

417 lines
15 KiB
Swift

//
// StartViewController.swift
// vcam
//
// Created by Brian Smith on 11/10/20.
// Copyright Epic Games, Inc. All Rights Reserved.
//
import UIKit
import CocoaAsyncSocket
import Kronos
import GameController
class StartViewController : BaseViewController {
@IBOutlet weak var headerView : HeaderView!
@IBOutlet weak var versionLabel : UILabel!
@IBOutlet weak var restartView : UIView!
@IBOutlet weak var entryView : UIView!
@IBOutlet weak var entryViewYConstraint : NSLayoutConstraint!
@IBOutlet weak var ipAddress : UITextField!
@IBOutlet weak var connect : UIButton!
@IBOutlet weak var connectingView : UIVisualEffectView!
private var tapGesture : UITapGestureRecognizer!
private var streamingConnection : StreamingConnection?
private var gameController : GCController?
private var _liveLinkTimer : Timer?
@objc dynamic let appSettings = AppSettings.shared
private var observers = [NSKeyValueObservation]()
var pickerData: [String] = [String]()
var selectedStreamer: String = "";
var ipAddressIsDemoMode : Bool {
self.ipAddress.text == "demo.mode"
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
// Constructor (before view is even loaded)
required init?(coder: NSCoder) {
super.init(coder: coder)
setupObservers()
}
deinit {
Log.info("StartViewController destructed")
}
func setupObservers() {
// Observers for keyboard show/hide
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
// Observers for game controller connect/disconnect
NotificationCenter.default.addObserver(self, selector: #selector(gameControllerDidConnectNotification), name: .GCControllerDidConnect, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(gameControllerDidDisconnectNotification), name: .GCControllerDidDisconnect, object: nil)
// Observers for app settings
observers.append(observe(\.appSettings.timecodeSource, options: [.initial,.new,.old], changeHandler: { [weak self] object, change in
if let oldValue = TimecodeSource(rawValue:change.oldValue ?? 0) {
switch oldValue {
case .ntp:
Clock.reset()
case .tentacleSync:
Tentacle.shared = nil
default:
break
}
}
switch self?.appSettings.timecodeSourceEnum() {
case .ntp:
let pool = AppSettings.shared.ntpPool.isEmpty ? "time.apple.com" : AppSettings.shared.ntpPool
Log.info("Started NTP : \(pool)")
// IMPORTANT
// We reset the NTP clock first --
// otherwise we don't know if the NTP address that is used for the pool is valid or not because it
// will be using a stale last valid time
Clock.reset()
Clock.sync(from: pool)
case .tentacleSync:
Tentacle.shared = Tentacle()
default:
break
}
}))
// any change to the subject name will remove & re-add the camera subject.
observers.append(observe(\.appSettings.liveLinkSubjectName, options: [.old,.new], changeHandler: { [weak self] object, change in
if let sc = self?.streamingConnection {
sc.subjectName = self?.appSettings.liveLinkSubjectName
}
}))
// initial & value changes for the connection type instantiates a new StreamingConnection object
observers.append(observe(\.appSettings.connectionType, options: [.initial, .old,.new], changeHandler: { [weak self] object, change in
if let validSelf = self {
validSelf.streamingConnection?.shutdown()
validSelf.streamingConnection = nil
let connectionType = validSelf.appSettings.connectionType
if let connectionClass = Bundle.main.classNamed("VCAM.\(connectionType)StreamingConnection") as? StreamingConnection.Type {
validSelf.streamingConnection = connectionClass.init(subjectName: validSelf.appSettings.liveLinkSubjectName)
validSelf.streamingConnection?.delegate = validSelf
}
}
}))
}
override func viewDidLoad() {
super.viewDidLoad();
self.restartView.isHidden = true
if let infoDict = Bundle.main.infoDictionary {
self.versionLabel.text = String(format: "v%@ (%@)", infoDict["CFBundleShortVersionString"] as! String, infoDict["CFBundleVersion"] as! String)
} else {
self.versionLabel.text = "";
}
NetUtility.triggerLocalNetworkPrivacyAlert()
self.ipAddress.text = AppSettings.shared.lastConnectionAddress
self.ipAddress.inputAssistantItem.leadingBarButtonGroups.removeAll()
self.ipAddress.inputAssistantItem.trailingBarButtonGroups.removeAll()
textFieldChanged(self.ipAddress)
self.rebuildRecentAddressesBarButtons()
// Add gesture recognizer
self.tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
self.tapGesture.cancelsTouchesInView = false
self.tapGesture.delegate = self
self.view.addGestureRecognizer(tapGesture)
// Attempt to get an attached game controller
self.gameController = GCController.controllers().first
if let gc = self.gameController {
if gc.isAttachedToDevice {
gc.playerIndex = .index1
}
}
}
override func viewWillAppear(_ animated : Bool) {
self.connectingView.isHidden = true
self.headerView.start()
self.streamingConnection?.delegate = self
_liveLinkTimer = Timer.scheduledTimer(withTimeInterval: 1.0/10.0, repeats: true, block: { [weak self] timer in
self?.streamingConnection?.sendTransform(simd_float4x4(), atTime: Timecode.create().toTimeInterval())
})
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.headerView.start()
self.streamingConnection?.disconnect()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.headerView.stop()
// Remove gesture recognizer
self.view.removeGestureRecognizer(self.tapGesture)
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
// hide the keyboard if it was being shown
self.view.endEditing(true)
if segue.identifier == "showVideoView" {
// connection was successful, we save the last address in our recents list
AppSettings.shared.addRecentConnectionAddress(AppSettings.shared.lastConnectionAddress)
self.rebuildRecentAddressesBarButtons()
if let vc = segue.destination as? VideoViewController {
// stop the timer locally which is sending LL identity xform
_liveLinkTimer?.invalidate()
_liveLinkTimer = nil
// This a little awkward but what happens here is the streamingConnection and gameController gets
// are passed (weakly) from StartViewController to VideoViewController as it needs them
// They are not nil'd in this view because this view still exists because it is the ancestor of the segue
vc.streamingConnection = self.streamingConnection
vc.streamingConnection?.delegate = vc
vc.gameController = self.gameController
}
}
}
@objc func keyboardWillShow(notification: NSNotification) {
guard let userInfo = notification.userInfo else {return}
// if the keyboard will overlap the connect button, then we move the view up so that nothing
// is obscured. In cases where there is a keyboard connected, the toolbar is shown only and
// nothing will move.
let keyboardFrame = self.view.convert((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue, from: self.view.window)
let connectButtonFrame = self.view.convert(connect.frame, from: connect.superview)
if keyboardFrame.minY < connectButtonFrame.maxY {
self.entryViewYConstraint.constant = -(keyboardFrame.size.height - self.headerView.frame.height) / 2.0
} else {
self.entryViewYConstraint.constant = 0
}
UIView.animate(withDuration: 0.2) { [weak self] in
self?.view.layoutIfNeeded()
}
}
@objc func keyboardWillHide(notification: NSNotification) {
if self.entryViewYConstraint.constant != 0 {
self.entryViewYConstraint.constant = 0
UIView.animate(withDuration: 0.2) { [weak self] in
self?.view.layoutIfNeeded()
}
}
}
func rebuildRecentAddressesBarButtons() {
var view : UIView?
if let addresses = AppSettings.shared.recentConnectionAddresses {
for address in addresses {
if view == nil {
view = UIView()
}
let item = UIButton(configuration: UIButton.Configuration.gray())
item.setTitle(address, for: .normal)
item.setTitleColor(UIColor.white, for: .normal)
item.addTarget(self, action: #selector(handleRecentAddressSelection), for: .touchUpInside)
view!.addSubview(item)
item.layoutToSuperview(.centerY)
if view!.subviews.count == 1 {
item.layoutToSuperview(.left)
} else {
item.layout(.left, to: .right, of: view!.subviews[view!.subviews.count - 2], offset: 20)
}
}
}
if let v = view {
v.subviews.last?.layoutToSuperview(.right)
let inputView = UIInputView(frame: CGRect(x: 0, y: 0, width: 100, height: 50), inputViewStyle: .keyboard)
inputView.addSubview(v)
v.layoutToSuperview(.top, offset: 4)
v.layoutToSuperview(.centerX, .bottom)
self.ipAddress.inputAccessoryView = inputView // inputAssistantItem.leadingBarButtonGroups.append(group)
}
}
@objc func handleRecentAddressSelection(_ sender : Any?) {
if let btn = sender as? UIButton {
if let addr = btn.title(for: .normal) {
self.ipAddress.text = addr
self.ipAddress.resignFirstResponder()
}
}
}
@objc func handleTap(_ recognizer: UITapGestureRecognizer) {
self.view.endEditing(true)
}
@IBAction func connect(_ sender : Any?) {
AppSettings.shared.lastConnectionAddress = self.ipAddress.text!
if self.ipAddressIsDemoMode {
self.performSegue(withIdentifier: "showVideoViewDemoMode", sender: self)
} else {
// show the connection view
self.connectingView.isHidden = false
self.connectingView.alpha = 0.0
UIView.animate(withDuration: 0.2) { [weak self] in
self?.connectingView.alpha = 1.0
}
showConnectingAlertView(mode: .connecting, { [weak self] in
self?.hideConnectingView() {}
})
do {
self.streamingConnection?.destination = self.ipAddress.text!.trimmed()
try self.streamingConnection?.connect()
} catch StreamingConnectionError.runtimeError(let errorMessage) {
showConnectionErrorAlert(errorMessage)
} catch {
showConnectionErrorAlert("\(Localized.messageCouldntConnect()) : \(error.localizedDescription)")
}
}
}
func showConnectionErrorAlert(_ message : String) {
hideConnectingAlertView() {
let errorAlert = UIAlertController(title: Localized.titleError(), message: "\(Localized.messageCouldntConnect()) : \(message)", preferredStyle: .alert)
errorAlert.addAction(UIAlertAction(title: Localized.buttonOK(), style: .default, handler: { [weak self] _ in
self?.hideConnectingView { }
}))
self.present(errorAlert, animated: true)
}
}
func hideConnectingView( _ completion : @escaping () -> Void) {
UIView.animate(withDuration: 0.2, animations: { [weak self] in
self?.connectingView.alpha = 0.0
}, completion: { [weak self] b in
self?.connectingView.isHidden = true
})
hideConnectingAlertView(completion)
}
@IBAction func textFieldChanged(_ sender : Any?) {
self.connect.isEnabled = !self.ipAddress.text!.isEmpty
}
@objc func gameControllerDidConnectNotification(_ notification: NSNotification) {
self.gameController = notification.object as? GCController
self.gameController?.playerIndex = .index1
if let videoViewController = self.presentedViewController as? VideoViewController {
videoViewController.gameController = self.gameController
}
}
@objc func gameControllerDidDisconnectNotification(_ notification: NSNotification) {
if let gc = notification.object as? GCController {
Log.info("gameControllerDidDisconnectNotification \(gc.vendorName ?? "Unknown controller")")
}
}
}
extension StartViewController : UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
return touch.view != self.connect && touch.view != self.ipAddress
}
}
extension StartViewController : UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
connect(textField)
return true
}
}
extension StartViewController : UIPickerViewDelegate, UIPickerViewDataSource {
func numberOfComponents(in pickerView: UIPickerView) -> Int {
// Number of columns
return 1;
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
// Number of rows
return pickerData.count
}
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
return pickerData[row]
}
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
// This method is triggered whenever the user makes a change to the picker selection.
// The parameter named row and component represents what was selected.
selectedStreamer = pickerData[row]
}
}