Files
VoidLink/VoidLink/Input/ControllerNavigator.swift
T
2026-08-12 18:28:04 +08:00

2423 lines
108 KiB
Swift

//
// ControllerNavigator.swift
// VoidLink
//
// Created by True砖家 on 2026/7/1.
// Copyright © 2026 True砖家 on Bilibili. All rights reserved.
//
import Foundation
import GameController
import ObjectiveC
import UIKit
final class ControllerNavigationElement: NSObject {
var control:ControllerElement
var action:String
var isInAction:Bool = false
init(control: ControllerElement, action: String){
self.control = control
self.action = action
}
}
private var controllerNavigationSelectedIndexPathKey: UInt8 = 0
private var controllerNavigationPersistTokenKey: UInt8 = 0
private var controllerNavigationHighlightGenerationKey: UInt8 = 0
private var controllerMouseCurvePreviewViewKey: UInt8 = 0
private var controllerMouseCurvePreviewDismissWorkItemKey: UInt8 = 0
private let settingsControllerNavigationHighlightedIdentifierKey = "SettingsControllerNavigationHighlightedIdentifier"
private let hostControllerNavigationHighlightedUUIDKey = "HostControllerNavigationHighlightedUUID"
private let settingsExcludedControllerNavigationSectionIdentifiers: Set<String> = [
"SettingsSectionTouch&Controller",
"SettingsSectionPencil"
]
@available(iOS 13.0, *)
private final class ControllerMouseCurvePreviewView: UIView {
var expo: CGFloat = 1.0 {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
isOpaque = false
layer.cornerRadius = 14
layer.cornerCurve = .continuous
layer.masksToBounds = true
layer.borderWidth = 1
}
override func draw(_ rect: CGRect) {
let isDark = ThemeManager.userInterfaceStyle() == .dark
let backgroundColor = isDark
? UIColor(white: 0.08, alpha: 0.88)
: UIColor.white.withAlphaComponent(0.92)
let axisColor = ThemeManager.textColor.withAlphaComponent(isDark ? 0.62 : 0.48)
let textColor = ThemeManager.textColor.withAlphaComponent(isDark ? 0.78 : 0.68)
let curveColor = ThemeManager.appPrimaryColor
layer.borderColor = ThemeManager.appPrimaryColor.withAlphaComponent(isDark ? 0.32 : 0.22).cgColor
guard let context = UIGraphicsGetCurrentContext() else { return }
context.setFillColor(backgroundColor.cgColor)
context.fill(bounds)
let footerHeight: CGFloat = 22
let xLabelHeight: CGFloat = 24
let plotHorizontalMargin: CGFloat = 34
let chartRect = bounds.inset(by: UIEdgeInsets(top: 18, left: 0, bottom: footerHeight + xLabelHeight, right: 0))
let plotSide = min(bounds.width - plotHorizontalMargin * 2, chartRect.height)
let plotRect = CGRect(
x: bounds.midX - plotSide / 2,
y: chartRect.midY - plotSide / 2,
width: plotSide,
height: plotSide
)
guard plotRect.width > 1, plotRect.height > 1 else { return }
let textAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.roundedSystemFont(ofSize: 11, weight: .medium),
.foregroundColor: textColor
]
("Cursor Speed".localized as NSString).draw(at: CGPoint(x: plotRect.minX + 8, y: plotRect.minY + 8), withAttributes: textAttributes)
let xAxisTitle = "Stick Offset".localized as NSString
let xAxisTitleSize = xAxisTitle.size(withAttributes: textAttributes)
xAxisTitle.draw(at: CGPoint(x: plotRect.midX - xAxisTitleSize.width / 2, y: plotRect.maxY + 10), withAttributes: textAttributes)
context.setStrokeColor(axisColor.cgColor)
context.setLineWidth(1)
context.move(to: CGPoint(x: plotRect.minX, y: plotRect.minY))
context.addLine(to: CGPoint(x: plotRect.minX, y: plotRect.maxY))
context.addLine(to: CGPoint(x: plotRect.maxX, y: plotRect.maxY))
context.strokePath()
("0" as NSString).draw(at: CGPoint(x: plotRect.minX - 10, y: plotRect.maxY - 8), withAttributes: textAttributes)
("1" as NSString).draw(at: CGPoint(x: plotRect.minX - 10, y: plotRect.minY - 7), withAttributes: textAttributes)
("1" as NSString).draw(at: CGPoint(x: plotRect.maxX - 4, y: plotRect.maxY + 10), withAttributes: textAttributes)
let guidePath = UIBezierPath()
guidePath.move(to: CGPoint(x: plotRect.minX, y: plotRect.maxY))
guidePath.addLine(to: CGPoint(x: plotRect.maxX, y: plotRect.minY))
context.saveGState()
context.setLineDash(phase: 0, lengths: [4, 4])
axisColor.withAlphaComponent(0.35).setStroke()
guidePath.lineWidth = 1
guidePath.stroke()
context.restoreGState()
let curvePath = UIBezierPath()
for step in 0...120 {
let normalizedX = CGFloat(step) / 120
let speed = abs(PublicUtils.controllerMouseExpoMappedOffset(normalizedX, expo: expo))
let point = CGPoint(
x: plotRect.minX + normalizedX * plotRect.width,
y: plotRect.maxY - min(max(speed, 0), 1) * plotRect.height
)
if step == 0 {
curvePath.move(to: point)
} else {
curvePath.addLine(to: point)
}
}
curveColor.setStroke()
curvePath.lineWidth = 3
curvePath.lineCapStyle = .round
curvePath.lineJoinStyle = .round
curvePath.stroke()
let footerParagraphStyle = NSMutableParagraphStyle()
footerParagraphStyle.alignment = .center
let footerAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.roundedSystemFont(ofSize: 10, weight: .medium),
.foregroundColor: textColor.withAlphaComponent(0.76),
.paragraphStyle: footerParagraphStyle
]
("Inspired by RC hobby transmitters".localized as NSString).draw(
in: CGRect(x: 12, y: bounds.maxY - footerHeight + 1, width: bounds.width - 24, height: footerHeight - 2),
withAttributes: footerAttributes
)
}
}
private final class ControllerDrivenUIKitAnimationWakeToken {
private var wakeView: UIView?
private var toggled = false
func wake(attachedTo view: UIView) {
let container = view.window ?? view
let wakeView = preparedWakeView(in: container)
toggled.toggle()
UIView.animate(
withDuration: 0.6,
delay: 0,
options: [.allowUserInteraction, .beginFromCurrentState],
animations: {
wakeView.alpha = self.toggled ? 0.002 : 0.001
}
)
}
private func preparedWakeView(in container: UIView) -> UIView {
if let wakeView, wakeView.superview === container {
return wakeView
}
wakeView?.removeFromSuperview()
let wakeView = UIView(frame: CGRect(x: -2, y: -2, width: 1, height: 1))
wakeView.isUserInteractionEnabled = false
wakeView.backgroundColor = .black
wakeView.alpha = 0.001
container.addSubview(wakeView)
self.wakeView = wakeView
return wakeView
}
}
@available(iOS 13.0, *)
@objc protocol ControllerNavigatorRadialMenuDelegate: AnyObject {
func controllerNavigatorDidSelect(item: RadialMenuItem)
}
@available(iOS 13.0, *)
@objc protocol ControllerUINavigationDelegate: AnyObject {
func getNavigationElements() -> [ControllerNavigationElement]
func navigateByController(forward: Bool)
func navigateByController(downward: Bool)
func uiWidgetActionForControllerNavigator(forward: Bool, from navigation:ControllerNavigationElement)
func uiButtonActionForControllerNavigator(pressed: Bool, from navigation:ControllerNavigationElement)
func persistControllerNavigationHighlight()
func restoreControllerNavigationHighlight()
func restoreControllerNavigationHighlightAfterSettingsModeSwitch()
}
protocol ControllerNavigationHighlightTargetProviding: AnyObject {
var controllerNavigationHighlightTargetView: UIView { get }
func controllerNavigationHighlightDidApply()
func controllerNavigationHighlightDidClear()
}
extension ControllerNavigationHighlightTargetProviding {
func controllerNavigationHighlightDidApply() {
}
func controllerNavigationHighlightDidClear() {
}
}
@available(iOS 13.0, *)
protocol ControllerCollectionNavigationDelegate: ControllerUINavigationDelegate where Self: UIViewController {
var controllerNavigationCollectionView: UICollectionView { get }
func controllerNavigationCurrentIndexPathForControllerNavigator() -> IndexPath?
func clearCollectionControllerNavigationHighlightForControllerNavigator()
}
@available(iOS 13.0, *)
final class ControllerNavigator: NSObject {
typealias Handler = (_ buttonDict: NSDictionary, _ gamepad: GCExtendedGamepad, _ element: GCControllerElement) -> Void
private static weak var listeningController: GCController?
static weak var radialMenuDelegate: ControllerNavigatorRadialMenuDelegate?
@objc static weak var uiNavigationDelegate: ControllerUINavigationDelegate?
private static weak var previousUINavigationDelegate: ControllerUINavigationDelegate?
private static var swapABXY = false
@objc static var radialMenuView: RadialMenuOverlayView?
@objc static var stickReleasedInRadialMenu: Bool = false
@objc static var radialMenuButton: ControllerElement = .leftShoulder
@objc static var radialMenuButtonPool: NSMutableSet {
let pool:NSMutableSet = NSMutableSet()
pool.add(ControllerElement.leftTrigger.rawValue)
pool.add(ControllerElement.rightTrigger.rawValue)
pool.add(ControllerElement.leftShoulder.rawValue)
pool.add(ControllerElement.rightShoulder.rawValue)
pool.add(ControllerElement.select.rawValue)
pool.add(ControllerElement.start.rawValue)
pool.add(ControllerElement.special.rawValue)
pool.add(ControllerElement.paddle1.rawValue)
pool.add(ControllerElement.paddle2.rawValue)
pool.add(ControllerElement.paddle3.rawValue)
pool.add(ControllerElement.paddle4.rawValue)
return pool
}
@objc static var radialMenuButtonPosition: ControllerElementPosition {
var position:ControllerElementPosition = .undefined
if radialMenuButton.position != .middle && radialMenuButton.position != .undefined {position = radialMenuButton.position}
else {
if radialMenuButton == localRadialMenuButton {position = customPositionForLocalRadialMenuButton}
if radialMenuButton == streamingRadialMenuButton {position = customPositionForStreamingRadialMenuButton}
}
return position
}
@objc static var enabled: Bool = true {
didSet{
if enabled {
if let settingViewVC = uiNavigationDelegate as? SettingsViewController {
settingViewVC.highlightViewForControllerNavigator(by: "controllerNavigationStack")
GamepadNavigationIllustrationHud.updateHud()
}
}
else {
stop()
}
}
}
@objc static var localRadialMenuButton: ControllerElement = .leftShoulder
@objc static var customPositionForLocalRadialMenuButton: ControllerElementPosition = .left
@objc static var streamingRadialMenuButton: ControllerElement = .leftShoulder
@objc static var customPositionForStreamingRadialMenuButton: ControllerElementPosition = .left
@objc static var streamingRadialMenuDelay: TimeInterval = 0
private static var controllerMouseTimer: SafeTimer?
@objc static var controllerMouseStick: ControllerElement = .rightStick
@objc static var controllerMouseLeftButton: ControllerElement = .dpadRight
@objc static var controllerMouseRightButton: ControllerElement = .dpadDown
@objc static var controllerMouseVelocity: CGFloat = 11.3
@objc static var controllerMouseExpo: CGFloat = 1.6
private static var controllerMouseInputX: CGFloat = 0
private static var controllerMouseInputY: CGFloat = 0
private static var controllerMouseWheelInputX: CGFloat = 0
private static var controllerMouseWheelInputY: CGFloat = 0
private static var controllerMouseFrameRate = 60
@objc static var controllerMouseEnabled: Bool {
return radialMenuState == .mouseModeEnabled
}
private static var controllerMouseNavigationElements: [ControllerNavigationElement] {
var elements = [ControllerNavigationElement]()
elements.append(ControllerNavigationElement(control:ControllerNavigator.controllerMouseStick, action: "moveCursor"))
elements.append(ControllerNavigationElement(control:ControllerNavigator.controllerMouseLeftButton, action: "leftButton"))
elements.append(ControllerNavigationElement(control:ControllerNavigator.controllerMouseRightButton, action: "rightButton"))
elements.append(ControllerNavigationElement(control:ControllerNavigator.controllerMouseStick.position == .left ? .rightStick : .leftStick, action: "verticalHorizontalScroll"))
return elements
}
static var settingsFavoriteReorderActive: Bool = false
static var settingsReadTipPendingToken = 0
static var settingsReadTipPendingWorkItem: DispatchWorkItem?
static var settingsReadTipPressedAgain = false
static var settingsReadTipSuppressNextRelease = false
static var navigationTimer: SafeTimer?
private static let controllerDrivenUIKitAnimationWakeToken = ControllerDrivenUIKitAnimationWakeToken()
@objc static weak var controllerNavigationHighlightedView: UIView?
private static let installAlertControllerNavigationHook: Void = {
UIAlertController.installControllerNavigationDelegateHook()
}()
static func wakeControllerDrivenUIKitAnimationIfNeeded(attachedTo view: UIView) {
controllerDrivenUIKitAnimationWakeToken.wake(attachedTo: view)
}
@objc static func setRadialMenuDelegate(_ delegate: ControllerNavigatorRadialMenuDelegate?) {
_ = installAlertControllerNavigationHook
radialMenuDelegate = delegate
}
@objc static func setUINavigationDelegate(_ delegate: ControllerUINavigationDelegate?) {
_ = installAlertControllerNavigationHook
if let currentDelegate = uiNavigationDelegate,
let nextDelegate = delegate,
currentDelegate !== nextDelegate, !(currentDelegate is UIAlertController) {
previousUINavigationDelegate = currentDelegate
}
uiNavigationDelegate = delegate
updateUINavigationDelegateState()
}
@objc static func setStreamFrameVCAsUINavigationDelegate() {
if let delegate: ControllerUINavigationDelegate? = StreamFrameViewController.sharedInstance() {
_ = installAlertControllerNavigationHook
if let currentDelegate = uiNavigationDelegate,
let nextDelegate = delegate,
currentDelegate !== nextDelegate, !(currentDelegate is UIAlertController) {
previousUINavigationDelegate = currentDelegate
}
uiNavigationDelegate = delegate
listenToRadialMenuButton()
GamepadNavigationIllustrationHud.updateHud()
}
}
@objc static func restorePreviousUINavigationDelegate(ifCurrentDelegateIs delegate: ControllerUINavigationDelegate) {
guard uiNavigationDelegate === delegate else { return }
/*
if previousUINavigationDelegate is StreamFrameViewController {
ControllerSupport.sharedInstance().reinitiatePrimaryController()
return
} */
uiNavigationDelegate = previousUINavigationDelegate
// previousUINavigationDelegate = nil
updateUINavigationDelegateState()
}
static func updateUINavigationDelegateState() {
ControllerUtil.disableSysGestures(ControllerUtil.primaryGCController)
if uiNavigationDelegate is StreamFrameViewController {
if radialMenuState == .mouseModeEnabled {
startControllerMouse()
// GamepadNavigationIllustrationHud.updateNavigationElements(controllerMouseNavigationElements)
return
}
ControllerSupport.sharedInstance().reinitiatePrimaryController()
GamepadNavigationIllustrationHud.updateHud()
return;
}
restartListening()
GamepadNavigationIllustrationHud.updateHud()
}
@objc static func restoreUINavigationHighlight() {
guard enabled else {return}
uiNavigationDelegate?.restoreControllerNavigationHighlight()
}
@objc static func persistUINavigationHighlight() {
guard enabled else {return}
uiNavigationDelegate?.persistControllerNavigationHighlight()
if previousUINavigationDelegate !== uiNavigationDelegate {
previousUINavigationDelegate?.persistControllerNavigationHighlight()
}
}
@objc static func restoreSettingsModeSwitchHighlight() {
guard enabled else {return}
uiNavigationDelegate?.restoreControllerNavigationHighlightAfterSettingsModeSwitch()
}
@objc static func updateHudForCustomRadialMenuButtonPosition() {
var elements: [ControllerNavigationElement] = []
elements.append(ControllerNavigationElement(control: .dpadLeft, action: "Left"))
elements.append(ControllerNavigationElement(control: .dpadRight, action: "Right"))
elements.append(ControllerNavigationElement(control: .dpadUp, action: "Centered (PlayStation)"))
GamepadNavigationIllustrationHud.updateNavigationElements(elements, forceDisplay: true)
}
/// Controller Mouse
@objc static func startControllerMouse() {
controllerMouseInputX = 0
controllerMouseInputY = 0
startControllerMouseTimer(frameRate: controllerMouseFrameRate)
ControllerUtil.stopListeningPrimaryController()
listenToControllerMouse()
DispatchQueue.main.async {
GamepadNavigationIllustrationHud.updateNavigationElements(controllerMouseNavigationElements)
DispatchQueue.main.asyncAfter(deadline: .now()+2) {
GamepadNavigationIllustrationHud.clearHud()
}
}
}
@objc static func configureControllerMouse(with settings: TemporarySettings) {
// controllerMouseEnabledFlag = false
controllerMouseStick = ControllerElement(rawValue: settings.controllerMouseStick.int32Value) ?? .rightStick
controllerMouseLeftButton = ControllerElement(rawValue: settings.controllerMouseLeftButton.int32Value) ?? .dpadRight
controllerMouseRightButton = ControllerElement(rawValue: settings.controllerMouseRightButton.int32Value) ?? .dpadDown
controllerMouseExpo = CGFloat(settings.controllerMouseExpo.floatValue)
controllerMouseFrameRate = max(settings.framerate.intValue, 1)
controllerMouseVelocity = PublicUtils.controllerMouseVelocity(
pointerVelocity: CGFloat(settings.controllerMousePointerVelocity.floatValue),
frameRate: CGFloat(controllerMouseFrameRate)
)
}
private static func startControllerMouseTimer(frameRate: Int) {
guard radialMenuState == .mouseModeEnabled else {
return
}
controllerMouseTimer = SafeTimer(interval: 1/TimeInterval(frameRate)) {
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.002) {
guard radialMenuState == .mouseModeEnabled else { return }
let moveVector = PublicUtils.controllerMouseMoveVector(
stickX: controllerMouseInputX,
stickY: controllerMouseInputY,
velocity: controllerMouseVelocity,
expo: controllerMouseExpo
)
LiSendMouseMoveEvent(clampedInt16(moveVector.dx), clampedInt16(moveVector.dy))
let scrollVector = PublicUtils.controllerMouseScrollVector(
stickX: controllerMouseWheelInputX,
stickY: controllerMouseWheelInputY,
multiplier: 15
)
if abs(scrollVector.dy) > abs(scrollVector.dx) {
LiSendHighResScrollEvent(clampedInt16(scrollVector.dy))
}
else {
LiSendHighResHScrollEvent(clampedInt16(scrollVector.dx))
}
}
}
controllerMouseTimer?.restart()
}
private static func stopControllerMouseTimer() {
controllerMouseInputX = 0
controllerMouseInputY = 0
controllerMouseTimer?.clean()
}
private static func swappedFaceButtonIfNeeded(_ button: ControllerElement, swapABXY: Bool) -> ControllerElement {
guard swapABXY else { return button }
switch button {
case .a:
return .b
case .b:
return .a
case .x:
return .y
case .y:
return .x
default:
return button
}
}
private static func clampedInt16(_ value: CGFloat) -> Int16 {
let roundedValue = value.rounded()
if roundedValue > CGFloat(Int16.max) {
return Int16.max
}
if roundedValue < CGFloat(Int16.min) {
return Int16.min
}
return Int16(roundedValue)
}
/*
@objc static func start(swapABXY: Bool = false) {
Self.swapABXY = swapABXY
installControllerObserversIfNeeded()
listenFirstAvailableController()
}
*/
private static func navigateContinuously(forward: Bool) {
navigationTimer?.clean()
navigationTimer = SafeTimer(interval: 0.23) {
uiNavigationDelegate?.navigateByController(forward: forward)
}
navigationTimer?.restart()
}
private static func navigateContinuously(downward: Bool) {
navigationTimer?.clean()
navigationTimer = SafeTimer(interval: 0.23) {
uiNavigationDelegate?.navigateByController(downward: downward)
}
navigationTimer?.restart()
}
@objc static func start() {
guard let controller = ControllerUtil.primaryGCController, controller.extendedGamepad != nil else { return }
restartListening()
GamepadNavigationIllustrationHud.updateHud()
}
@objc static func stop() {
ControllerUtil.stopListeningPrimaryController(stopListenToRadialMenuButton: true)
radialMenuView?.dismiss()
radialMenuView = nil
GamepadNavigationIllustrationHud.clearHud()
clearNavigationHighlights()
}
@objc static func clearNavigationHighlights() {
PublicUtils.runOnMain {
if let mainFrameVC = radialMenuDelegate as? MainFrameViewController {
mainFrameVC.settingsViewController?.clearControllerNavigationHighlightForControllerNavigator()
mainFrameVC.hostCollectionVC?.clearCollectionControllerNavigationHighlightForControllerNavigator()
} else {
(uiNavigationDelegate as? SettingsViewController)?.clearControllerNavigationHighlightForControllerNavigator()
(uiNavigationDelegate as? ControllerCollectionNavigationDelegate)?.clearCollectionControllerNavigationHighlightForControllerNavigator()
}
controllerNavigationHighlightedView = nil
}
}
private static func performRadialMenuAction(_ item: RadialMenuItem) {
radialMenuDelegate?.controllerNavigatorDidSelect(item:item)
switch item {
case .gameProfiles, .addHost, .aboutView:
radialMenuView?.dismiss()
radialMenuView = nil
case .disconnect:
if radialMenuState == .disconnectAndQuit {
radialMenuView?.dismiss()
radialMenuView = nil
StreamFrameViewController.sharedInstance().returnToMainFrame()
break
}
else {
radialMenuState = .disconnectAndQuit
updateRadialMenu()
break
}
case .quitApp:
radialMenuView?.dismiss()
radialMenuView = nil
StreamFrameViewController.sharedInstance().disconnectAndQuitApp()
case .more:
radialMenuState = .moreOptions
updateRadialMenu()
case .mouse:
if radialMenuState != .mouseModeEnabled {
radialMenuState = .mouseModeEnabled
updateRadialMenu()
}
else {
stopControllerMouseTimer()
radialMenuState = .main
updateRadialMenu()
}
case .theme:
let targetTheme: UIUserInterfaceStyle = ThemeManager.userInterfaceStyle() == .light ? .dark : .light
DispatchQueue.main.async {
ThemeManager.setUserInterfaceStyle(targetTheme)
if let mainFrameVC = radialMenuDelegate as? MainFrameViewController, let settingsViewVC = mainFrameVC.settingsViewController {
settingsViewVC.appThemeSelector.selectedSegmentIndex = targetTheme.rawValue
}
}
let dataMan = DataManager()
let settings = dataMan.retrieveSettings()
settings?.appTheme = NSNumber(value: targetTheme.rawValue)
dataMan.saveData()
case .navigationSettings:
DispatchQueue.main.async {
radialMenuView?.dismiss()
radialMenuView = nil
if let mainFrameVC = radialMenuDelegate as? MainFrameViewController {
mainFrameVC.expandSettingsView()
DispatchQueue.main.asyncAfter(deadline: .now()+0.3) {
if let settingViewVC = uiNavigationDelegate as? SettingsViewController {
settingViewVC.expandGamepadSection()
settingViewVC.highlightViewForControllerNavigator(by: "controllerNavigationStack")
}
}
}
}
case .exit:
exit(0)
case .toolbox:
DispatchQueue.main.async {
radialMenuView?.dismiss()
radialMenuView = nil
StreamFrameViewController.sharedInstance().bringUpToolboxMenuWithoutWidgetLayoutTool()
}
default:
break
}
}
@objc static func updateRadialMenu() {
controllerMouseInputX = 0
controllerMouseInputY = 0
navigationTimer?.clean()
ControllerUtil.stopListeningPrimaryController()
if let mainFrameVC = radialMenuDelegate as? MainFrameViewController {
let streamFrameVC = StreamFrameViewController.sharedInstance()
let showRadialMenu = mainFrameVC.isStreaming() ? streamFrameVC?.hasNoPresentedVC == true : (mainFrameVC.hasNoPresentedVC || mainFrameVC.presentedViewController is LoadingFrameViewController)
if showRadialMenu {
radialMenuView?.removeFromSuperview()
RadialMenuOverlayView.menuSectors.removeAll()
if radialMenuState == .disconnectAndQuit {
RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Quit App".localized, subtitle: "", symbol: PublicUtils.quitSymbol(), item: .quitApp))
RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Disconnect".localized, subtitle: "", symbol: PublicUtils.disconnectSymbol(), item: .disconnect))
radialMenuView = RadialMenuOverlayView.presentInKeyWindow()
listenToRadialMenuStick()
return
}
let navigationSettingsItem = RadialMenuSector(title: "Navigation Settings".localized, subtitle: "", symbol:"gear", item: .navigationSettings)
if radialMenuState == .moreOptions {
if mainFrameVC.isStreaming() {
RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Game Profiles".localized, subtitle: "", symbol:PublicUtils.iOS18Available ? "gamecontroller.circle" : "gamecontroller.fill", item: .gameProfiles))
RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "=toolbox".localized, subtitle: "", symbol: "apple.terminal", item: .toolbox))
}
else {
if !mainFrameVC.isInAppView(), !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "About".localized, subtitle: "", symbol: "questionmark.circle", item: .aboutView))}
if !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Theme".localized, subtitle: "", symbol: "circle.lefthalf.filled", item: .theme))}
if !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.appendIfNotContains(navigationSettingsItem)}
if !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Exit".localized, subtitle: "", symbol: "rectangle.portrait.and.arrow.right", item: .exit))}
}
RadialMenuOverlayView.menuSectors.appendIfNotContains(navigationSettingsItem)
radialMenuView = RadialMenuOverlayView.presentInKeyWindow()
listenToRadialMenuStick()
return
}
let gameProfileItem = RadialMenuSector(title: "Game Profiles".localized, subtitle: "", symbol:PublicUtils.iOS18Available ? "gamecontroller.circle" : "gamecontroller.fill", item: .gameProfiles)
RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Settings Menu".localized, subtitle: "", symbol: "sidebar.left", item: .settings))
if mainFrameVC.settingsViewExpanded {
if mainFrameVC.settingsViewController.currentSettingsMenuMode == .AllSettings {
RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Favorite Settings".localized, subtitle: "", symbol: "bookmark", item: .favoriteSettings))
RadialMenuOverlayView.menuSectors.appendIfNotContains(gameProfileItem)
RadialMenuOverlayView.menuSectors.appendIfNotContains(navigationSettingsItem)
}
if mainFrameVC.settingsViewController.currentSettingsMenuMode == .FavoriteSettings {
RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "All Settings".localized, subtitle: "", symbol: "circle.grid.3x3", item: .allSettings))
}
}
if !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "=more".localized, subtitle: "", symbol: "ellipsis.circle", item: .more))}
if !mainFrameVC.isStreaming() || radialMenuState == .moreOptions { RadialMenuOverlayView.menuSectors.appendIfNotContains(gameProfileItem)
}
if mainFrameVC.isInAppView(), !mainFrameVC.isStreaming(), !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Host View".localized, subtitle: "", symbol: PublicUtils.liquidGlassEnabled ? "macwindow.on.rectangle" : "tv", item: .hostView))}
if !mainFrameVC.isInAppView(), !mainFrameVC.isStreaming(), !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Add Host".localized, subtitle: "", symbol: "plus.circle", item: .addHost))}
if mainFrameVC.isStreaming(), !mainFrameVC.settingsExpandedInStreamView {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Controller Mouse".localized, subtitle: "", symbol: radialMenuState == .mouseModeEnabled ? "disableControllerMouse" : "controllerMouse", item: .mouse))}
if mainFrameVC.isStreaming(), !mainFrameVC.settingsExpandedInStreamView {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Disconnect".localized, subtitle: "", symbol: PublicUtils.disconnectSymbol(), item: .disconnect))}
// if mainFrameVC.isStreaming(), !mainFrameVC.settingsExpandedInStreamView {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Quit App".localized, subtitle: "", symbol: PublicUtils.quitSymbol(), item: .quitApp))}
radialMenuView = RadialMenuOverlayView.presentInKeyWindow()
}
}
listenToRadialMenuStick()
}
private static func listenToRadialMenuStick() {
let radialMenuStick: ControllerElement = radialMenuButtonPosition == .right ? .leftStick : .rightStick
ControllerUtil.listenPrimaryControllerStick(radialMenuStick) { offsetVector in
GamepadNavigationIllustrationHud.updateActionState(for: radialMenuStick, isInAction: hypot(offsetVector.dx, offsetVector.dy) >= 0.1)
if let selectedItem = radialMenuView?.updateSelection(
xOffset: offsetVector.dx,
yOffset: offsetVector.dy
) {
PublicUtils.runOnMain {
stickReleasedInRadialMenu = true
performRadialMenuAction(selectedItem)
}
}
}
}
@objc static var radialMenuButtonPressed: Bool = false
private static var radialMenuDelayTimer: SafeTimer?
private static var radialMenuState: RadialMenuState = .main
private static var radialMenuPressDownTimestamp: TimeInterval = 0.0
@objc static func listenToRadialMenuButton() {
guard enabled else {return}
guard let mainFrameVC = radialMenuDelegate as? MainFrameViewController else {return}
radialMenuState = .main
radialMenuButton = mainFrameVC.isStreaming() ? streamingRadialMenuButton : localRadialMenuButton
ControllerUtil.listenPrimaryControllerButton(radialMenuButton) { pressed in
GamepadNavigationIllustrationHud.updateActionState(for: radialMenuButton, isInAction: pressed)
if pressed {
radialMenuPressDownTimestamp = CACurrentMediaTime()
radialMenuDelayTimer?.clean()
radialMenuButtonPressed = true
let delayRadialMenu = (mainFrameVC.isStreaming() && !mainFrameVC.settingsExpandedInStreamView) || streamingRadialMenuDelay < 0.03
streamingRadialMenuDelay = max(streamingRadialMenuDelay, 0.032)
if delayRadialMenu {
radialMenuDelayTimer = SafeTimer(interval: streamingRadialMenuDelay) {
if radialMenuDelayTimer?.remainingMinimumRunCount == 2 {return}
if radialMenuButtonPressed {
DispatchQueue.main.async {
self.updateRadialMenu()
radialMenuDelayTimer?.clean()
}
}
}
radialMenuDelayTimer?.restart(minimumRunCount: 2)
}
else {
self.updateRadialMenu()
}
}
else {
guard CACurrentMediaTime() - radialMenuPressDownTimestamp > 0.035 else {
return
}
radialMenuButtonPressed = false
radialMenuDelayTimer?.clean()
stickReleasedInRadialMenu = false
if radialMenuView == nil, uiNavigationDelegate is StreamFrameViewController {
ControllerSupport.sharedInstance().sendNavigationButtonPress()
return
}
radialMenuView?.dismiss()
radialMenuView = nil
ControllerNavigator.updateUINavigationDelegateState()
}
}
}
private static func listenToNavigationCluster() {
guard let mainFrameVC = radialMenuDelegate as? MainFrameViewController else {return}
let upNavButton: ControllerElement = ControllerNavigator.radialMenuButtonPosition == .right ? .dpadUp : .y
let downNavButton: ControllerElement = ControllerNavigator.radialMenuButtonPosition == .right ? .dpadDown : .a
let leftNavButton: ControllerElement = ControllerNavigator.radialMenuButtonPosition == .right ? .dpadLeft : .x
let rightNavButton: ControllerElement = ControllerNavigator.radialMenuButtonPosition == .right ? .dpadRight : .b
if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() || mainFrameVC.settingsExpandedInStreamView || uiNavigationDelegate is ToolboxViewController || uiNavigationDelegate is ProfileSelectorViewController {
let verticalNavigationAxis: ControllerElement = ControllerNavigator.radialMenuButtonPosition == .right ? .leftStickY : .rightStickY
ControllerUtil.listenPrimaryControllerStickAxis(verticalNavigationAxis, threshold: 0.6) {state in
GamepadNavigationIllustrationHud.updateActionState(for: verticalNavigationAxis, isInAction: state != .orderedSame)
guard radialMenuView == nil else {return}
switch state {
case .orderedAscending:
if navigationTimer?.isRunning() != true {
if uiNavigationDelegate is SettingsViewController {
navigateContinuously(downward: true)
}
if uiNavigationDelegate is ControllerCollectionNavigationDelegate {
navigateContinuously(downward: true)
}
}
case .orderedDescending:
if navigationTimer?.isRunning() != true {
if uiNavigationDelegate is SettingsViewController {
navigateContinuously(downward: false)
}
if uiNavigationDelegate is ControllerCollectionNavigationDelegate {
navigateContinuously(downward: false)
}
}
case .orderedSame:
navigationTimer?.clean()
}
}
ControllerUtil.listenPrimaryControllerButton(upNavButton) {pressed in
GamepadNavigationIllustrationHud.updateActionState(for: upNavButton, isInAction: pressed)
if pressed {
uiNavigationDelegate.navigateByController(downward: false)
}
}
ControllerUtil.listenPrimaryControllerButton(downNavButton) {pressed in
GamepadNavigationIllustrationHud.updateActionState(for: downNavButton, isInAction: pressed)
if pressed {
uiNavigationDelegate.navigateByController(downward: true)
}
}
}
if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() || uiNavigationDelegate is ToolboxViewController || uiNavigationDelegate is ProfileSelectorViewController {
let horizontalNavigationAxis: ControllerElement = ControllerNavigator.radialMenuButtonPosition == .right ? .leftStickX : .rightStickX
ControllerUtil.listenPrimaryControllerStickAxis(horizontalNavigationAxis, threshold: 0.6) {state in
GamepadNavigationIllustrationHud.updateActionState(for: horizontalNavigationAxis, isInAction: state != .orderedSame)
guard radialMenuView == nil else {return}
switch state {
case .orderedAscending:
if navigationTimer?.isRunning() != true {
if uiNavigationDelegate is ControllerCollectionNavigationDelegate {
navigateContinuously(forward: false)
}
}
case .orderedDescending:
if navigationTimer?.isRunning() != true {
if uiNavigationDelegate is ControllerCollectionNavigationDelegate {
navigateContinuously(forward: true)
}
}
case .orderedSame:
navigationTimer?.clean()
}
}
ControllerUtil.listenPrimaryControllerButton(leftNavButton) {pressed in
GamepadNavigationIllustrationHud.updateActionState(for: leftNavButton, isInAction: pressed)
if pressed {
uiNavigationDelegate.navigateByController(forward: false)
}
}
ControllerUtil.listenPrimaryControllerButton(rightNavButton) {pressed in
GamepadNavigationIllustrationHud.updateActionState(for: rightNavButton, isInAction: pressed)
if pressed {
uiNavigationDelegate.navigateByController(forward: true)
}
}
}
}
@objc static func listenToControllerMouse() {
guard radialMenuState == .mouseModeEnabled else { return }
ControllerUtil.listenPrimaryControllerButton(controllerMouseLeftButton){ pressed in
GamepadNavigationIllustrationHud.updateActionState(for: controllerMouseLeftButton, isInAction: pressed)
LiSendMouseButtonEvent(CChar(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE), BUTTON_LEFT)
}
ControllerUtil.listenPrimaryControllerButton(controllerMouseRightButton){ pressed in
GamepadNavigationIllustrationHud.updateActionState(for: controllerMouseRightButton, isInAction: pressed)
LiSendMouseButtonEvent(CChar(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE), BUTTON_RIGHT)
}
ControllerUtil.listenPrimaryControllerStick(controllerMouseStick){ offsetVector in
GamepadNavigationIllustrationHud.updateActionState(for: controllerMouseStick, isInAction: hypot(offsetVector.dx, offsetVector.dy) >= 0.1)
// print("performRadialMenuAction mouse \(offsetVector) \(CACurrentMediaTime())")
controllerMouseInputX = offsetVector.dx
controllerMouseInputY = offsetVector.dy
}
let wheelStick: ControllerElement = controllerMouseStick == .rightStick ? .leftStick : .rightStick
ControllerUtil.listenPrimaryControllerStick(wheelStick) { offsetVector in
GamepadNavigationIllustrationHud.updateActionState(for: wheelStick, isInAction: hypot(offsetVector.dx, offsetVector.dy) >= 0.1)
controllerMouseWheelInputX = offsetVector.dx
controllerMouseWheelInputY = offsetVector.dy
}
}
@objc static func restartListening() {
guard enabled else {
ControllerUtil.stopListeningPrimaryController(stopListenToRadialMenuButton: true)
GamepadNavigationIllustrationHud.clearHud()
return
}
ControllerUtil.stopListeningPrimaryController()
GamepadNavigationIllustrationHud.resetActionStates()
guard let mainFrameVC = radialMenuDelegate as? MainFrameViewController else {return}
listenToRadialMenuButton()
listenToRadialMenuStick()
listenToNavigationCluster()
/*
if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() || mainFrameVC.settingsExpandedInStreamView || uiNavigationDelegate is ToolboxViewController || uiNavigationDelegate is ProfileSelectorViewController {
let verticalNavigationAxis: ControllerElement = ControllerNavigator.radialMenuButtonPosition == .right ? .leftStickY : .rightStickY
ControllerUtil.listenPrimaryControllerStickAxis(verticalNavigationAxis, threshold: 0.6) {state in
GamepadNavigationIllustrationHud.updateActionState(for: verticalNavigationAxis, isInAction: state != .orderedSame)
guard radialMenuView == nil else {return}
switch state {
case .orderedAscending:
if navigationTimer?.isRunning() != true {
if uiNavigationDelegate is SettingsViewController {
navigateContinuously(forward: true)
}
if uiNavigationDelegate is ControllerCollectionNavigationDelegate {
navigateContinuously(downward: true)
}
}
case .orderedDescending:
if navigationTimer?.isRunning() != true {
if uiNavigationDelegate is SettingsViewController {
navigateContinuously(forward: false)
}
if uiNavigationDelegate is ControllerCollectionNavigationDelegate {
navigateContinuously(downward: false)
}
}
case .orderedSame:
navigationTimer?.clean()
}
}
}
if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() || uiNavigationDelegate is ToolboxViewController || uiNavigationDelegate is ProfileSelectorViewController {
let horizontalNavigationAxis: ControllerElement = ControllerNavigator.radialMenuButtonPosition == .right ? .leftStickX : .rightStickX
ControllerUtil.listenPrimaryControllerStickAxis(horizontalNavigationAxis, threshold: 0.6) {state in
GamepadNavigationIllustrationHud.updateActionState(for: horizontalNavigationAxis, isInAction: state != .orderedSame)
guard radialMenuView == nil else {return}
switch state {
case .orderedAscending:
if navigationTimer?.isRunning() != true {
if uiNavigationDelegate is ControllerCollectionNavigationDelegate {
navigateContinuously(forward: false)
}
}
case .orderedDescending:
if navigationTimer?.isRunning() != true {
if uiNavigationDelegate is ControllerCollectionNavigationDelegate {
navigateContinuously(forward: true)
}
}
case .orderedSame:
navigationTimer?.clean()
}
}
}
*/
if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() || mainFrameVC.settingsExpandedInStreamView || uiNavigationDelegate is UIAlertController || uiNavigationDelegate is ProfileSelectorViewController || uiNavigationDelegate is ToolboxViewController {
let navigations = uiNavigationDelegate.getNavigationElements()
let buttonNavigations = navigations.filter({$0.control.type == .button})
// let stickNavigations = navigations.filter({$0.control.type == .stick || $0.control.type == .stickAxis})
var listenedControls = Set<ControllerElement>()
for buttonNavigation in buttonNavigations {
if buttonNavigation.control == radialMenuButton {continue}
if listenedControls.contains(buttonNavigation.control) {continue}
ControllerUtil.listenPrimaryControllerButton(buttonNavigation.control) {pressed in
buttonNavigation.isInAction = pressed
GamepadNavigationIllustrationHud.updateActionState(for: buttonNavigation.control, isInAction: pressed)
uiNavigationDelegate.uiButtonActionForControllerNavigator(pressed: pressed, from: buttonNavigation)
// print("buttonElement.control \(buttonElement.control.displayName) \(pressed)")
if !pressed {
navigationTimer?.clean()
}
}
listenedControls.insert(buttonNavigation.control)
}
}
/*
ControllerUtil.listenPrimaryControllerStickAxis(.leftStickX, threshold: 0.8) {state in
guard uiNavigationDelegate != nil, radialMenuView == nil else {return}
switch state {
case .orderedAscending:
if navigationTimer?.isRunning() != true {uiNavigationDelegate?.uiWidgetActionForControllerNavigator(forward: false)}
case .orderedDescending:
if navigationTimer?.isRunning() != true {uiNavigationDelegate?.uiWidgetActionForControllerNavigator(forward: true)}
case .orderedSame:
navigationTimer?.clean()
}
}
*/
}
}
@available(iOS 13.0, *)
extension SettingsViewController: ControllerUINavigationDelegate {
@objc(showControllerMouseCurvePreviewWithExpo:)
func showControllerMouseCurvePreview(expo: CGFloat) {
PublicUtils.runOnMain { [weak self] in
self?.showControllerMouseCurvePreviewOnMain(expo: expo)
}
}
@objc func persistControllerNavigationHighlight() {
guard let highlightedView = ControllerNavigator.controllerNavigationHighlightedView,
let identifier = controllerNavigationPersistenceIdentifier(for: highlightedView) else {
return
}
UserDefaults.standard.set(identifier, forKey: settingsControllerNavigationHighlightedIdentifierKey)
}
private var controllerMouseCurvePreviewView: ControllerMouseCurvePreviewView? {
get {
objc_getAssociatedObject(self, &controllerMouseCurvePreviewViewKey) as? ControllerMouseCurvePreviewView
}
set {
objc_setAssociatedObject(self, &controllerMouseCurvePreviewViewKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
private var controllerMouseCurvePreviewDismissWorkItem: DispatchWorkItem? {
get {
objc_getAssociatedObject(self, &controllerMouseCurvePreviewDismissWorkItemKey) as? DispatchWorkItem
}
set {
objc_setAssociatedObject(self, &controllerMouseCurvePreviewDismissWorkItemKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
private func showControllerMouseCurvePreviewOnMain(expo: CGFloat) {
let previewView = controllerMouseCurvePreviewView ?? ControllerMouseCurvePreviewView()
controllerMouseCurvePreviewView = previewView
previewView.expo = expo
previewView.translatesAutoresizingMaskIntoConstraints = false
if previewView.superview == nil {
previewView.alpha = 0
view.addSubview(previewView)
let preferredWidth = previewView.widthAnchor.constraint(equalTo: view.safeAreaLayoutGuide.widthAnchor, multiplier: 0.72)
preferredWidth.priority = .defaultHigh
NSLayoutConstraint.activate([
previewView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
previewView.widthAnchor.constraint(lessThanOrEqualToConstant: 360),
preferredWidth,
previewView.heightAnchor.constraint(equalToConstant: 190),
previewView.bottomAnchor.constraint(equalTo: controllerMouseExpoStack.topAnchor, constant: -10)
])
}
view.bringSubviewToFront(previewView)
UIView.animate(withDuration: 0.12) {
previewView.alpha = 1
}
controllerMouseCurvePreviewDismissWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self, weak previewView] in
UIView.animate(withDuration: 0.18, animations: {
previewView?.alpha = 0
}, completion: { _ in
previewView?.removeFromSuperview()
if self?.controllerMouseCurvePreviewView === previewView {
self?.controllerMouseCurvePreviewView = nil
}
})
}
controllerMouseCurvePreviewDismissWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
}
@objc func restoreControllerNavigationHighlight() {
guard ControllerNavigator.enabled, ControllerUtil.primaryGCController != nil else {return}
PublicUtils.runOnMain { [weak self] in
self?.restoreControllerNavigationHighlightOnMain()
}
}
@objc func restoreControllerNavigationHighlightAfterSettingsModeSwitch() {
PublicUtils.runOnMain { [weak self] in
self?.restoreControllerNavigationHighlightAfterSettingsModeSwitchOnMain()
}
}
@objc func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) {
let isHighlightingUIStack = ControllerNavigator.controllerNavigationHighlightedView is UIStackView
if navigation.action == "holdToReorder", isHighlightingUIStack {
handleControllerNavigationReorderHold(pressed: pressed)
return
}
if isHighlightingUIStack, navigation.action == "readTip" || navigation.action == "doublePressToDelete" || navigation.action == "doublePressToAddFavorite" {
PublicUtils.runOnMain { [weak self] in
self?.handleControllerNavigationReadTip(pressed: pressed)
}
return
}
if pressed {
switch navigation.action {
case "widgetOperationBackward":
self.uiWidgetActionForControllerNavigator(forward: false, from: navigation)
case "widgetOperationForward":
self.uiWidgetActionForControllerNavigator(forward: true, from: navigation)
default:
break
}
}
}
func getNavigationElements() -> [ControllerNavigationElement] {
var elements: [ControllerNavigationElement] = []
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .a, action: "readTip"))
if self.currentSettingsMenuMode == .AllSettings {
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .a, action: "doublePressToAddFavorite"))
}
if self.currentSettingsMenuMode == .FavoriteSettings {
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .a, action: "doublePressToDelete"))
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadDown : .y, action: "holdToReorder"))
}
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButton, action: "radialMenu"))
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .rightStickY : .leftStickY, action: "menuNavigation"))
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .abxy : .dpad, action: "menuNavigation"))
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadLeft : .x, action: "widgetOperationBackward"))
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .b, action: "widgetOperationForward"))
return elements
}
func navigateByController(downward:Bool) {
guard self.hasNoPresentedVC else {return}
if currentSettingsMenuMode == .FavoriteSettings,
ControllerNavigator.settingsFavoriteReorderActive {
moveHighlightedFavoriteSettingStack(by: downward ? 1 : -1)
return
}
performControllerNavigationSelectionMove(by: downward ? 1 : -1)
}
func navigateByController(forward:Bool) {
}
func uiWidgetActionForControllerNavigator(forward: Bool, from navigation:ControllerNavigationElement) {
guard self.hasNoPresentedVC else {return}
if let highlightedView = ControllerNavigator.controllerNavigationHighlightedView, highlightedView is UIButton {
if let section = highlightedView.superview as? MenuSectionView {
ControllerNavigator.wakeControllerDrivenUIKitAnimationIfNeeded(attachedTo: highlightedView)
DispatchQueue.main.async{
section.toggleFold()
}
}
}
if let stack = ControllerNavigator.controllerNavigationHighlightedView as? UIStackView {
for view in stack.arrangedSubviews {
if let selector = view as? UISegmentedControl {
guard selector.isEnabled else {continue}
let updateSelector = {
var targetIndex:Int = 0
repeat {
targetIndex = selector.nextIndex(forward: forward)
selector.selectedSegmentIndex = targetIndex
} while !selector.isEnabledForSegment(at: targetIndex)
selector.sendActions(for: .valueChanged)
}
ControllerNavigator.wakeControllerDrivenUIKitAnimationIfNeeded(attachedTo: selector)
DispatchQueue.main.async(execute: updateSelector)
}
if let uiSwitch = view as? UISwitch {
guard uiSwitch.isEnabled else {continue}
uiSwitch.isOn = !uiSwitch.isOn
uiSwitch.sendActions(for: .valueChanged)
}
if let slider = view as? UISlider {
guard slider.isEnabled else {continue}
ControllerNavigator.navigationTimer?.clean()
slider.step(forward: forward, visualStepRatio: 0.02)
DispatchQueue.main.asyncAfter(deadline: .now()+0.08) {
guard navigation.isInAction else { return }
ControllerNavigator.navigationTimer = SafeTimer(interval: 0.03) {
slider.step(forward: forward, visualStepRatio: 0.02)
}
ControllerNavigator.navigationTimer?.restart()
}
}
}
}
}
private func performControllerNavigationSelectionMove(by offset: Int) {
PublicUtils.runOnMain { [weak self] in
self?.moveControllerNavigationSelection(by: offset)
}
}
private func restoreControllerNavigationHighlightOnMain() {
if let persistedIdentifier = UserDefaults.standard.string(forKey: settingsControllerNavigationHighlightedIdentifierKey) {
highlightViewForControllerNavigator(by: persistedIdentifier)
}
}
func highlightViewForControllerNavigator(by identifer: String?){
let targets = controllerNavigationRestorableTargets()
let selectableTargets = targets.filter { isControllerNavigationSelectableTarget($0) }
guard !selectableTargets.isEmpty else {
clearControllerNavigationHighlight()
return
}
if let identifer = identifer,
let target = selectableTargets.first(where: {
controllerNavigationPersistenceIdentifier(for: $0) == identifer
}) {
highlightControllerNavigationView(target)
return
}
highlightControllerNavigationView(selectableTargets[0])
}
private func restoreControllerNavigationHighlightAfterSettingsModeSwitchOnMain() {
let targets = controllerNavigationRestorableTargets()
let selectableTargets = targets.filter { isControllerNavigationSelectableTarget($0) }
guard !selectableTargets.isEmpty else {
clearControllerNavigationHighlight()
return
}
if let highlightedView = ControllerNavigator.controllerNavigationHighlightedView,
!isControllerNavigationSectionHeader(highlightedView),
let highlightedIdentifier = controllerNavigationPersistenceIdentifier(for: highlightedView),
let target = selectableTargets.first(where: {
!isControllerNavigationSectionHeader($0) &&
controllerNavigationPersistenceIdentifier(for: $0) == highlightedIdentifier
}) {
highlightControllerNavigationView(target)
return
}
highlightControllerNavigationView(selectableTargets[0])
}
private func controllerNavigationRestorableTargets() -> [UIView] {
var targets: [UIView] = []
if currentSettingsMenuMode == .FavoriteSettings || currentSettingsMenuMode == .RemoveSettingItem,
let parentStack = parentStack {
targets.append(contentsOf: visibleFavoriteSettingStacks(in: parentStack))
}
targets.append(contentsOf: controllerNavigationTargets(skippingDisabledStacks: false))
var seenIdentifiers = Set<ObjectIdentifier>()
return targets.filter { target in
let identifier = ObjectIdentifier(target)
guard !seenIdentifiers.contains(identifier) else { return false }
seenIdentifiers.insert(identifier)
return true
}
}
func controllerNavigationPersistenceIdentifier(for view: UIView) -> String? {
if currentSettingsMenuMode == .FavoriteSettings || currentSettingsMenuMode == .RemoveSettingItem,
let topLevelStack = topLevelFavoriteSettingStack(containing: view),
let identifier = topLevelStack.accessibilityIdentifier,
!identifier.isEmpty {
return identifier
}
var currentView: UIView? = view
while let viewToCheck = currentView {
if let identifier = viewToCheck.accessibilityIdentifier,
!identifier.isEmpty {
return identifier
}
if viewToCheck === parentStack {
return nil
}
currentView = viewToCheck.superview
}
return nil
}
private func isControllerNavigationSectionHeader(_ view: UIView) -> Bool {
return view.accessibilityIdentifier?.hasPrefix("sectionHeader") == true
}
private func handleControllerNavigationReorderHold(pressed: Bool) {
guard currentSettingsMenuMode == .FavoriteSettings else {
ControllerNavigator.settingsFavoriteReorderActive = false
return
}
if pressed {
ControllerNavigator.settingsFavoriteReorderActive = true
return
}
guard ControllerNavigator.settingsFavoriteReorderActive else { return }
ControllerNavigator.settingsFavoriteReorderActive = false
saveFavoriteSettingStackIdentifiers()
}
private func handleControllerNavigationReadTip(pressed: Bool) {
if pressed {
handleControllerNavigationReadTipPressDown()
} else {
handleControllerNavigationReadTipRelease()
}
}
private func handleControllerNavigationReadTipPressDown() {
if ControllerNavigator.settingsReadTipPendingWorkItem != nil {
ControllerNavigator.settingsReadTipPressedAgain = true
ControllerNavigator.settingsReadTipSuppressNextRelease = true
}
}
private func handleControllerNavigationReadTipRelease() {
if ControllerNavigator.settingsReadTipSuppressNextRelease {
ControllerNavigator.settingsReadTipSuppressNextRelease = false
return
}
cancelPendingControllerNavigationReadTip()
ControllerNavigator.settingsReadTipPendingToken += 1
let token = ControllerNavigator.settingsReadTipPendingToken
let workItem = DispatchWorkItem { [weak self] in
guard ControllerNavigator.settingsReadTipPendingToken == token else { return }
if ControllerNavigator.settingsReadTipPressedAgain {
switch self?.currentSettingsMenuMode {
case .FavoriteSettings:
self?.removeHighlightedFavoriteSettingStack()
case .AllSettings:
if let highlitedStack = ControllerNavigator.controllerNavigationHighlightedView as? UIStackView {
self?.addSetting(toFavorite: highlitedStack)
DispatchQueue.main.async {
if AlertControllerUtil.autoCompletion {return}
AlertControllerUtil.autoCompletion = true
AlertControllerUtil.showAlert(
in: self,
title: LocalizationHelper.localizedString(forKey: ""),
message: "Setting added to favorite".localized,
withCancel: false,
buttonTitle: "",
countdown: 1,
completion: {
})
}
}
default:
break
}
// print("self?.cancelPendingControllerNavigationReadTip(resetSuppressNextRelease: false)");
} else {
self?.performControllerNavigationReadTip()
}
self?.cancelPendingControllerNavigationReadTip(resetSuppressNextRelease: false)
}
ControllerNavigator.settingsReadTipPendingWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem)
}
private func cancelPendingControllerNavigationReadTip(resetSuppressNextRelease: Bool = true) {
ControllerNavigator.settingsReadTipPendingWorkItem?.cancel()
ControllerNavigator.settingsReadTipPendingWorkItem = nil
ControllerNavigator.settingsReadTipPressedAgain = false
if resetSuppressNextRelease {
ControllerNavigator.settingsReadTipSuppressNextRelease = false
}
ControllerNavigator.settingsReadTipPendingToken += 1
}
private func performControllerNavigationReadTip() {
guard let stack = ControllerNavigator.controllerNavigationHighlightedView as? UIStackView,
stack.hasInfoTag || stack.isGameProfileSetting else {
return
}
for view in stack.subviews.filter({ $0.accessibilityIdentifier == "infoButton" }) {
if let button = view as? UIButton {
button.sendActions(for: .touchUpInside)
}
}
}
private func removeHighlightedFavoriteSettingStack() {
guard currentSettingsMenuMode == .FavoriteSettings,
let parentStack = parentStack,
let highlightedView = ControllerNavigator.controllerNavigationHighlightedView,
let stack = topLevelFavoriteSettingStack(containing: highlightedView) else {
return
}
let visibleStacksBeforeRemoval = visibleFavoriteSettingStacks(in: parentStack)
guard let removedVisibleIndex = visibleStacksBeforeRemoval.firstIndex(where: { $0 === stack }) else { return }
let nextVisibleIndex = max(removedVisibleIndex - 1, 0)
parentStack.removeArrangedSubview(stack)
stack.removeFromSuperview()
saveFavoriteSettingStackIdentifiers()
parentStack.setNeedsLayout()
UIView.animate(withDuration: 0.12) {
parentStack.layoutIfNeeded()
}
let visibleStacksAfterRemoval = visibleFavoriteSettingStacks(in: parentStack)
guard !visibleStacksAfterRemoval.isEmpty else {
clearControllerNavigationHighlight()
return
}
let targetIndex = min(nextVisibleIndex, visibleStacksAfterRemoval.count - 1)
highlightControllerNavigationView(visibleStacksAfterRemoval[targetIndex])
}
private func visibleFavoriteSettingStacks(in parentStack: UIStackView) -> [UIStackView] {
return parentStack.arrangedSubviews.compactMap { $0 as? UIStackView }.filter {
isControllerNavigationVisible($0, within: parentStack)
}
}
private func moveHighlightedFavoriteSettingStack(by offset: Int) {
PublicUtils.runOnMain { [weak self] in
self?.moveHighlightedFavoriteSettingStackOnMain(by: offset)
}
}
private func moveHighlightedFavoriteSettingStackOnMain(by offset: Int) {
guard currentSettingsMenuMode == .FavoriteSettings,
let parentStack = parentStack,
let highlightedView = ControllerNavigator.controllerNavigationHighlightedView,
let stack = topLevelFavoriteSettingStack(containing: highlightedView),
isControllerNavigationVisible(stack, within: parentStack) else {
return
}
let visibleStacks = parentStack.arrangedSubviews.compactMap { $0 as? UIStackView }.filter {
isControllerNavigationVisible($0, within: parentStack)
}
guard let currentVisibleIndex = visibleStacks.firstIndex(where: { $0 === stack }) else { return }
let targetVisibleIndex = currentVisibleIndex + offset
guard visibleStacks.indices.contains(targetVisibleIndex) else { return }
let targetStack = visibleStacks[targetVisibleIndex]
guard let targetArrangedIndex = parentStack.arrangedSubviews.firstIndex(where: { $0 === targetStack }) else {
return
}
parentStack.removeArrangedSubview(stack)
parentStack.insertArrangedSubview(stack, at: targetArrangedIndex)
parentStack.setNeedsLayout()
UIView.animate(withDuration: 0.12) {
parentStack.layoutIfNeeded()
}
highlightControllerNavigationView(stack)
}
private func topLevelFavoriteSettingStack(containing view: UIView) -> UIStackView? {
guard let parentStack = parentStack else { return nil }
var currentView: UIView? = view
while let viewToCheck = currentView, viewToCheck !== parentStack {
if let stackView = viewToCheck as? UIStackView,
parentStack.arrangedSubviews.contains(where: { $0 === stackView }) {
return stackView
}
currentView = viewToCheck.superview
}
return nil
}
private func moveControllerNavigationSelection(by offset: Int) {
let allTargets = controllerNavigationTargets(skippingDisabledStacks: false)
let targets = allTargets.filter { isControllerNavigationSelectableTarget($0) }
guard !targets.isEmpty else {
clearControllerNavigationHighlight()
return
}
if let highlightedView = ControllerNavigator.controllerNavigationHighlightedView,
let currentIndex = allTargets.firstIndex(where: { $0 === highlightedView }) {
var nextIndex = currentIndex
for _ in 0..<allTargets.count {
nextIndex = (nextIndex + offset + allTargets.count) % allTargets.count
let candidate = allTargets[nextIndex]
if isControllerNavigationSelectableTarget(candidate) {
highlightControllerNavigationView(candidate)
return
}
}
}
let currentIndex = ControllerNavigator.controllerNavigationHighlightedView.flatMap { highlightedView in
targets.firstIndex { $0 === highlightedView }
}
let nextIndex: Int
if let currentIndex = currentIndex {
nextIndex = (currentIndex + offset + targets.count) % targets.count
} else {
nextIndex = offset >= 0 ? 0 : targets.count - 1
}
highlightControllerNavigationView(targets[nextIndex])
}
private func controllerNavigationTargets(skippingDisabledStacks: Bool = true) -> [UIView] {
guard let parentStack = parentStack else { return [] }
switch currentSettingsMenuMode {
case .FavoriteSettings, .RemoveSettingItem:
return favoriteSettingsControllerNavigationTargets(
in: parentStack,
skippingDisabledStacks: skippingDisabledStacks
)
case .AllSettings:
break
@unknown default:
break
}
var targets: [UIView] = []
for arrangedSubview in parentStack.arrangedSubviews {
guard let sectionView = arrangedSubview as? MenuSectionView else { continue }
guard isControllerNavigationVisible(sectionView, within: parentStack) else { continue }
if let headerView = sectionView.headerView,
isControllerNavigationVisible(headerView, within: sectionView) {
targets.append(headerView)
}
guard !shouldSkipControllerNavigationSection(sectionView) else { continue }
guard let rootStackView = sectionView.rootStackView else { continue }
guard isControllerNavigationVisible(rootStackView, within: sectionView) else { continue }
for arrangedRootSubview in rootStackView.arrangedSubviews {
guard let stackView = arrangedRootSubview as? UIStackView else { continue }
targets.append(contentsOf: deepestVisibleControllerNavigationStacks(from: stackView, within: rootStackView, skippingDisabledStacks: skippingDisabledStacks))
}
}
return targets
}
private func shouldSkipControllerNavigationSection(_ sectionView: MenuSectionView) -> Bool {
guard let identifier = sectionView.identifier else {
return false
}
return settingsExcludedControllerNavigationSectionIdentifiers.contains(identifier)
}
private func favoriteSettingsControllerNavigationTargets(in parentStack: UIStackView, skippingDisabledStacks: Bool) -> [UIView] {
var targets: [UIView] = []
for arrangedSubview in parentStack.arrangedSubviews {
guard let stackView = arrangedSubview as? UIStackView else { continue }
targets.append(contentsOf: deepestVisibleControllerNavigationStacks(
from: stackView,
within: parentStack,
skippingDisabledStacks: skippingDisabledStacks
))
}
return targets
}
private func deepestVisibleControllerNavigationStacks(from stackView: UIStackView, within rootStackView: UIStackView, skippingDisabledStacks: Bool) -> [UIStackView] {
guard isControllerNavigationVisible(stackView, within: rootStackView) else { return [] }
let childStackViews = stackView.arrangedSubviews.compactMap { $0 as? UIStackView }
let visibleChildStackViews = childStackViews.filter {
isControllerNavigationVisible($0, within: rootStackView)
}
guard !visibleChildStackViews.isEmpty else {
if skippingDisabledStacks, hasDisabledArrangedSubview(in: stackView) {
return []
}
return [stackView]
}
return visibleChildStackViews.flatMap {
deepestVisibleControllerNavigationStacks(from: $0, within: rootStackView, skippingDisabledStacks: skippingDisabledStacks)
}
}
private func isControllerNavigationSelectableTarget(_ view: UIView) -> Bool {
if currentSettingsMenuMode == .FavoriteSettings || currentSettingsMenuMode == .RemoveSettingItem {
return true
}
guard let stackView = view as? UIStackView else { return true }
return !hasDisabledArrangedSubview(in: stackView)
}
private func hasDisabledArrangedSubview(in stackView: UIStackView) -> Bool {
for arrangedSubview in stackView.arrangedSubviews {
if let control = arrangedSubview as? UIControl, !control.isEnabled {
return true
}
if let nestedStackView = arrangedSubview as? UIStackView,
hasDisabledArrangedSubview(in: nestedStackView) {
return true
}
}
return false
}
private func isControllerNavigationVisible(_ view: UIView, within ancestor: UIView) -> Bool {
var currentView: UIView? = view
while let viewToCheck = currentView {
if viewToCheck.isHidden || viewToCheck.alpha <= 0.01 {
return false
}
if viewToCheck === ancestor {
return true
}
currentView = viewToCheck.superview
}
return false
}
private func highlightControllerNavigationView(_ view: UIView) {
guard ControllerNavigator.controllerNavigationHighlightedView !== view else {
scrollControllerNavigationViewToVisible(view)
return
}
var navigations = self.getNavigationElements()
let shouldShowReadTip = (view as? UIStackView).map { $0.hasInfoTag || $0.isGameProfileSetting } ?? false
if !shouldShowReadTip {
navigations.removeAll { $0.action == "readTip" }
}
GamepadNavigationIllustrationHud.updateNavigationElements(navigations)
clearControllerNavigationHighlight()
ControllerNavigator.controllerNavigationHighlightedView = view
let overlayView = UIView()
overlayView.translatesAutoresizingMaskIntoConstraints = false
overlayView.isUserInteractionEnabled = false
overlayView.backgroundColor = ThemeManager.appPrimaryColorWithAlpha
overlayView.layer.cornerRadius = 8
overlayView.layer.cornerCurve = .continuous
overlayView.layer.masksToBounds = true
overlayView.alpha = 0
view.addSubview(overlayView)
NSLayoutConstraint.activate([
overlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
overlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
overlayView.topAnchor.constraint(equalTo: view.topAnchor),
overlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
view.bringSubviewToFront(overlayView)
controllerNavigationHighlightOverlayView = overlayView
UIView.animate(withDuration: 0.12) {
overlayView.alpha = 1
}
scrollControllerNavigationViewToVisible(view)
}
private func clearControllerNavigationHighlight() {
guard let overlayView = controllerNavigationHighlightOverlayView else {
ControllerNavigator.controllerNavigationHighlightedView = nil
return
}
UIView.animate(withDuration: 0.08, animations: {
overlayView.alpha = 0
}, completion: { _ in
overlayView.removeFromSuperview()
})
ControllerNavigator.controllerNavigationHighlightedView = nil
controllerNavigationHighlightOverlayView = nil
}
fileprivate func clearControllerNavigationHighlightForControllerNavigator() {
clearControllerNavigationHighlight()
}
private func scrollControllerNavigationViewToVisible(_ view: UIView) {
guard let scrollView = scrollView,
let parentStack = parentStack else { return }
scrollView.layoutIfNeeded()
parentStack.layoutIfNeeded()
let targetRectInParentStack = view.convert(view.bounds, to: parentStack)
let targetRectInContent = targetRectInParentStack.offsetBy(
dx: parentStack.frame.minX,
dy: parentStack.frame.minY
)
let edgePadding: CGFloat = 72
let adjustedInset = scrollView.adjustedContentInset
let visibleMinY = scrollView.contentOffset.y + adjustedInset.top
let visibleMaxY = scrollView.contentOffset.y + scrollView.bounds.height - adjustedInset.bottom
var targetOffsetY = scrollView.contentOffset.y
if targetRectInContent.minY < visibleMinY + edgePadding {
targetOffsetY = targetRectInContent.minY - edgePadding - adjustedInset.top
} else if targetRectInContent.maxY > visibleMaxY - edgePadding {
targetOffsetY = targetRectInContent.maxY + edgePadding + adjustedInset.bottom - scrollView.bounds.height
}
let minOffsetY = -adjustedInset.top
let maxOffsetY = max(minOffsetY, scrollView.contentSize.height - scrollView.bounds.height + adjustedInset.bottom)
targetOffsetY = min(max(targetOffsetY, minOffsetY), maxOffsetY)
guard abs(targetOffsetY - scrollView.contentOffset.y) > 0.5 else { return }
UIView.animate(withDuration: 0.1) {
scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: targetOffsetY), animated: false)
}
}
}
@available(iOS 13.0, *)
extension ControllerCollectionNavigationDelegate {
var controllerNavigationSelectedIndexPath: IndexPath? {
get {
objc_getAssociatedObject(self, &controllerNavigationSelectedIndexPathKey) as? IndexPath
}
set {
objc_setAssociatedObject(self, &controllerNavigationSelectedIndexPathKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
private var controllerNavigationPersistToken: Int {
get {
objc_getAssociatedObject(self, &controllerNavigationPersistTokenKey) as? Int ?? 0
}
set {
objc_setAssociatedObject(self, &controllerNavigationPersistTokenKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
private var controllerNavigationHighlightGeneration: Int {
get {
objc_getAssociatedObject(self, &controllerNavigationHighlightGenerationKey) as? Int ?? 0
}
set {
objc_setAssociatedObject(self, &controllerNavigationHighlightGenerationKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
var controllerNavigationCollectionView: UICollectionView {
guard let collectionViewController = self as? UICollectionViewController else {
assertionFailure("ControllerCollectionNavigationDelegate adopters must provide controllerNavigationCollectionView")
return UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
}
return collectionViewController.collectionView
}
func applyControllerNavigationHighlight(to cell: UICollectionViewCell, highlighted: Bool) {
if self is ProfileSelectorViewController {
return
}
clearControllerNavigationHighlightBorder(in: cell)
guard highlighted else { return }
let highlightedView = controllerNavigationHighlightTargetView(for: cell)
let isDarkTheme = ThemeManager.userInterfaceStyle() == .dark
if self is ToolboxViewController {
highlightedView.layer.borderColor = UIColor.systemOrange.withAlphaComponent(isDarkTheme ? 0.8 : 0.97).cgColor
highlightedView.layer.borderWidth = 3
}
else {
highlightedView.layer.borderColor = ThemeManager.appPrimaryColor.withAlphaComponent(isDarkTheme ? 0.85 : 0.93).cgColor
highlightedView.layer.borderWidth = (self is HostCollectionViewController) ? 3 : 5
}
(cell as? ControllerNavigationHighlightTargetProviding)?.controllerNavigationHighlightDidApply()
}
private func clearControllerNavigationHighlightBorder(in cell: UICollectionViewCell) {
if let highlightProvider = cell as? ControllerNavigationHighlightTargetProviding {
let targetView = highlightProvider.controllerNavigationHighlightTargetView
targetView.layer.borderWidth = 0
targetView.layer.borderColor = nil
highlightProvider.controllerNavigationHighlightDidClear()
return
}
for view in [cell, cell.contentView] + cell.contentView.subviews + cell.subviews {
view.layer.borderWidth = 0
view.layer.borderColor = nil
}
}
private func controllerNavigationHighlightTargetView(for cell: UICollectionViewCell) -> UIView {
if let highlightProvider = cell as? ControllerNavigationHighlightTargetProviding {
return highlightProvider.controllerNavigationHighlightTargetView
}
if let contentView = controllerNavigationContentView(in: cell) {
return contentView
}
return cell.contentView
}
private func controllerNavigationContentView(in cell: UICollectionViewCell) -> UIView? {
let contentSubviews = cell.contentView.subviews.filter {
!$0.isHidden && $0.alpha > 0.01 && !$0.bounds.isEmpty
}
if let contentSubview = contentSubviews.first {
return contentSubview
}
let cellSubviews = cell.subviews.filter {
$0 !== cell.contentView && !$0.isHidden && $0.alpha > 0.01 && !$0.bounds.isEmpty
}
return cellSubviews.first
}
}
@available(iOS 13.0, *)
extension UICollectionViewController: ControllerCollectionNavigationDelegate {
@objc func persistControllerNavigationHighlight() {
controllerNavigationPersistCollectionHighlight()
}
@objc func restoreControllerNavigationHighlight() {
controllerNavigationRestoreCollectionHighlight()
}
@objc func restoreControllerNavigationHighlightAfterSettingsModeSwitch() {
}
@objc func uiWidgetActionForControllerNavigator(forward: Bool, from navigation: ControllerNavigationElement) {
}
@objc func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) {
controllerNavigationPerformDefaultButtonAction(pressed: pressed, from: navigation)
}
@objc func getNavigationElements() -> [ControllerNavigationElement] {
controllerNavigationDefaultNavigationElements()
}
@objc func navigateByController(forward: Bool) {
controllerNavigationNavigateCollection(forward: forward)
}
@objc func navigateByController(downward: Bool) {
controllerNavigationNavigateCollection(downward: downward)
}
}
@available(iOS 13.0, *)
extension ControllerCollectionNavigationDelegate {
func controllerNavigationPersistCollectionHighlight() {
if let hostCollectionVC = self as? HostCollectionViewController {
persistHostControllerNavigationHighlight(in: hostCollectionVC)
return
}
if self is MainFrameViewController {
persistAppControllerNavigationHighlight()
}
}
func controllerNavigationRestoreCollectionHighlight() {
guard ControllerNavigator.enabled, ControllerUtil.primaryGCController != nil else {return}
PublicUtils.runOnMain { [weak self] in
guard let self else { return }
if let hostCollectionVC = self as? HostCollectionViewController {
self.restoreHostControllerNavigationHighlight(in: hostCollectionVC)
return
}
if let mainFrameVC = self as? MainFrameViewController {
self.restoreAppControllerNavigationHighlight(in: mainFrameVC)
}
}
}
private func currentControllerNavigationCell() -> UICollectionViewCell? {
guard let selectedIndexPath = controllerNavigationSelectedIndexPath,
selectedIndexPath.section < controllerNavigationCollectionView.numberOfSections,
selectedIndexPath.item >= 0,
selectedIndexPath.item < controllerNavigationCollectionView.numberOfItems(inSection: selectedIndexPath.section) else {
return nil
}
return controllerNavigationCollectionView.cellForItem(at: selectedIndexPath)
}
private func currentControllerNavigationContentView() -> UIView? {
guard let cell = currentControllerNavigationCell() else { return nil }
return controllerNavigationContentView(in: cell)
}
func controllerNavigationPerformDefaultButtonAction(pressed: Bool, from navigation: ControllerNavigationElement) {
PublicUtils.runOnMain { [weak self] in
guard let self else { return }
if pressed {
if self is HostCollectionViewController {
guard let hostCardView = self.currentControllerNavigationContentView() as? HostCardView else { return }
switch navigation.action {
case "launchPairWake":
hostCardView.launchButtonTapped()
hostCardView.pairButtonTapped()
hostCardView.wakeupButtonTapped()
case "applications":
hostCardView.appButtonTapped()
default:
break
}
}
if let mainFrameVC = self as? MainFrameViewController {
let appView = self.currentControllerNavigationContentView() as? UIAppView
switch navigation.action {
case "launch":
mainFrameVC.forceLaunchApp(appView?.app)
case "quitApp":
PublicUtils.runOnMain {
mainFrameVC.quitApp(appView?.app)
}
default:
break
}
}
}
}
}
func controllerNavigationDefaultNavigationElements() -> [ControllerNavigationElement] {
var elements: [ControllerNavigationElement] = []
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButton, action: "radialMenu"))
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .rightStick : .leftStick, action: "focusNavigation"))
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .abxy : .dpad, action: "focusNavigation"))
if self is HostCollectionViewController {elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .a, action: "applications"))}
if self is HostCollectionViewController {elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .x, action: "launchPairWake"))}
if self is MainFrameViewController {elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .a, action: "launch"))}
if self is MainFrameViewController {elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .b, action: "quitApp"))}
return elements
}
func controllerNavigationNavigateCollection(forward: Bool) {
guard hasNoPresentedVC else { return }
performControllerNavigationSelectionMove(horizontalOffset: forward ? 1 : -1)
scheduleControllerNavigationHighlightPersistenceWhenStickSettles()
}
func controllerNavigationNavigateCollection(downward: Bool) {
guard hasNoPresentedVC else { return }
performControllerNavigationSelectionMove(verticalOffset: downward ? 1 : -1)
scheduleControllerNavigationHighlightPersistenceWhenStickSettles()
}
func uiWidgetActionForControllerNavigator(forward: Bool) {
}
private func scheduleControllerNavigationHighlightPersistenceWhenStickSettles() {
controllerNavigationPersistToken += 1
let token = controllerNavigationPersistToken
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in
self?.persistControllerNavigationHighlightIfStickSettled(token: token)
}
}
private func persistControllerNavigationHighlightIfStickSettled(token: Int) {
guard controllerNavigationPersistToken == token else { return }
if ControllerNavigator.navigationTimer?.isRunning() == true {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in
self?.persistControllerNavigationHighlightIfStickSettled(token: token)
}
return
}
persistControllerNavigationHighlight()
}
private func persistHostControllerNavigationHighlight(in hostCollectionVC: HostCollectionViewController) {
guard let selectedIndexPath = controllerNavigationSelectedIndexPath,
selectedIndexPath.section == 0,
selectedIndexPath.item >= 0,
selectedIndexPath.item < hostCollectionVC.items.count,
let host = hostCollectionVC.items[selectedIndexPath.item] as? TemporaryHost else {
return
}
let uuid = host.uuid
guard !uuid.isEmpty else { return }
UserDefaults.standard.set(uuid, forKey: hostControllerNavigationHighlightedUUIDKey)
}
private func restoreHostControllerNavigationHighlight(in hostCollectionVC: HostCollectionViewController) {
guard let uuid = UserDefaults.standard.string(forKey: hostControllerNavigationHighlightedUUIDKey),
!uuid.isEmpty else {
restoreFirstControllerNavigationItemIfNeeded()
return
}
for index in 0..<hostCollectionVC.items.count {
guard let host = hostCollectionVC.items[index] as? TemporaryHost,
host.uuid == uuid else {
continue
}
controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: index, section: 0))
return
}
restoreFirstControllerNavigationItemIfNeeded()
}
private func persistAppControllerNavigationHighlight() {
guard let appView = currentControllerNavigationContentView() as? UIAppView,
let appID = appView.app.id,
!appID.isEmpty,
let host = appView.app.host else {
return
}
host.controllerNavigationHighlightedAppID = appID
DataManager().update(host)
}
private func restoreAppControllerNavigationHighlight(in mainFrameVC: MainFrameViewController) {
guard let appID = mainFrameVC.sortedAppList.first?.host?.controllerNavigationHighlightedAppID,
!appID.isEmpty else {
restoreFirstControllerNavigationItemIfNeeded()
return
}
for (index, app) in mainFrameVC.sortedAppList.enumerated() where app.id == appID {
controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: index, section: 0))
return
}
restoreFirstControllerNavigationItemIfNeeded()
}
private func restoreFirstControllerNavigationItemIfNeeded() {
guard controllerNavigationCollectionView.numberOfSections > 0,
controllerNavigationCollectionView.numberOfItems(inSection: 0) > 0 else {
clearCollectionControllerNavigationHighlight()
return
}
controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: 0, section: 0))
}
private func performControllerNavigationSelectionMove(horizontalOffset: Int) {
PublicUtils.runOnMain { [weak self] in
self?.moveControllerNavigationSelection(horizontalOffset: horizontalOffset)
}
}
private func performControllerNavigationSelectionMove(verticalOffset: Int) {
PublicUtils.runOnMain { [weak self] in
self?.moveControllerNavigationSelection(verticalOffset: verticalOffset)
}
}
private func moveControllerNavigationSelection(horizontalOffset: Int) {
let itemCount = controllerNavigationCollectionView.numberOfItems(inSection: 0)
guard itemCount > 0 else {
clearCollectionControllerNavigationHighlight()
return
}
guard let currentIndexPath = currentControllerNavigationIndexPath() else {
controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: horizontalOffset >= 0 ? 0 : itemCount - 1, section: 0))
return
}
let nextItem = (currentIndexPath.item + horizontalOffset + itemCount) % itemCount
controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: nextItem, section: currentIndexPath.section))
}
private func moveControllerNavigationSelection(verticalOffset: Int) {
let itemCount = controllerNavigationCollectionView.numberOfItems(inSection: 0)
guard itemCount > 0 else {
clearCollectionControllerNavigationHighlight()
return
}
guard let currentIndexPath = currentControllerNavigationIndexPath(),
let currentAttributes = layoutAttributesForItem(at: currentIndexPath) else {
controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: verticalOffset >= 0 ? 0 : itemCount - 1, section: 0))
return
}
let currentCenter = currentAttributes.center
let candidates = (0..<itemCount).compactMap { item -> (indexPath: IndexPath, attributes: UICollectionViewLayoutAttributes)? in
let indexPath = IndexPath(item: item, section: 0)
guard indexPath != currentIndexPath,
let attributes = layoutAttributesForItem(at: indexPath) else {
return nil
}
let isTargetDirection = verticalOffset > 0
? attributes.center.y > currentCenter.y + 1
: attributes.center.y < currentCenter.y - 1
guard isTargetDirection else { return nil }
return (indexPath, attributes)
}
let nextIndexPath = candidates.min { lhs, rhs in
let lhsVerticalDistance = abs(lhs.attributes.center.y - currentCenter.y)
let rhsVerticalDistance = abs(rhs.attributes.center.y - currentCenter.y)
if abs(lhsVerticalDistance - rhsVerticalDistance) > 0.5 {
return lhsVerticalDistance < rhsVerticalDistance
}
let lhsHorizontalDistance = abs(lhs.attributes.center.x - currentCenter.x)
let rhsHorizontalDistance = abs(rhs.attributes.center.x - currentCenter.x)
if abs(lhsHorizontalDistance - rhsHorizontalDistance) > 0.5 {
return lhsHorizontalDistance < rhsHorizontalDistance
}
return verticalOffset > 0
? lhs.indexPath.item < rhs.indexPath.item
: lhs.indexPath.item > rhs.indexPath.item
}?.indexPath
if let nextIndexPath {
controllerNavigationHighlightItemForControllerNavigator(at: nextIndexPath)
}
}
private func currentControllerNavigationIndexPath() -> IndexPath? {
if let selectedIndexPath = controllerNavigationSelectedIndexPath,
selectedIndexPath.section < controllerNavigationCollectionView.numberOfSections,
selectedIndexPath.item < controllerNavigationCollectionView.numberOfItems(inSection: selectedIndexPath.section) {
return selectedIndexPath
}
if let highlightedView = ControllerNavigator.controllerNavigationHighlightedView,
let highlightedCell = collectionViewCell(containing: highlightedView),
let indexPath = controllerNavigationCollectionView.indexPath(for: highlightedCell) {
return indexPath
}
return nil
}
func controllerNavigationCurrentIndexPathForControllerNavigator() -> IndexPath? {
currentControllerNavigationIndexPath()
}
func controllerNavigationHighlightItemForControllerNavigator(at indexPath: IndexPath) {
PublicUtils.runOnMain { [weak self] in
guard let self else { return }
let collectionView = self.controllerNavigationCollectionView
guard indexPath.section < collectionView.numberOfSections,
indexPath.item >= 0,
indexPath.item < collectionView.numberOfItems(inSection: indexPath.section) else {
self.clearCollectionControllerNavigationHighlight()
return
}
collectionView.layoutIfNeeded()
let previousIndexPath = self.controllerNavigationSelectedIndexPath
self.controllerNavigationHighlightGeneration += 1
let highlightGeneration = self.controllerNavigationHighlightGeneration
self.controllerNavigationSelectedIndexPath = indexPath
if let previousIndexPath,
previousIndexPath != indexPath,
let previousCell = collectionView.cellForItem(at: previousIndexPath) {
self.applyControllerNavigationHighlight(to: previousCell, highlighted: false)
}
collectionView.scrollToItem(at: indexPath, at: [.centeredHorizontally, .centeredVertically], animated: true)
self.applyControllerNavigationHighlightWhenCellIsReady(at: indexPath, generation: highlightGeneration, remainingAttempts: 8)
}
}
private func applyControllerNavigationHighlightWhenCellIsReady(at indexPath: IndexPath, generation: Int, remainingAttempts: Int) {
guard controllerNavigationSelectedIndexPath == indexPath,
controllerNavigationHighlightGeneration == generation else { return }
let collectionView = controllerNavigationCollectionView
collectionView.layoutIfNeeded()
if let cell = collectionView.cellForItem(at: indexPath) {
applyControllerNavigationHighlight(to: cell, highlighted: true)
ControllerNavigator.controllerNavigationHighlightedView = controllerNavigationHighlightTargetView(for: cell)
return
}
guard remainingAttempts > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.applyControllerNavigationHighlightWhenCellIsReady(at: indexPath, generation: generation, remainingAttempts: remainingAttempts - 1)
}
}
private func clearCollectionControllerNavigationHighlight() {
PublicUtils.runOnMain { [weak self] in
guard let self else { return }
if let selectedIndexPath = self.controllerNavigationSelectedIndexPath,
let cell = self.controllerNavigationCollectionView.cellForItem(at: selectedIndexPath) {
self.applyControllerNavigationHighlight(to: cell, highlighted: false)
}
self.controllerNavigationHighlightGeneration += 1
self.controllerNavigationSelectedIndexPath = nil
ControllerNavigator.controllerNavigationHighlightedView = nil
}
}
func clearCollectionControllerNavigationHighlightForControllerNavigator() {
clearCollectionControllerNavigationHighlight()
}
func invalidateCollectionControllerNavigationHighlightForControllerNavigator() {
controllerNavigationHighlightGeneration += 1
controllerNavigationSelectedIndexPath = nil
ControllerNavigator.controllerNavigationHighlightedView = nil
}
private func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
controllerNavigationCollectionView.collectionViewLayout.layoutAttributesForItem(at: indexPath)
}
private func collectionViewCell(containing view: UIView) -> UICollectionViewCell? {
var currentView: UIView? = view
while let viewToCheck = currentView {
if let cell = viewToCheck as? UICollectionViewCell {
return cell
}
currentView = viewToCheck.superview
}
return nil
}
}
extension MainFrameViewController {
func forceLaunchApp(_ app: TemporaryApp?){
guard let app = app else { return }
if let currentRunningApp = self.findRunningApp(app.host) {
if currentRunningApp !== app {
self.quitRunningAppAndStart(app)
}
else {
self.launch(currentRunningApp)
}
}
else {
self.launch(app)
}
}
}
private var alertControllerNavigationHookInstalledKey: UInt8 = 0
@available(iOS 13.0, *)
extension UIAlertController: ControllerUINavigationDelegate {
static func installControllerNavigationDelegateHook() {
guard objc_getAssociatedObject(self, &alertControllerNavigationHookInstalledKey) == nil else { return }
objc_setAssociatedObject(self, &alertControllerNavigationHookInstalledKey, true, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
swizzleAlertLifecycleMethod(
originalSelector: #selector(UIViewController.viewDidAppear(_:)),
swizzledSelector: #selector(UIAlertController.vl_controllerNavigationViewDidAppear(_:))
)
swizzleAlertLifecycleMethod(
originalSelector: #selector(UIViewController.viewDidDisappear(_:)),
swizzledSelector: #selector(UIAlertController.vl_controllerNavigationViewDidDisappear(_:))
)
}
private static func swizzleAlertLifecycleMethod(originalSelector: Selector, swizzledSelector: Selector) {
guard let originalMethod = class_getInstanceMethod(UIAlertController.self, originalSelector),
let swizzledMethod = class_getInstanceMethod(UIAlertController.self, swizzledSelector) else {
return
}
let didAddMethod = class_addMethod(
UIAlertController.self,
originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod)
)
if didAddMethod {
class_replaceMethod(
UIAlertController.self,
swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod)
)
} else {
method_exchangeImplementations(originalMethod, swizzledMethod)
}
}
@objc private func vl_controllerNavigationViewDidAppear(_ animated: Bool) {
vl_controllerNavigationViewDidAppear(animated)
ControllerNavigator.setUINavigationDelegate(self)
}
@objc private func vl_controllerNavigationViewDidDisappear(_ animated: Bool) {
vl_controllerNavigationViewDidDisappear(animated)
ControllerNavigator.restorePreviousUINavigationDelegate(ifCurrentDelegateIs: self)
}
func getNavigationElements() -> [ControllerNavigationElement] {
let enabledActions = actions.filter { $0.isEnabled }
guard let firstAction = enabledActions.first else { return [] }
guard enabledActions.count > 1, let lastAction = enabledActions.last else {
guard let title = firstAction.title, !title.isEmpty else { return [] }
return [ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .a, action: title)]
}
var elements: [ControllerNavigationElement] = []
if ControllerNavigator.radialMenuButtonPosition == .left {
elements.append(ControllerNavigationElement(control: firstAction.style == .default ? .dpadRight : .dpadUp, action: firstAction.title ?? ""))
if firstAction.style != lastAction.style {
elements.append(ControllerNavigationElement(control: lastAction.style == .cancel ? .dpadUp : .dpadRight, action: lastAction.title ?? ""))
}
}
else {
elements.append(ControllerNavigationElement(control: firstAction.style == .default ? .a : .b, action: firstAction.title ?? ""))
if firstAction.style != lastAction.style {
elements.append(ControllerNavigationElement(control: lastAction.style == .cancel ? .b : .a, action: lastAction.title ?? ""))
}
}
return elements
}
func navigateByController(forward: Bool) {}
func navigateByController(downward: Bool) {}
func persistControllerNavigationHighlight() {}
func restoreControllerNavigationHighlight() {}
func restoreControllerNavigationHighlightAfterSettingsModeSwitch() {}
func uiWidgetActionForControllerNavigator(forward: Bool, from navigation: ControllerNavigationElement) {}
func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) {
guard pressed,
let action = actions.first(where: { $0.title == navigation.action && $0.isEnabled }) else {
return
}
performControllerNavigationAction(action)
}
private func performControllerNavigationAction(_ action: UIAlertAction) {
let selector = NSSelectorFromString("_invokeHandlersForAction:")
if responds(to: selector) {
self.perform(selector, with: action)
dismiss(animated: true)
return
}
}
}
@available(iOS 13.0, *)
extension StreamFrameViewController: ControllerUINavigationDelegate {
func getNavigationElements() -> [ControllerNavigationElement] {
var elements = [ControllerNavigationElement]()
elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButton, action: "radialMenu"))
return elements
}
func navigateByController(forward: Bool) {}
func navigateByController(downward: Bool) {}
func persistControllerNavigationHighlight() {}
func restoreControllerNavigationHighlight() {}
func restoreControllerNavigationHighlightAfterSettingsModeSwitch() {}
func uiWidgetActionForControllerNavigator(forward: Bool, from navigation: ControllerNavigationElement) {}
func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) {}
}