diff --git a/XIONControlPanel.xcodeproj/project.pbxproj b/XIONControlPanel.xcodeproj/project.pbxproj index f6dbbd8..82f1343 100644 --- a/XIONControlPanel.xcodeproj/project.pbxproj +++ b/XIONControlPanel.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 0C3CAEAF1C3350C800B856AD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0C3CAEAE1C3350C800B856AD /* Assets.xcassets */; }; 0C3CAEB21C3350C800B856AD /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0C3CAEB01C3350C800B856AD /* LaunchScreen.storyboard */; }; 0C41EF4A1C34B51500AF847F /* SwitchesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41EF491C34B51500AF847F /* SwitchesViewController.swift */; }; + B10000010000000000000001 /* AudioMixerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000010000000000000002 /* AudioMixerViewController.swift */; }; + B10000010000000000000003 /* AudioSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000010000000000000004 /* AudioSectionView.swift */; }; 0C41EF4E1C34B81F00AF847F /* KSIRingView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0C41EF4D1C34B81F00AF847F /* KSIRingView.m */; }; 0C41EF501C34B97700AF847F /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41EF4F1C34B97700AF847F /* HeaderView.swift */; }; 0C41EF521C34BA4E00AF847F /* Orbitron-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0C41EF511C34BA4E00AF847F /* Orbitron-Medium.ttf */; }; @@ -67,6 +69,8 @@ 0C3CAEB11C3350C800B856AD /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 0C3CAEB31C3350C800B856AD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 0C41EF491C34B51500AF847F /* SwitchesViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitchesViewController.swift; sourceTree = ""; }; + B10000010000000000000002 /* AudioMixerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMixerViewController.swift; sourceTree = ""; }; + B10000010000000000000004 /* AudioSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSectionView.swift; sourceTree = ""; }; 0C41EF4C1C34B81F00AF847F /* KSIRingView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = KSIRingView.h; sourceTree = ""; }; 0C41EF4D1C34B81F00AF847F /* KSIRingView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = KSIRingView.m; sourceTree = ""; }; 0C41EF4F1C34B97700AF847F /* HeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = ""; }; @@ -146,6 +150,7 @@ 0C20E2B61C33514100BA789B /* Controllers */ = { isa = PBXGroup; children = ( + B10000010000000000000002 /* AudioMixerViewController.swift */, 0C20E2BA1C33529700BA789B /* MainViewController.swift */, 0C41EF491C34B51500AF847F /* SwitchesViewController.swift */, 0C20E2BC1C33534F00BA789B /* VisualizationViewController.swift */, @@ -167,6 +172,7 @@ 0C20E2B81C33514100BA789B /* Views */ = { isa = PBXGroup; children = ( + B10000010000000000000004 /* AudioSectionView.swift */, 0C41EF551C35ABCD00AF847F /* ConnectionStatusView.swift */, 0C41EF4F1C34B97700AF847F /* HeaderView.swift */, 0CF9A48D1C35E9C800276F13 /* SwitchIndicatorView.swift */, @@ -393,6 +399,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B10000010000000000000001 /* AudioMixerViewController.swift in Sources */, + B10000010000000000000003 /* AudioSectionView.swift in Sources */, 0C41EF4E1C34B81F00AF847F /* KSIRingView.m in Sources */, 0CAA327B1C361CBE00B353CF /* NSErrorAdditions.swift in Sources */, CDA64C242E03AE6400E9B07E /* HomeAssistantServer.swift in Sources */, diff --git a/XIONControlPanel.xcodeproj/xcshareddata/xcschemes/XIONControlPanel.xcscheme b/XIONControlPanel.xcodeproj/xcshareddata/xcschemes/XIONControlPanel.xcscheme index 44b53ef..35dba69 100644 --- a/XIONControlPanel.xcodeproj/xcshareddata/xcschemes/XIONControlPanel.xcscheme +++ b/XIONControlPanel.xcodeproj/xcshareddata/xcschemes/XIONControlPanel.xcscheme @@ -52,6 +52,18 @@ ReferencedContainer = "container:XIONControlPanel.xcodeproj"> + + + + + + - - - - - - Void)? + + private static let mixerURL = URL(string: "http://avmotu.localdomain/#touch-console")! + private static let accentColor = UIColor(red: 0.0, green: 0.95, blue: 0.95, alpha: 1.0) + + private let _panelView = UIView(frame: .zero) + private let _webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + private let _closeButton = UIButton(type: .custom) + private let _refreshButton = UIButton(type: .custom) + private let _loadingProgressView = UIProgressView(progressViewStyle: .bar) + private var _progressObservation: NSKeyValueObservation? + + override func loadView() + { + view = UIView(frame: .zero) + view.backgroundColor = .clear + view.clipsToBounds = false + } + + override func viewDidLoad() + { + super.viewDidLoad() + + _panelView.backgroundColor = .black + _panelView.layer.borderColor = Self.accentColor.withAlphaComponent(0.65).cgColor + _panelView.layer.borderWidth = 1.0 + _panelView.layer.shadowColor = UIColor.black.cgColor + _panelView.layer.shadowOpacity = 0.95 + _panelView.layer.shadowRadius = 116.0 + _panelView.layer.shadowOffset = CGSize(width: -10.0, height: 0.0) + view.addSubview(_panelView) + + _webView.navigationDelegate = self + _webView.isOpaque = false + _webView.backgroundColor = .black + _webView.scrollView.backgroundColor = .black + _webView.allowsBackForwardNavigationGestures = false + _panelView.addSubview(_webView) + + _configureControlTab(_closeButton, accessibilityLabel: "Close Mixer") + _closeButton.setTitle("X", for: .normal) + _closeButton.titleLabel?.font = UIFont(name: "Orbitron-Medium", size: 18.0) + _closeButton.accessibilityIdentifier = "audio.close" + _closeButton.addTarget(self, action: #selector(_close), for: .touchUpInside) + view.addSubview(_closeButton) + + _configureControlTab(_refreshButton, accessibilityLabel: "Refresh Mixer") + let refreshImage = UIImage( + systemName: "arrow.clockwise", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 20.0, weight: .medium) + ) + _refreshButton.setImage(refreshImage, for: .normal) + _refreshButton.tintColor = Self.accentColor + _refreshButton.accessibilityIdentifier = "audio.refresh" + _refreshButton.addTarget(self, action: #selector(_refresh), for: .touchUpInside) + view.addSubview(_refreshButton) + + _loadingProgressView.progressTintColor = Self.accentColor + _loadingProgressView.trackTintColor = UIColor(white: 0.15, alpha: 1.0) + _panelView.addSubview(_loadingProgressView) + + _progressObservation = _webView.observe( + \.estimatedProgress, + options: [.new] + ) { [weak self] webView, _ in + guard let self else { return } + let progress = Float(webView.estimatedProgress) + self._loadingProgressView.setProgress(progress, animated: true) + self._loadingProgressView.isHidden = progress >= 1.0 + } + + _loadMixer() + } + + override func viewDidLayoutSubviews() + { + super.viewDidLayoutSubviews() + + let contentFrame = CGRect( + x: Self.controlsWidth, + y: 0.0, + width: max(0.0, view.bounds.width - Self.controlsWidth), + height: view.bounds.height + ) + _panelView.frame = contentFrame + _panelView.layer.shadowPath = UIBezierPath(rect: _panelView.bounds).cgPath + _webView.frame = _panelView.bounds + _loadingProgressView.frame = CGRect( + x: 1.0, + y: 1.0, + width: max(0.0, _panelView.bounds.width - 2.0), + height: 2.0 + ) + + let tabHeight: CGFloat = 54.0 + let tabSpacing: CGFloat = 5.0 + let tabTop: CGFloat = 16.0 + _closeButton.frame = CGRect( + x: 0.0, + y: tabTop, + width: Self.controlsWidth + 1.0, + height: tabHeight + ) + _refreshButton.frame = CGRect( + x: 0.0, + y: _closeButton.frame.maxY + tabSpacing, + width: Self.controlsWidth + 1.0, + height: tabHeight + ) + + view.bringSubviewToFront(_closeButton) + view.bringSubviewToFront(_refreshButton) + } + + private func _configureControlTab(_ button: UIButton, accessibilityLabel: String) + { + button.backgroundColor = UIColor(white: 0.2, alpha: 1.0) + button.setTitleColor(Self.accentColor, for: .normal) + button.accessibilityTraits = .button + button.accessibilityLabel = accessibilityLabel + button.layer.borderColor = Self.accentColor.withAlphaComponent(0.65).cgColor + button.layer.borderWidth = 1.0 + } + + private func _loadMixer() + { + _loadingProgressView.isHidden = false + _loadingProgressView.progress = 0.0 + let request = URLRequest( + url: Self.mixerURL, + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: 30.0 + ) + _webView.load(request) + } + + @objc private func _close() + { + closeHandler?() + } + + @objc private func _refresh() + { + if _webView.url == nil { + _loadMixer() + } else { + _webView.reload() + } + } +} diff --git a/XIONControlPanel/Controllers/MainViewController.swift b/XIONControlPanel/Controllers/MainViewController.swift index d9a4b7c..d28054d 100644 --- a/XIONControlPanel/Controllers/MainViewController.swift +++ b/XIONControlPanel/Controllers/MainViewController.swift @@ -19,6 +19,11 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv fileprivate var _switchesController: SwitchesViewController = SwitchesViewController() fileprivate var _lightsController: SwitchesViewController = SwitchesViewController() + fileprivate var _audioSectionView: AudioSectionView = AudioSectionView() + fileprivate var _audioMixerController: AudioMixerViewController = AudioMixerViewController() + fileprivate var _audioMixerDimmingView: UIControl = UIControl(frame: .zero) + fileprivate var _isAudioMixerPresented: Bool = false + fileprivate var _isAudioMixerAnimating: Bool = false override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { @@ -56,8 +61,30 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv _lightsController.labelText = "lights" self.addChild(_lightsController) self.view.addSubview(_lightsController.view) + + _audioSectionView.mixerSettingsHandler = { [weak self] in + self?._presentAudioMixer() + } + self.view.addSubview(_audioSectionView) + + _audioMixerController.closeHandler = { [weak self] in + self?._dismissAudioMixer() + } self.view.addSubview(_headerView) + + _audioMixerDimmingView.backgroundColor = UIColor.black + _audioMixerDimmingView.alpha = 0.0 + _audioMixerDimmingView.isHidden = true + _audioMixerDimmingView.accessibilityIdentifier = "audio.dimmingView" + _audioMixerDimmingView.accessibilityLabel = "Dismiss Mixer" + _audioMixerDimmingView.accessibilityTraits = .button + _audioMixerDimmingView.addTarget( + self, + action: #selector(_audioMixerDimmingViewTapped), + for: .touchUpInside + ) + self.view.addSubview(_audioMixerDimmingView) _updateConnectivityStatus(.disconnected) _updateSizeClassPresentation() @@ -82,8 +109,10 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv ) _headerView.frame = headerBounds + _audioMixerDimmingView.frame = bounds if _visualizationShouldBeVisible() { + let sectionSpacing: CGFloat = 8.0 let visualizationFrame = CGRect( x: bodyBounds.origin.x, y: bodyBounds.origin.y, @@ -103,15 +132,36 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv dy: 0.0 ) - _switchesController.view.frame = CGRect( + let rightColumnFrame = CGRect( x: visualizationFrame.maxX, y: bodyBounds.origin.y, width: bodyBounds.width - visualizationFrame.width, height: bodyBounds.height ) + let audioHeight = min( + AudioSectionView.preferredHeight(forWidth: rightColumnFrame.width), + bodyBounds.height * 0.22 + ) + _switchesController.view.frame = CGRect( + x: rightColumnFrame.minX, + y: rightColumnFrame.minY, + width: rightColumnFrame.width, + height: rightColumnFrame.height - audioHeight - sectionSpacing + ) + _audioSectionView.frame = CGRect( + x: rightColumnFrame.minX, + y: _switchesController.view.frame.maxY + sectionSpacing, + width: rightColumnFrame.width, + height: audioHeight + ) } else { - let sectionSpacing: CGFloat = 8 - let switchesHeight = rint(bodyBounds.height * 0.68) + let sectionSpacing: CGFloat = 8.0 + let audioHeight = min( + AudioSectionView.preferredHeight(forWidth: bodyBounds.width), + bodyBounds.height * 0.18 + ) + let lightsHeight = rint(bodyBounds.height * 0.27) + let switchesHeight = bodyBounds.height - audioHeight - lightsHeight - (sectionSpacing * 2.0) _visualizationController.view.frame = .zero _switchesController.view.frame = CGRect( @@ -120,14 +170,26 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv width: bodyBounds.width, height: switchesHeight ) + _audioSectionView.frame = CGRect( + x: bodyBounds.minX + sectionSpacing, + y: _switchesController.view.frame.maxY + sectionSpacing, + width: bodyBounds.width - (sectionSpacing * 2.0), + height: audioHeight + ) _lightsController.view.frame = CGRect( x: bodyBounds.minX + sectionSpacing, - y: bodyBounds.minY + switchesHeight + sectionSpacing, + y: _audioSectionView.frame.maxY + sectionSpacing, width: bodyBounds.width - (sectionSpacing * 2), - height: bodyBounds.height - switchesHeight - sectionSpacing + height: lightsHeight ) } + if _audioMixerController.parent === self, !_isAudioMixerAnimating { + _audioMixerController.view.frame = _isAudioMixerPresented + ? _audioMixerPresentedFrame() + : _audioMixerHiddenFrame() + } + _updateSizeClassPresentation() } @@ -214,6 +276,90 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv _visualizationController.setPercentActivated(percentageActivated, animated: animated) } } + + internal func _presentAudioMixer() + { + guard !_isAudioMixerPresented, !_isAudioMixerAnimating else { return } + + if _audioMixerController.parent == nil { + addChild(_audioMixerController) + view.addSubview(_audioMixerController.view) + _audioMixerController.didMove(toParent: self) + } + + _isAudioMixerPresented = true + _isAudioMixerAnimating = true + _audioMixerDimmingView.alpha = 0.0 + _audioMixerDimmingView.isHidden = false + _audioMixerController.view.isHidden = false + _audioMixerController.view.frame = _audioMixerHiddenFrame() + _audioMixerController.view.setNeedsLayout() + _audioMixerController.view.layoutIfNeeded() + view.bringSubviewToFront(_audioMixerDimmingView) + view.bringSubviewToFront(_audioMixerController.view) + + UIView.animate( + withDuration: 0.45, + delay: 0.0, + usingSpringWithDamping: 0.88, + initialSpringVelocity: 0.25, + options: [.allowUserInteraction, .beginFromCurrentState], + animations: { + self._audioMixerDimmingView.alpha = 0.55 + self._audioMixerController.view.frame = self._audioMixerPresentedFrame() + }, + completion: { _ in + self._isAudioMixerAnimating = false + self._audioMixerController.view.frame = self._audioMixerPresentedFrame() + } + ) + } + + internal func _dismissAudioMixer() + { + guard _isAudioMixerPresented, !_isAudioMixerAnimating else { return } + + _isAudioMixerPresented = false + _isAudioMixerAnimating = true + UIView.animate( + withDuration: 0.35, + delay: 0.0, + options: [.curveEaseIn, .allowUserInteraction, .beginFromCurrentState], + animations: { + self._audioMixerDimmingView.alpha = 0.0 + self._audioMixerController.view.frame = self._audioMixerHiddenFrame() + }, + completion: { _ in + self._isAudioMixerAnimating = false + self._audioMixerController.view.isHidden = true + self._audioMixerDimmingView.isHidden = true + } + ) + } + + @objc private func _audioMixerDimmingViewTapped() + { + _dismissAudioMixer() + } + + private func _audioMixerPresentedFrame() -> CGRect + { + let switchesFrame = _switchesController.view.frame + return CGRect( + x: switchesFrame.minX - AudioMixerViewController.controlsWidth, + y: switchesFrame.minY, + width: switchesFrame.width + AudioMixerViewController.controlsWidth, + height: _audioSectionView.frame.maxY - switchesFrame.minY + ) + } + + private func _audioMixerHiddenFrame() -> CGRect + { + return _audioMixerPresentedFrame().offsetBy( + dx: UIScreen.main.bounds.width, + dy: 0.0 + ) + } internal func _updateConnectivityStatus(_ status: ConnectionStatus) { diff --git a/XIONControlPanel/SupportingFiles/Info.plist b/XIONControlPanel/SupportingFiles/Info.plist index b4de0c6..e231943 100644 --- a/XIONControlPanel/SupportingFiles/Info.plist +++ b/XIONControlPanel/SupportingFiles/Info.plist @@ -31,6 +31,8 @@ NSAllowsArbitraryLoads + NSLocalNetworkUsageDescription + Connect to the local MOTU audio mixer touch console. UIAppFonts Orbitron-Medium.ttf diff --git a/XIONControlPanel/Views/AudioSectionView.swift b/XIONControlPanel/Views/AudioSectionView.swift new file mode 100644 index 0000000..03cf35b --- /dev/null +++ b/XIONControlPanel/Views/AudioSectionView.swift @@ -0,0 +1,120 @@ +// +// AudioSectionView.swift +// XIONControlPanel +// + +import UIKit + +final class AudioSectionView: UIView +{ + var mixerSettingsHandler: (() -> Void)? + + private let _label = UILabel(frame: .zero) + private let _mixerSettingsButton = AudioActionButton(type: .custom) + + override init(frame: CGRect) + { + super.init(frame: frame) + + _label.font = UIFont(name: "Orbitron-Medium", size: 14.0) + _label.text = "audio" + _label.textColor = .white.withAlphaComponent(0.8) + addSubview(_label) + + _mixerSettingsButton.setTitle("MIXER SETTINGS", for: .normal) + _mixerSettingsButton.titleLabel?.font = UIFont(name: "Orbitron-Medium", size: 21.0) + _mixerSettingsButton.setTitleColor(.white, for: .normal) + _mixerSettingsButton.accessibilityIdentifier = "audio.mixerSettings" + _mixerSettingsButton.accessibilityLabel = "Mixer Settings" + _mixerSettingsButton.addTarget(self, action: #selector(_showMixerSettings), for: .touchUpInside) + addSubview(_mixerSettingsButton) + } + + required init?(coder: NSCoder) + { + fatalError("unsupported") + } + + override func layoutSubviews() + { + super.layoutSubviews() + + let labelSpacing: CGFloat = 8.0 + let labelSize = _label.sizeThatFits(bounds.size) + _label.frame = CGRect( + x: 0.0, + y: 0.0, + width: labelSize.width, + height: labelSize.height + ) + _mixerSettingsButton.frame = CGRect( + x: 0.0, + y: _label.frame.maxY + labelSpacing, + width: bounds.width, + height: max(0.0, bounds.height - _label.frame.height - labelSpacing) + ) + } + + static func preferredHeight(forWidth width: CGFloat) -> CGFloat + { + let cellsPerRow: CGFloat + switch width { + case 500.0...: + cellsPerRow = 4.0 + case 400.0..<500.0: + cellsPerRow = 3.0 + default: + cellsPerRow = 2.0 + } + + let cellSpacing: CGFloat = 5.0 + let cellDimension = floor( + (width / cellsPerRow) - ((cellSpacing * (cellsPerRow - 1.0)) / cellsPerRow) + ) + let buttonHeight = rint(cellDimension / 1.5) + return 17.0 + 8.0 + buttonHeight + } + + @objc private func _showMixerSettings() + { + mixerSettingsHandler?() + } +} + +private final class AudioActionButton: UIButton +{ + private static let normalBackgroundColor = UIColor(white: 0.2, alpha: 1.0) + private static let highlightedBackgroundColor = UIColor(white: 0.8, alpha: 1.0) + + override init(frame: CGRect) + { + super.init(frame: frame) + backgroundColor = Self.normalBackgroundColor + accessibilityTraits = .button + } + + required init?(coder: NSCoder) + { + fatalError("unsupported") + } + + override var isHighlighted: Bool + { + didSet + { + if isHighlighted { + backgroundColor = Self.highlightedBackgroundColor + } else { + UIView.animate( + withDuration: 0.35, + delay: 0.0, + options: [.allowUserInteraction, .beginFromCurrentState], + animations: { + self.backgroundColor = Self.normalBackgroundColor + }, + completion: nil + ) + } + } + } +}