508 lines
20 KiB
Swift
508 lines
20 KiB
Swift
//
|
|
// PuppetViewController.swift
|
|
// BluefruitPlayground
|
|
//
|
|
// Created by Trevor Beaton & Antonio García on 03/02/2020.
|
|
// Copyright © 2020 Adafruit. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
import SceneKit
|
|
import ReplayKit
|
|
import AVFoundation
|
|
|
|
class PuppetViewController: UIViewController {
|
|
// Constants
|
|
static let kIdentifier = "PuppetViewController"
|
|
|
|
// UI
|
|
@IBOutlet weak var sceneView: SCNView!
|
|
@IBOutlet weak var cameraView: PreviewView!
|
|
@IBOutlet weak var cameraButtonsContainerView: UIView!
|
|
@IBOutlet weak var recordButton: UIButton!
|
|
@IBOutlet weak var fullscreenButton: UIButton!
|
|
|
|
// Data
|
|
private var acceleration = BlePeripheral.AccelerometerValue(x: 0, y: 0, z: 0)
|
|
private var buttonsState: BlePeripheral.ButtonsState?
|
|
|
|
private var jawNode: SCNNode?
|
|
private var headNode: SCNNode?
|
|
private var sparkyFaceNode: SCNNode?
|
|
|
|
private var isPlayingIntroAnimation = true
|
|
|
|
private var filteredAccelerometerAngleX = LowPassFilterSignal(value: 0, filterFactor: 0.06 / Float(BlePeripheral.kAdafruitSensorDefaultPeriod))
|
|
private var filteredAcceleromterAngleY = LowPassFilterSignal(value: 0, filterFactor: 0.07 / Float(BlePeripheral.kAdafruitSensorDefaultPeriod))
|
|
|
|
// Camera Data
|
|
private let captureSession = AVCaptureSession()
|
|
private var isUsingFrontCamera = true
|
|
private var isFullScreen = false
|
|
|
|
// MARK: - Lifecycle
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
// Load base
|
|
let scene = SCNScene(named: "Sparky_Gold1.scn")!
|
|
scene.background.contents = UIColor.clear
|
|
|
|
jawNode = scene.rootNode.childNode(withName: "jaw", recursively: true)
|
|
headNode = scene.rootNode.childNode(withName: "SparkyHead", recursively: true)
|
|
sparkyFaceNode = scene.rootNode.childNode(withName: "Face", recursively: true)
|
|
|
|
// Setup scene
|
|
sceneView.scene = scene
|
|
sceneView.autoenablesDefaultLighting = true
|
|
sceneView.isUserInteractionEnabled = true
|
|
|
|
// Setup camera
|
|
self.cameraView.videoPreviewLayer.videoGravity = .resizeAspectFill
|
|
|
|
// Localization
|
|
let localizationManager = LocalizationManager.shared
|
|
self.title = localizationManager.localizedString("puppet_title")
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
|
|
// Initial value
|
|
let board = AdafruitBoardsManager.shared.currentBoard
|
|
if let acceleration = board?.accelerometerLastValue() {
|
|
self.acceleration = acceleration
|
|
}
|
|
|
|
updateValueUI()
|
|
updateRecordButtonUI()
|
|
|
|
// Setup UI for not fullscreen
|
|
showFullScreen(enabled: false, animated: false)
|
|
|
|
// Navigationbar setup
|
|
if let customNavigationBar = navigationController?.navigationBar as? NavigationBarWithScrollAwareRightButton {
|
|
customNavigationBar.setRightButton(topViewController: self, image: UIImage(named: "help"), target: self, action: #selector(help(_:)))
|
|
}
|
|
|
|
// Set delegates
|
|
board?.accelerometerDelegate = self
|
|
board?.buttonsDelegate = self
|
|
RPScreenRecorder.shared().delegate = self
|
|
|
|
// Start camera
|
|
enableCamera(isFrontCamera: isUsingFrontCamera, animated: false)
|
|
|
|
// Intro Animation
|
|
startSparkyIntroAnimation()
|
|
}
|
|
|
|
override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
|
|
// Remove delegates
|
|
let board = AdafruitBoardsManager.shared.currentBoard
|
|
board?.accelerometerDelegate = nil
|
|
board?.buttonsDelegate = nil
|
|
RPScreenRecorder.shared().delegate = nil
|
|
}
|
|
|
|
override func viewDidDisappear(_ animated: Bool) {
|
|
super.viewDidDisappear(animated)
|
|
|
|
// Stop camera
|
|
disableCamera(animated: false)
|
|
}
|
|
|
|
// MARK: - Camera
|
|
@discardableResult
|
|
private func enableCamera(isFrontCamera: Bool, animated: Bool) -> Bool {
|
|
guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: isFrontCamera ? .front: .back) else { return false }
|
|
let enabled = enableCameraCaptureSession(captureInput: camera)
|
|
|
|
if enabled {
|
|
// Show the cameraView
|
|
if animated {
|
|
UIView.animate(withDuration: 0.3) {
|
|
self.cameraView.alpha = 1
|
|
}
|
|
} else {
|
|
self.cameraView.alpha = 1
|
|
}
|
|
}
|
|
|
|
return enabled
|
|
}
|
|
|
|
private func enableCameraCaptureSession(captureInput: AVCaptureDevice) -> Bool {
|
|
guard let input = try? AVCaptureDeviceInput(device: captureInput) else { return false }
|
|
|
|
var isEnabled = true
|
|
|
|
// Disable current camera session
|
|
captureSession.stopRunning()
|
|
|
|
// Configure
|
|
captureSession.beginConfiguration()
|
|
|
|
let previousInput = captureSession.inputs.first
|
|
for input in captureSession.inputs {
|
|
captureSession.removeInput(input)
|
|
}
|
|
|
|
if captureSession.canAddInput(input) {
|
|
captureSession.addInput(input)
|
|
} else {
|
|
DLog("Error adding input to capture session")
|
|
isEnabled = false
|
|
|
|
// Revert to previous input
|
|
if let previousInput = previousInput, captureSession.canAddInput(previousInput) {
|
|
captureSession.addInput(previousInput)
|
|
}
|
|
}
|
|
|
|
captureSession.commitConfiguration()
|
|
|
|
self.cameraView.videoPreviewLayer.session = captureSession
|
|
captureSession.startRunning()
|
|
return isEnabled
|
|
}
|
|
|
|
private func disableCamera(animated: Bool) {
|
|
captureSession.stopRunning()
|
|
|
|
// Hide the cameraView
|
|
if animated {
|
|
UIView.animate(withDuration: 0.3) {
|
|
self.cameraView.alpha = 0
|
|
}
|
|
} else {
|
|
self.cameraView.alpha = 0
|
|
}
|
|
}
|
|
|
|
private func switchCamera() {
|
|
isUsingFrontCamera.toggle()
|
|
enableCamera(isFrontCamera: isUsingFrontCamera, animated: true)
|
|
}
|
|
|
|
private func switchScreenMode() {
|
|
if captureSession.isRunning {
|
|
disableCamera(animated: true)
|
|
} else {
|
|
enableCamera(isFrontCamera: isUsingFrontCamera, animated: true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Recording
|
|
private func startRecording() {
|
|
let recorder = RPScreenRecorder.shared()
|
|
recorder.isMicrophoneEnabled = true
|
|
recorder.startRecording { [weak self] error in
|
|
guard let self = self else { return }
|
|
guard error == nil else {
|
|
DLog("Error recording: \(error!)")
|
|
return
|
|
}
|
|
|
|
self.enableRecordingUI()
|
|
}
|
|
}
|
|
|
|
private func stopRecording() {
|
|
disableRecordingUI()
|
|
RPScreenRecorder.shared().stopRecording { [weak self] (previewViewController, error) in
|
|
self?.processRecordingStopped(previewViewController: previewViewController, error: error)
|
|
}
|
|
}
|
|
|
|
private func enableRecordingUI() {
|
|
// Update buttons
|
|
PuppetViewController.startButtonRecordAnimation(button: self.recordButton)
|
|
}
|
|
|
|
private func disableRecordingUI() {
|
|
// Update UI
|
|
PuppetViewController.stopButtonRecordAnimation(button: recordButton)
|
|
}
|
|
|
|
private func processRecordingStopped(previewViewController: RPPreviewViewController?, error: Error?) {
|
|
// Check errors
|
|
guard error == nil else {
|
|
DLog("Error recording: \(error!)")
|
|
return
|
|
}
|
|
|
|
// Show recorder edit controller
|
|
guard let previewViewController = previewViewController else { return }
|
|
previewViewController.previewControllerDelegate = self
|
|
self.present(previewViewController, animated: true, completion: nil)
|
|
}
|
|
|
|
private func switchRecording() {
|
|
if RPScreenRecorder.shared().isRecording {
|
|
stopRecording()
|
|
} else {
|
|
startRecording()
|
|
}
|
|
}
|
|
|
|
// MARK: - Trigonometric Utils
|
|
/**
|
|
Converts an origin angle (usually read from the accelerometer) to a destination angle used to rotate a 3d model.
|
|
|
|
- parameters:
|
|
- originAngle: angle (read from the accelerometer)
|
|
- originMinAngle: start of the angles range that will be converted to a destination angle
|
|
- originMaxAngle: end of the angles range that will be converted to a destination angle
|
|
- isOriginRangeClockWise: true if direction from originMinAngle to originMaxAngle is clokwise, false if anticlockwise
|
|
- destinationMinAngle: start of the destination angles range
|
|
- destinationMaxAngle: end of the destination angles range
|
|
|
|
If the origin angle is between [originMinAngle, originMaxAngle] it will be coverted to [destinationMinAngle, destinationMaxAngle] using a linear scale.
|
|
|
|
- note: All angles are measured in radians, and they should be between 0 and ±𝜋 radians
|
|
*/
|
|
private func converAngle(originAngle: Float, originMinAngle: Float, originMaxAngle: Float, isOriginRangeClockWise: Bool, destinationMinAngle: Float, destinationMaxAngle: Float) -> Float {
|
|
|
|
let angle = originAngle.clamped(min: originMinAngle, max: originMaxAngle)
|
|
let rotationFactorCounterClockWise = (angle - originMinAngle) / (originMaxAngle - originMinAngle)
|
|
let rotationFactor = isOriginRangeClockWise ? 1-rotationFactorCounterClockWise : rotationFactorCounterClockWise
|
|
//DLog("angle:\(currentAngle * 180 / .pi) factor: \(rotationFactor)")
|
|
|
|
return (destinationMaxAngle - destinationMinAngle) * rotationFactor + destinationMinAngle
|
|
}
|
|
|
|
private func deg2rad(_ number: Float) -> Float {
|
|
return number * .pi / 180
|
|
}
|
|
|
|
// MARK: - UI
|
|
private func updateValueUI() {
|
|
|
|
// Update sparky rotation (only if the intro animation is not currently playing)
|
|
if !isPlayingIntroAnimation {
|
|
SCNTransaction.animationDuration = BlePeripheral.kAdafruitSensorDefaultPeriod
|
|
SCNTransaction.animationTimingFunction = CAMediaTimingFunction(name: .linear)
|
|
|
|
// Calculate euler angles and feed them to the low pass filters
|
|
let accelerometerAngles = AccelerometerUtils.accelerationToEuler(acceleration)
|
|
filteredAccelerometerAngleX.update(newValue: accelerometerAngles.x)
|
|
filteredAcceleromterAngleY.update(newValue: accelerometerAngles.y)
|
|
|
|
// Jaw animation: eurlerAngles between [0º, -45º] are converted to [7º, 45º]
|
|
let jawAngle = converAngle(originAngle: filteredAccelerometerAngleX.value, originMinAngle: deg2rad(-45), originMaxAngle: deg2rad(0), isOriginRangeClockWise: true, destinationMinAngle: deg2rad(7), destinationMaxAngle: deg2rad(45))
|
|
jawNode?.eulerAngles = SCNVector3(jawAngle, 0, 0)
|
|
|
|
// Head animation
|
|
let xAngle = -jawAngle/2 //-filteredAcceleromterAngleY.value.clamped(min: deg2rad(6), max: deg2rad(40))
|
|
let yAngle = -filteredAcceleromterAngleY.value * 0.5 // Reduce the sensitivity by a factor
|
|
let zAngle = filteredAcceleromterAngleY.value
|
|
headNode?.eulerAngles = SCNVector3(xAngle, yAngle, zAngle)
|
|
}
|
|
}
|
|
|
|
private func updateRecordButtonUI() {
|
|
recordButton.isEnabled = RPScreenRecorder.shared().isAvailable
|
|
}
|
|
|
|
private func showFullScreen(enabled: Bool, animated: Bool) {
|
|
// Calculate changes
|
|
let applyUIChanges = { [unowned self] in
|
|
self.cameraButtonsContainerView.alpha = enabled ? 0.3 : 1
|
|
self.fullscreenButton.setImage(UIImage(named: enabled ? "shrink":"expand"), for: .normal)
|
|
}
|
|
|
|
// Apply changes
|
|
self.navigationController?.setNavigationBarHidden(enabled, animated: animated)
|
|
if animated {
|
|
UIView.animate(withDuration: 0.3) {
|
|
applyUIChanges()
|
|
}
|
|
} else {
|
|
applyUIChanges()
|
|
}
|
|
isFullScreen = enabled
|
|
}
|
|
|
|
// MARK: - Animations
|
|
private func startSparkyIntroAnimation() {
|
|
guard let headNode = headNode else { return }
|
|
self.isPlayingIntroAnimation = true
|
|
|
|
let scale: Float = 0.0005
|
|
headNode.scale = SCNVector3(x: scale, y: scale, z: scale)
|
|
headNode.eulerAngles = SCNVector3(0, -12.57, 0)
|
|
let scaleAction = SCNAction.scale(to: 1, duration: 1.3)
|
|
let rotationAction = SCNAction.rotateTo(x: 0, y: 0, z: 0, duration: 1.4)
|
|
rotationAction.timingMode = .easeOut
|
|
|
|
headNode.runAction(scaleAction)
|
|
headNode.runAction(rotationAction) {
|
|
self.isPlayingIntroAnimation = false
|
|
}
|
|
}
|
|
|
|
private func startSparkyEyesAnimation() {
|
|
guard let sparkyFaceNode = sparkyFaceNode else { return }
|
|
|
|
// Reset pervious animation if still running
|
|
self.sparkyFaceNode?.removeAllActions()
|
|
|
|
// Animatie a bounce in and out animation for Sparky's eyes.
|
|
let scale: Float = 0.0005
|
|
sparkyFaceNode.scale = SCNVector3(x: scale, y: scale, z: scale)
|
|
|
|
let bounceAction = SCNAction.scale(to: 1, duration: 1)
|
|
bounceAction.timingMode = .linear
|
|
|
|
bounceAction.timingFunction = { time in // Use a custom timing function
|
|
return self.easeOutElastic(time)
|
|
}
|
|
sparkyFaceNode.runAction(bounceAction)
|
|
}
|
|
|
|
private func startSparkyShakeAnimation() {
|
|
guard let headNode = headNode else { return }
|
|
guard !isPlayingIntroAnimation else { return } // Don't play while intro is playing because both overwrite the headNode animations
|
|
|
|
// Reset pervious animation if still running
|
|
self.headNode?.removeAllActions()
|
|
|
|
// Animate
|
|
let rotateAnimation = SCNAction.rotateTo(x: 0, y: 0, z: -0.3, duration: 0.1)
|
|
rotateAnimation.timingMode = .linear
|
|
|
|
let rotateAnimationReverse = SCNAction.rotateTo(x: 0, y: 0, z: 0.3, duration: 0.1)
|
|
rotateAnimationReverse.timingMode = .linear
|
|
|
|
let headReset = SCNAction.rotateTo(x: -0.01, y: 0, z: -0.0, duration: 0.1)
|
|
|
|
let sequence = SCNAction.sequence([rotateAnimation, rotateAnimationReverse, rotateAnimation, rotateAnimationReverse, rotateAnimation, rotateAnimationReverse, headReset])
|
|
|
|
headNode.runAction(sequence)
|
|
}
|
|
|
|
private func easeOutElastic(_ t: Float) -> Float {
|
|
// Timing function that has a "bounce in" effect
|
|
let p: Float = 0.3
|
|
let result = pow(2.0, -5.0 * t) * sin((t - p / 4.0) * (2.0 * Float.pi) / p) + 1.0
|
|
return result
|
|
}
|
|
|
|
private static func startButtonRecordAnimation(button: UIButton) {
|
|
let pulseAnimation = CASpringAnimation(keyPath: "transform.scale")
|
|
pulseAnimation.duration = 0.6
|
|
pulseAnimation.fromValue = 1.0
|
|
pulseAnimation.toValue = 1.20
|
|
pulseAnimation.autoreverses = true
|
|
pulseAnimation.repeatCount = 1
|
|
pulseAnimation.initialVelocity = 0.8
|
|
pulseAnimation.damping = 0.8
|
|
|
|
let animationGroup = CAAnimationGroup()
|
|
animationGroup.duration = 2.7
|
|
animationGroup.repeatCount = .greatestFiniteMagnitude
|
|
animationGroup.animations = [pulseAnimation]
|
|
|
|
button.setImage(UIImage(named: "record_stop")!, for: .normal)
|
|
button.imageView?.layer.add(animationGroup, forKey: "pulse")
|
|
button.tintColor = .red
|
|
}
|
|
|
|
private static func stopButtonRecordAnimation(button: UIButton) {
|
|
button.imageView?.layer.removeAllAnimations()
|
|
button.setImage(UIImage(named: "record_start")!, for: .normal)
|
|
button.tintColor = UIColor(named: "text_default")!
|
|
}
|
|
|
|
// MARK: - Actions
|
|
@IBAction func switchCameraAction(_ sender: Any) {
|
|
switchCamera()
|
|
}
|
|
|
|
@IBAction func switchRecordingAction(_ sender: Any) {
|
|
switchRecording()
|
|
}
|
|
|
|
@IBAction func switchScreenModeAction(_ sender: Any) {
|
|
switchScreenMode()
|
|
}
|
|
|
|
@IBAction func fullScreenAction(_ sender: Any) {
|
|
self.showFullScreen(enabled: !isFullScreen, animated: true)
|
|
}
|
|
|
|
@IBAction func help(_ sender: Any) {
|
|
guard let navigationController = storyboard?.instantiateViewController(withIdentifier: HelpViewController.kIdentifier) as? UINavigationController, let helpViewController = navigationController.topViewController as? HelpViewController else { return }
|
|
helpViewController.addMessage(LocalizationManager.shared.localizedString("puppet_help_header"))
|
|
if let image = UIImage(named: "puppet_hand") {
|
|
helpViewController.addImage(image)
|
|
}
|
|
helpViewController.addMessage(LocalizationManager.shared.localizedString("puppet_help_details"))
|
|
|
|
self.present(navigationController, animated: true, completion: nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - CPBBleAccelerometerDelegate
|
|
extension PuppetViewController: AdafruitAccelerometerDelegate {
|
|
func adafruitAccelerationReceived(_ acceleration: BlePeripheral.AccelerometerValue) {
|
|
self.acceleration = acceleration
|
|
updateValueUI()
|
|
}
|
|
}
|
|
|
|
// MARK: - CPBBleButtonsDelegate
|
|
extension PuppetViewController: AdafruitButtonsDelegate {
|
|
func adafruitButtonsReceived(_ newButtonsState: BlePeripheral.ButtonsState) {
|
|
// Check if A became pressed
|
|
if newButtonsState.buttonA == .pressed && newButtonsState.buttonA != buttonsState?.buttonA {
|
|
startSparkyEyesAnimation()
|
|
}
|
|
|
|
// Check if B became pressed
|
|
if newButtonsState.buttonB == .pressed && newButtonsState.buttonB != buttonsState?.buttonB {
|
|
startSparkyShakeAnimation()
|
|
}
|
|
|
|
// Save current state
|
|
self.buttonsState = newButtonsState
|
|
}
|
|
}
|
|
|
|
// MARK: - RPScreenRecorderDelegate
|
|
extension PuppetViewController: RPScreenRecorderDelegate {
|
|
func screenRecorderDidChangeAvailability(_ screenRecorder: RPScreenRecorder) {
|
|
|
|
// Update 'record' button
|
|
DispatchQueue.main.async {
|
|
self.updateRecordButtonUI()
|
|
}
|
|
}
|
|
|
|
func screenRecorder(_ screenRecorder: RPScreenRecorder, didStopRecordingWith previewViewController: RPPreviewViewController?, error: Error?) {
|
|
|
|
// Disable Recording
|
|
DispatchQueue.main.async {
|
|
self.disableRecordingUI()
|
|
self.processRecordingStopped(previewViewController: previewViewController, error: error)
|
|
|
|
if previewViewController == nil || error != nil { // No preview controller means that there was an error
|
|
let localizationManager = LocalizationManager.shared
|
|
let alertController = UIAlertController(title: localizationManager.localizedString("puppet_recording_error_title"), message: localizationManager.localizedString("puppet_recording_error_description"), preferredStyle: .alert)
|
|
let okAction = UIAlertAction(title: localizationManager.localizedString("dialog_ok"), style: .default, handler: nil)
|
|
alertController.addAction(okAction)
|
|
self.present(alertController, animated: true, completion: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - RPPreviewViewControllerDelegate
|
|
extension PuppetViewController: RPPreviewViewControllerDelegate {
|
|
func previewControllerDidFinish(_ previewController: RPPreviewViewController) {
|
|
dismiss(animated: true, completion: nil)
|
|
}
|
|
}
|