10 Commits
17 changed files with 843 additions and 29 deletions

No files matched your search

+8
View File
@@ -20,6 +20,8 @@
1850C82A2E667E4000845308 /* MicHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850C8292E667E4000845308 /* MicHandler.swift */; };
18729EDE2E8BD68800101194 /* UUIDHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18729EDD2E8BD68800101194 /* UUIDHelper.swift */; };
18780CC72F10D66000B90483 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 18780CC62F10D66000B90483 /* StoreKit.framework */; };
A76B00032FFCC00100000003 /* GameSirG8MFiRumbler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B00022FFCC00100000002 /* GameSirG8MFiRumbler.swift */; };
A76B00052FFCC00100000005 /* ExternalAccessory.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A76B00042FFCC00100000004 /* ExternalAccessory.framework */; };
18780CCA2F10D6CC00B90483 /* IAPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18780CC92F10D6CC00B90483 /* IAPManager.swift */; };
187F4B282E04658A00EECC59 /* WidgetPanelStackView.m in Sources */ = {isa = PBXBuildFile; fileRef = 187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */; };
1888C1922E24C33100108730 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1888C1912E24C33100108730 /* AboutViewController.swift */; };
@@ -200,6 +202,8 @@
1850C8292E667E4000845308 /* MicHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicHandler.swift; sourceTree = "<group>"; };
18729EDD2E8BD68800101194 /* UUIDHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDHelper.swift; sourceTree = "<group>"; };
18780CC62F10D66000B90483 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
A76B00022FFCC00100000002 /* GameSirG8MFiRumbler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameSirG8MFiRumbler.swift; sourceTree = "<group>"; };
A76B00042FFCC00100000004 /* ExternalAccessory.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ExternalAccessory.framework; path = System/Library/Frameworks/ExternalAccessory.framework; sourceTree = SDKROOT; };
18780CC92F10D6CC00B90483 /* IAPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPManager.swift; sourceTree = "<group>"; };
187F4B232E04633100EECC59 /* WidgetPanelStackView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WidgetPanelStackView.h; sourceTree = "<group>"; };
187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WidgetPanelStackView.m; sourceTree = "<group>"; };
@@ -471,6 +475,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A76B00052FFCC00100000005 /* ExternalAccessory.framework in Frameworks */,
18A0EE272EFB9B5A00BA853A /* SVGKitSwift in Frameworks */,
18A0EE252EFB9B5A00BA853A /* SVGKit in Frameworks */,
9890CF6B203B7EE1006C4B06 /* libxml2.tbd in Frameworks */,
@@ -648,6 +653,7 @@
FB290CF019B2C406004C83CF /* Frameworks */ = {
isa = PBXGroup;
children = (
A76B00042FFCC00100000004 /* ExternalAccessory.framework */,
18780CC62F10D66000B90483 /* StoreKit.framework */,
98882A032AF60F5300C5A11C /* libavcodec.a */,
98882A052AF60F5300C5A11C /* libavformat.a */,
@@ -750,6 +756,7 @@
isa = PBXGroup;
children = (
84351B052C29ACAF00A77989 /* CustomOSC */,
A76B00022FFCC00100000002 /* GameSirG8MFiRumbler.swift */,
FB89460A19F646E200339C8A /* ControllerSupport.h */,
FB89460B19F646E200339C8A /* ControllerSupport.m */,
FB89460C19F646E200339C8A /* StreamView.h */,
@@ -1243,6 +1250,7 @@
FBD3494319FC9C04002D2A60 /* AppAssetManager.m in Sources */,
FB6549561A57907E001C8F39 /* DiscoveryWorker.m in Sources */,
84351B0E2C29ACCB00A77989 /* LayoutOnScreenControls.m in Sources */,
A76B00032FFCC00100000003 /* GameSirG8MFiRumbler.swift in Sources */,
FB89462A19F646E200339C8A /* ControllerSupport.m in Sources */,
FB9AFD3D1A7E111600872C98 /* AppAssetResponse.m in Sources */,
1888C1922E24C33100108730 /* AboutViewController.swift in Sources */,
+4 -5
View File
@@ -2637,7 +2637,6 @@
</state>
<connections>
<action selector="closeTapped:" destination="xSZ-i2-Jts" eventType="touchUpInside" id="sc6-Mq-rbQ"/>
<action selector="undoTapped:" destination="xSZ-i2-Jts" eventType="touchUpInside" id="d4b-0R-vJU"/>
</connections>
</button>
<button autoresizesSubviews="NO" opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="F7X-l4-cl3" userLabel="Undo">
@@ -3977,19 +3976,19 @@
<image name="tray.full.fill" catalog="system" width="128" height="88"/>
<image name="video.and.waveform" catalog="system" width="128" height="94"/>
<systemColor name="linkColor">
<color red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<systemColor name="systemBrownColor">
<color red="0.63529411759999999" green="0.51764705879999995" blue="0.36862745099999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color red="0.67450980392156867" green="0.49803921568627452" blue="0.36862745098039218" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="systemGray3Color">
<color red="0.78039215689999997" green="0.78039215689999997" blue="0.80000000000000004" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color red="0.7803921568627451" green="0.7803921568627451" blue="0.80000000000000004" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="systemTealColor">
<color red="0.18823529410000001" green="0.69019607839999997" blue="0.78039215689999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color red="0.0" green="0.76470588235294112" blue="0.81568627450980391" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>
+98 -12
View File
@@ -315,6 +315,8 @@ final class ControllerNavigator: NSObject {
static var settingsReadTipSuppressNextRelease = false
static var navigationTimer: SafeTimer?
private static let navigationInitialRepeatDelay: TimeInterval = 0.23
private static let navigationContinuousRepeatInterval: TimeInterval = 0.177
private static let controllerDrivenUIKitAnimationWakeToken = ControllerDrivenUIKitAnimationWakeToken()
@objc static weak var controllerNavigationHighlightedView: UIView?
private static let installAlertControllerNavigationHook: Void = {
@@ -520,19 +522,27 @@ final class ControllerNavigator: NSObject {
*/
private static func navigateContinuously(forward: Bool) {
navigationTimer?.clean()
navigationTimer = SafeTimer(interval: 0.23) {
uiNavigationDelegate?.navigateByController(forward: forward)
}
navigationTimer?.restart()
navigationTimer?.clean()
uiNavigationDelegate?.navigateByController(forward: forward)
navigationTimer = SafeTimer(
interval: navigationContinuousRepeatInterval,
delay: navigationInitialRepeatDelay
) {
uiNavigationDelegate?.navigateByController(forward: forward)
}
navigationTimer?.restart(minimumRunCount: 0)
}
private static func navigateContinuously(downward: Bool) {
navigationTimer?.clean()
navigationTimer = SafeTimer(interval: 0.23) {
uiNavigationDelegate?.navigateByController(downward: downward)
}
navigationTimer?.restart()
navigationTimer?.clean()
uiNavigationDelegate?.navigateByController(downward: downward)
navigationTimer = SafeTimer(
interval: navigationContinuousRepeatInterval,
delay: navigationInitialRepeatDelay
) {
uiNavigationDelegate?.navigateByController(downward: downward)
}
navigationTimer?.restart(minimumRunCount: 0)
}
@objc static func start() {
@@ -768,7 +778,7 @@ final class ControllerNavigator: NSObject {
radialMenuButtonPressed = false
radialMenuDelayTimer?.clean()
stickReleasedInRadialMenu = false
if radialMenuView == nil, uiNavigationDelegate is StreamFrameViewController {
if radialMenuView == nil, let vc = uiNavigationDelegate as? StreamFrameViewController, vc.hasNoPresentedVC {
ControllerSupport.sharedInstance()?.sendNavigationButtonPress()
return
}
@@ -1227,6 +1237,12 @@ extension SettingsViewController: ControllerUINavigationDelegate {
highlightControllerNavigationView(target)
return
}
if let identifer = identifer,
let target = nearestSelectableControllerNavigationTarget(to: identifer, selectableTargets: selectableTargets) {
highlightControllerNavigationView(target)
return
}
highlightControllerNavigationView(selectableTargets[0])
}
@@ -1249,6 +1265,14 @@ extension SettingsViewController: ControllerUINavigationDelegate {
highlightControllerNavigationView(target)
return
}
if let highlightedView = ControllerNavigator.controllerNavigationHighlightedView,
!isControllerNavigationSectionHeader(highlightedView),
let highlightedIdentifier = controllerNavigationPersistenceIdentifier(for: highlightedView),
let target = nearestSelectableControllerNavigationTarget(to: highlightedIdentifier, selectableTargets: selectableTargets) {
highlightControllerNavigationView(target)
return
}
highlightControllerNavigationView(selectableTargets[0])
}
@@ -1269,6 +1293,31 @@ extension SettingsViewController: ControllerUINavigationDelegate {
return true
}
}
private func nearestSelectableControllerNavigationTarget(to identifier: String, selectableTargets: [UIView]) -> UIView? {
let orderedTargets = controllerNavigationTargetsIncludingUnavailable()
guard let persistedIndex = orderedTargets.firstIndex(where: {
controllerNavigationPersistenceIdentifier(for: $0) == identifier
}) else {
return nil
}
for distance in 1..<orderedTargets.count {
let forwardIndex = persistedIndex + distance
if forwardIndex < orderedTargets.count,
let target = selectableTargets.first(where: { $0 === orderedTargets[forwardIndex] }) {
return target
}
let backwardIndex = persistedIndex - distance
if backwardIndex >= 0,
let target = selectableTargets.first(where: { $0 === orderedTargets[backwardIndex] }) {
return target
}
}
return nil
}
func controllerNavigationPersistenceIdentifier(for view: UIView) -> String? {
if currentSettingsMenuMode == .FavoriteSettings || currentSettingsMenuMode == .RemoveSettingItem,
@@ -1569,6 +1618,44 @@ extension SettingsViewController: ControllerUINavigationDelegate {
return targets
}
private func controllerNavigationTargetsIncludingUnavailable() -> [UIView] {
guard let parentStack = parentStack else { return [] }
if currentSettingsMenuMode == .FavoriteSettings || currentSettingsMenuMode == .RemoveSettingItem {
return parentStack.arrangedSubviews.compactMap { $0 as? UIStackView }
}
var targets: [UIView] = []
for arrangedSubview in parentStack.arrangedSubviews {
guard let sectionView = arrangedSubview as? MenuSectionView else { continue }
if let headerView = sectionView.headerView {
targets.append(headerView)
}
guard !shouldSkipControllerNavigationSection(sectionView) else { continue }
guard let rootStackView = sectionView.rootStackView else { continue }
for arrangedRootSubview in rootStackView.arrangedSubviews {
guard let stackView = arrangedRootSubview as? UIStackView else { continue }
targets.append(contentsOf: deepestControllerNavigationStacks(from: stackView))
}
}
return targets
}
private func deepestControllerNavigationStacks(from stackView: UIStackView) -> [UIStackView] {
let childStackViews = stackView.arrangedSubviews.compactMap { $0 as? UIStackView }
guard !childStackViews.isEmpty else {
return [stackView]
}
return childStackViews.flatMap {
deepestControllerNavigationStacks(from: $0)
}
}
private func shouldSkipControllerNavigationSection(_ sectionView: MenuSectionView) -> Bool {
guard let identifier = sectionView.identifier else {
@@ -1604,7 +1691,6 @@ extension SettingsViewController: ControllerUINavigationDelegate {
if skippingDisabledStacks, hasDisabledArrangedSubview(in: stackView) {
return []
}
return [stackView]
}
+51 -8
View File
@@ -10,9 +10,9 @@
//
#import "ControllerSupport.h"
#import "OnScreenControls.h"
#import "VoidController.h"
#import "VoidLink-Swift.h"
#import "OnScreenControls.h"
#import "DataManager.h"
#include "Limelight.h"
@@ -118,6 +118,9 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
TemporarySettings* tempSettings;
OSCProfile* oscProfile;
OSCProfilesManager* oscProfileMan;
#if !TARGET_OS_TV
GameSirG8MFiRumbler *_gameSirG8MFiRumbler;
#endif
#define EMULATING_SELECT 0x1
#define EMULATING_SPECIAL 0x2
@@ -155,6 +158,20 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
#define MAX_MAGNITUDE(x, y) (abs(x) > abs(y) ? (x) : (y))
-(void) applyPhysicalControllerRumble:(VoidController*)controller lowFreqMotor:(unsigned short)lowFreqMotor highFreqMotor:(unsigned short)highFreqMotor
{
#if !TARGET_OS_TV
if (controller.hardware == ControllerHardwareG8PlusMFi) {
// NSLog(@"[G8Rumble] route native rumble low=%hu high=%hu", lowFreqMotor, highFreqMotor);
[_gameSirG8MFiRumbler setLowFrequencyMotor:lowFreqMotor highFrequencyMotor:highFreqMotor];
return;
}
#endif
[controller.lowFreqMotor setMotorAmplitude:lowFreqMotor];
[controller.highFreqMotor setMotorAmplitude:highFreqMotor];
}
-(void) rumble:(unsigned short)controllerNumber lowFreqMotor:(unsigned short)lowFreqMotor highFreqMotor:(unsigned short)highFreqMotor
{
VoidController* voidController = [_voidControllers objectForKey:[NSNumber numberWithInteger:controllerNumber]];
@@ -173,23 +190,32 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
// No connected controller for this player
return;
}
/*
NSLog(@"[G8Rumble] rumble request controller=%hu low=%hu high=%hu preference=%ld vendor=%@",
controllerNumber,
lowFreqMotor,
highFreqMotor,
(long)preference,
voidController.gamepad.vendorName);
*/
// physical controller connected:
switch (preference) {
case HapticEngineAuto:
// if controller has no haptic profile, it already falled bakc to device engine
[voidController.lowFreqMotor setMotorAmplitude:lowFreqMotor];
[voidController.highFreqMotor setMotorAmplitude:highFreqMotor];
[self applyPhysicalControllerRumble:voidController lowFreqMotor:lowFreqMotor highFreqMotor:highFreqMotor];
break;
case RumbleDevice:
[self applyPhysicalControllerRumble:voidController lowFreqMotor:0 highFreqMotor:0];
[_oscController.lowFreqMotor setMotorAmplitude:lowFreqMotor];
[_oscController.highFreqMotor setMotorAmplitude:highFreqMotor];
break;
case LeftRightSwapped:
[voidController.lowFreqMotor setMotorAmplitude:highFreqMotor];
[voidController.highFreqMotor setMotorAmplitude:lowFreqMotor];
[self applyPhysicalControllerRumble:voidController lowFreqMotor:highFreqMotor highFreqMotor:lowFreqMotor];
break;
case RumbleOff:
[self applyPhysicalControllerRumble:voidController lowFreqMotor:0 highFreqMotor:0];
break;
default:
break;
@@ -305,7 +331,7 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
deviceAccelSample.x += voidController.motionManager.deviceMotion.gravity.x;
deviceAccelSample.y += voidController.motionManager.deviceMotion.gravity.y;
deviceAccelSample.z += voidController.motionManager.deviceMotion.gravity.z;
NSLog(@"sending device accel %f", CACurrentMediaTime());
// NSLog(@"sending device accel %f", CACurrentMediaTime());
UIInterfaceOrientation interfaceOrientation = [self currentInterfaceOrientation];
CMAcceleration mappedDeviceAccelSample = {};
@@ -460,10 +486,10 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger,
block:^(NSTimer *timer) {
GCAcceleration lastAccelSample = voidController.lastAccelSample;
GCAcceleration accelSample = voidController.gamepad.motion.acceleration;
if (memcmp(&accelSample, &lastAccelSample, sizeof(accelSample)) == 0) {
return;
}
// NSLog(@"sending controller accel %f", CACurrentMediaTime());
voidController.lastAccelSample = accelSample;
@@ -1319,7 +1345,8 @@ double rc_expo(double x, double expo) {
- (bool)useMotionHandler{
return true;
return tempSettings.gyroMode.intValue == GyroModeOff
|| tempSettings.gyroMode.intValue == AlwaysDevice;
}
- (void)switchMotionControlOnOffByControllerButton{
@@ -1747,6 +1774,11 @@ double rc_expo(double x, double expo) {
voidController.motionTypes = [[NSMutableSet alloc] init];
voidController.supportedEmulationFlags = EMULATING_SPECIAL | EMULATING_SELECT;
voidController.gamepad = controller;
#if !TARGET_OS_TV
voidController.hardware = [_gameSirG8MFiRumbler isTargetController:controller] ? ControllerHardwareG8PlusMFi : ControllerHardwareGeneric;
#else
voidController.hardware = ControllerHardwareGeneric;
#endif
voidController.hasAccelerometer = NO;
voidController.hasGyroscope = NO;
@@ -2005,6 +2037,9 @@ double rc_expo(double x, double expo) {
_voidControllers = [[NSMutableDictionary alloc] init];
[ControllerUtil.activeStreamingGCControllers removeAllObjects];
_controllerNumbers = 0;
#if !TARGET_OS_TV
_gameSirG8MFiRumbler = [[GameSirG8MFiRumbler alloc] init];
#endif
_captureMouse = (streamConfig.localMousePointerMode == 0);
if (@available(iOS 14.0, tvOS 14.0, *)) {
@@ -2064,6 +2099,11 @@ double rc_expo(double x, double expo) {
VoidController* voidController = [self->_voidControllers objectForKey:[NSNumber numberWithInteger:controller.playerIndex]];
if (voidController) {
#if !TARGET_OS_TV
if ([self->_gameSirG8MFiRumbler isTargetController:controller]) {
[self->_gameSirG8MFiRumbler stopAndClose];
}
#endif
[self stopTimerForController:voidController];
// Stop haptics on this controller
@@ -2267,6 +2307,9 @@ double rc_expo(double x, double expo) {
-(void) cleanup
{
[ControllerUtil stopAllDualSenseHaptics];
#if !TARGET_OS_TV
[_gameSirG8MFiRumbler invalidate];
#endif
if (VLSharedControllerSupport == self) {
VLSharedControllerSupport = nil;
+5
View File
@@ -267,6 +267,11 @@ import UIKit
case xboxAndPs = 0x00 // LI_CTYPE_UNKNOWN
}
@objc enum ControllerHardware: UInt8 {
case generic
case g8PlusMFi
}
@objc protocol ControllerUtilDelegate: AnyObject {
func isStreaming() -> Bool
func isInAppView() -> Bool
+456
View File
@@ -0,0 +1,456 @@
//
// GameSirG8MFiRumble.m -> GameSirG8MFiRumbler.swift
// VoidLink
//
// Created by chrisnch on 2026/08/22
// Copyright © 2026 chrisnch@github. All rights reserved.
//
// Modified by True砖家 on 2026/08/25
// Copyright © 2026 True砖家 on Bilibili. All rights reserved.
//
import ExternalAccessory
import Foundation
import GameController
import UIKit
@objcMembers
final class GameSirG8MFiRumbler: NSObject, StreamDelegate {
private static let gameSirG8MFiProtocol = "com.xiaoji.M2boot"
private static let gameSirManufacturer = "GameSir"
private static let gameSirG8MFiModel = "G8+ MFi"
static var repeatInterval: TimeInterval = 0.167253
private var session: EASession?
private var inFlightPacket: Data?
private var inFlightOffset = 0
private var queuedPacket: Data?
private var lastSentPacket: Data?
private var repeatTimer: Timer?
private var repeatPacket: Data?
private var appActive: Bool
private var invalidated = false
override init() {
appActive = UIApplication.shared.applicationState == .active
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationWillResignActive(_:)),
name: UIApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidBecomeActive(_:)),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
assert(Thread.isMainThread || session == nil, "Active GameSir sessions must be closed on the main thread before deallocation")
if Thread.isMainThread {
stopAndCloseOnMainThread()
}
}
func canHandleController(_ controller: GCController) -> Bool {
return isTargetController(controller) && connectedAccessory() != nil
}
func isTargetController(_ controller: GCController) -> Bool {
guard let vendorName = controller.vendorName, !vendorName.isEmpty else {
return false
}
return vendorName.range(of: Self.gameSirManufacturer, options: [.caseInsensitive, .diacriticInsensitive]) != nil &&
vendorName.range(of: "G8+", options: [.caseInsensitive, .diacriticInsensitive]) != nil &&
vendorName.range(of: "MFi", options: [.caseInsensitive, .diacriticInsensitive]) != nil
}
@objc(setLowFrequencyMotor:highFrequencyMotor:)
func setLowFrequencyMotor(_ lowFrequencyMotor: UInt16, highFrequencyMotor: UInt16) {
objc_sync_enter(self)
let isInvalidated = invalidated
objc_sync_exit(self)
if isInvalidated {
return
}
let packet = packet(lowFrequencyMotor: lowFrequencyMotor, highFrequencyMotor: highFrequencyMotor)
// NSLog("[G8Rumble] queue request low=%hu high=%hu packet=%@", lowFrequencyMotor, highFrequencyMotor, packet as NSData)
let shouldRepeat = lowFrequencyMotor != 0 || highFrequencyMotor != 0
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
objc_sync_enter(self)
let isInvalidated = self.invalidated
objc_sync_exit(self)
if isInvalidated {
// NSLog("[G8Rumble] drop packet because rumble handler is invalidated")
return
}
if !self.appActive {
// NSLog("[G8Rumble] drop packet because app is inactive packet=%@", packet as NSData)
return
}
if shouldRepeat {
self.stopRepeatTimer(clearPacket: false)
self.repeatPacket = packet
self.startRepeatTimer()
} else {
self.stopRepeatTimer()
}
self.queuePacket(packet)
}
}
func stopAndClose() {
if Thread.isMainThread {
stopAndCloseOnMainThread()
return
}
DispatchQueue.main.async { [weak self] in
self?.stopAndCloseOnMainThread()
}
}
func invalidate() {
objc_sync_enter(self)
invalidated = true
objc_sync_exit(self)
stopAndClose()
}
@objc private func applicationWillResignActive(_ notification: Notification) {
appActive = false
stopAndCloseOnMainThread()
}
@objc private func applicationDidBecomeActive(_ notification: Notification) {
appActive = true
}
private func connectedAccessory() -> EAAccessory? {
var sawAccessory = false
for accessory in EAAccessoryManager.shared().connectedAccessories {
sawAccessory = true
/*
NSLog(
"[G8Rumble] EA accessory name=%@ manufacturer=%@ model=%@ protocols=%@ connected=%d",
accessory.name,
accessory.manufacturer,
accessory.modelNumber,
accessory.protocolStrings,
accessory.isConnected
) */
guard accessory.isConnected,
accessory.protocolStrings.contains(Self.gameSirG8MFiProtocol),
!accessory.manufacturer.isEmpty,
!accessory.modelNumber.isEmpty,
accessory.manufacturer.caseInsensitiveCompare(Self.gameSirManufacturer) == .orderedSame,
accessory.modelNumber.caseInsensitiveCompare(Self.gameSirG8MFiModel) == .orderedSame else {
continue
}
NSLog("[G8Rumble] matched GameSir G8+ MFi accessory name=%@", accessory.name)
return accessory
}
if sawAccessory {
NSLog("[G8Rumble] no matching GameSir G8+ MFi accessory")
} else {
NSLog("[G8Rumble] no connected EA accessories")
}
return nil
}
private static func convertMotorAmplitude(_ amplitude: UInt16) -> UInt8 {
return UInt8((UInt32(amplitude) * 255 + 32767) / 65535)
}
private func packet(lowFrequencyMotor: UInt16, highFrequencyMotor: UInt16) -> Data {
return Data([
0x04,
Self.convertMotorAmplitude(lowFrequencyMotor),
0x01,
Self.convertMotorAmplitude(highFrequencyMotor),
0x01,
0x00,
0x00,
0x00,
0x00,
])
}
private func openSessionIfNeeded() -> Bool {
if session != nil {
return true
}
guard let accessory = connectedAccessory() else {
NSLog("[G8Rumble] cannot open session: accessory not found")
return false
}
NSLog("[G8Rumble] opening EASession protocol=%@ accessory=%@", Self.gameSirG8MFiProtocol, accessory.name)
guard let newSession = EASession(accessory: accessory, forProtocol: Self.gameSirG8MFiProtocol),
let inputStream = newSession.inputStream,
let outputStream = newSession.outputStream else {
NSLog("[G8Rumble] failed to create EASession")
return false
}
session = newSession
inputStream.delegate = self
outputStream.delegate = self
inputStream.schedule(in: .main, forMode: .default)
outputStream.schedule(in: .main, forMode: .default)
inputStream.open()
outputStream.open()
/*
NSLog(
"[G8Rumble] EASession opened inputStatus=%ld outputStatus=%ld",
inputStream.streamStatus.rawValue,
outputStream.streamStatus.rawValue
) */
return true
}
private func startRepeatTimer() {
let timer = Timer(timeInterval: Self.repeatInterval, repeats: true) { [weak self] _ in
self?.repeatCurrentPacket()
}
repeatTimer = timer
RunLoop.main.add(timer, forMode: .common)
// NSLog("[G8Rumble] repeat timer started interval=%.3f", Self.repeatInterval)
}
private func stopRepeatTimer(clearPacket: Bool = true) {
guard let timer = repeatTimer else {
if clearPacket {
repeatPacket = nil
}
return
}
timer.invalidate()
repeatTimer = nil
if clearPacket {
repeatPacket = nil
}
// NSLog("[G8Rumble] repeat timer stopped")
}
private func repeatCurrentPacket() {
objc_sync_enter(self)
let isInvalidated = invalidated
objc_sync_exit(self)
if isInvalidated || !appActive {
stopRepeatTimer()
return
}
guard let packet = repeatPacket else {
stopRepeatTimer()
return
}
queuePacket(packet)
}
private func queuePacket(_ packet: Data) {
queuedPacket = packet
// NSLog("[G8Rumble] queued packet=%@", packet as NSData)
if openSessionIfNeeded() {
flushOutput()
} else {
// NSLog("[G8Rumble] packet queued but session is not open packet=%@", packet as NSData)
}
}
private func flushOutput() {
guard let outputStream = session?.outputStream else {
return
}
guard outputStream.streamStatus == .open, outputStream.hasSpaceAvailable else {
/*
NSLog(
"[G8Rumble] flush skipped status=%ld hasSpace=%d queued=%@ inFlight=%@",
outputStream.streamStatus.rawValue,
outputStream.hasSpaceAvailable,
(queuedPacket as NSData?) ?? NSNull(),
(inFlightPacket as NSData?) ?? NSNull()
) */
return
}
while outputStream.hasSpaceAvailable {
if inFlightPacket == nil {
guard let packet = queuedPacket else {
return
}
inFlightPacket = packet
queuedPacket = nil
inFlightOffset = 0
}
guard let packet = inFlightPacket else {
return
}
let remainingLength = packet.count - inFlightOffset
let written = packet.withUnsafeBytes { rawBuffer -> Int in
guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else {
return -1
}
return outputStream.write(baseAddress.advanced(by: inFlightOffset), maxLength: remainingLength)
}
if written <= 0 {
NSLog(
"[G8Rumble] write failed packet=%@ offset=%lu remaining=%lu written=%ld status=%ld error=%@",
packet as NSData,
UInt(inFlightOffset),
UInt(remainingLength),
written,
outputStream.streamStatus.rawValue,
outputStream.streamError as NSError? ?? NSNull()
)
return
}
/*
NSLog(
"[G8Rumble] write packet=%@ offset=%lu remaining=%lu written=%ld status=%ld",
packet as NSData,
UInt(inFlightOffset),
UInt(remainingLength),
written,
outputStream.streamStatus.rawValue
) */
inFlightOffset += written
if inFlightOffset == packet.count {
// NSLog("[G8Rumble] packet sent %@", packet as NSData)
lastSentPacket = packet
inFlightPacket = nil
inFlightOffset = 0
}
}
}
private func writeStopPacketBestEffort() {
guard let outputStream = session?.outputStream else {
return
}
guard outputStream.streamStatus == .open, outputStream.hasSpaceAvailable else {
return
}
queuedPacket = nil
flushOutput()
if inFlightPacket != nil {
return
}
let stopPacket = packet(lowFrequencyMotor: 0, highFrequencyMotor: 0)
var offset = 0
while offset < stopPacket.count && outputStream.hasSpaceAvailable {
let written = stopPacket.withUnsafeBytes { rawBuffer -> Int in
guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else {
return -1
}
return outputStream.write(baseAddress.advanced(by: offset), maxLength: stopPacket.count - offset)
}
if written <= 0 {
NSLog(
"[G8Rumble] stop packet write failed offset=%lu written=%ld status=%ld error=%@",
UInt(offset),
written,
outputStream.streamStatus.rawValue,
outputStream.streamError as NSError? ?? NSNull()
)
break
}
offset += written
}
if offset == stopPacket.count {
lastSentPacket = stopPacket
}
NSLog("[G8Rumble] stop packet written=%lu/%lu packet=%@", UInt(offset), UInt(stopPacket.count), stopPacket as NSData)
}
private func closeSession() {
assert(Thread.isMainThread, "GameSir sessions must be closed on the main thread")
stopRepeatTimer()
if let session,
let inputStream = session.inputStream,
let outputStream = session.outputStream {
NSLog(
"[G8Rumble] closing EASession inputStatus=%ld outputStatus=%ld",
inputStream.streamStatus.rawValue,
outputStream.streamStatus.rawValue
)
inputStream.close()
outputStream.close()
inputStream.remove(from: .main, forMode: .default)
outputStream.remove(from: .main, forMode: .default)
inputStream.delegate = nil
outputStream.delegate = nil
}
session = nil
inFlightPacket = nil
inFlightOffset = 0
queuedPacket = nil
lastSentPacket = nil
}
private func stopAndCloseOnMainThread() {
assert(Thread.isMainThread, "GameSir sessions must be stopped on the main thread")
writeStopPacketBestEffort()
closeSession()
}
func stream(_ stream: Stream, handle eventCode: Stream.Event) {
guard let session,
let inputStream = session.inputStream,
let outputStream = session.outputStream else {
return
}
guard stream === inputStream || stream === outputStream else {
return
}
switch eventCode {
case .hasBytesAvailable:
var buffer = [UInt8](repeating: 0, count: 64)
while inputStream.hasBytesAvailable {
if inputStream.read(&buffer, maxLength: buffer.count) <= 0 {
break
}
}
case .openCompleted, .hasSpaceAvailable:
if stream === outputStream {
flushOutput()
}
case .errorOccurred, .endEncountered:
writeStopPacketBestEffort()
closeSession()
default:
break
}
}
}
+4
View File
@@ -75,6 +75,7 @@ import ObjectiveC.runtime
func resetMagnifierStreamView(animated:Bool)
@objc(restoreMagnifierStreamViewWithOffset:scale:)
func restoreMagnifierStreamView(offset: CGPoint, scale: CGFloat)
func startStreamViewInteractionTimer()
}
@objc enum WidgetTypeEnum: UInt8 {
@@ -3630,6 +3631,7 @@ import ObjectiveC.runtime
}
@objc private func vl_autoDock_touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
self.functionalWidgetDelegate?.startStreamViewInteractionTimer()
guard autoDockEnabled else {
OnScreenWidgetView.autoDockOriginalTouchesBeganIMP?(self, #selector(OnScreenWidgetView.touchesBegan(_:with:)), touches, event)
return
@@ -3784,6 +3786,8 @@ import ObjectiveC.runtime
autoDockIdleDuration = storedAutoDockIdleDuration
self.functionalWidgetDelegate?.startStreamViewInteractionTimer()
if !folded {
restartAutoDockCountdown()
return
+2
View File
@@ -80,6 +80,8 @@
- (void)setAllowSingleTouchEnabled:(BOOL)enabled;
- (void)toggleTouchDisabled:(bool)disabled;
- (void)startInteractionTimer;
#if !TARGET_OS_TV
- (void) updateCursorLocation:(CGPoint)location isMouse:(BOOL)isMouse;
#endif
+4
View File
@@ -7,6 +7,7 @@
//
#import "HapticContext.h"
#include <stdint.h>
@import GameController;
@import CoreHaptics;
@@ -14,6 +15,8 @@
@import CoreMotion;
#endif
typedef NS_ENUM(uint8_t, ControllerHardware);
@interface VoidController : NSObject
typedef struct {
@@ -69,6 +72,7 @@ typedef NS_ENUM(NSInteger, ControllerDeviceBatteryState) {
@property(nonatomic, strong) NSMutableSet* _Nullable motionTypes;
@property(nonatomic, assign) uint16_t reportRateHz;
@property(nonatomic, assign) uint16_t controllerNumber;
@property(nonatomic, assign) ControllerHardware hardware;
@property(nonatomic, assign) BOOL hasAccelerometer;
@property(nonatomic, assign) BOOL hasGyroscope;
+4
View File
@@ -116,6 +116,10 @@ VoidLink uses microphone to stream your voice to PC host. Microphone redirection
<false/>
<key>UIStatusBarHidden</key>
<true/>
<key>UISupportedExternalAccessoryProtocols</key>
<array>
<string>com.xiaoji.M2boot</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
+117 -1
View File
@@ -5978,6 +5978,35 @@
}
}
},
"Exit Directly" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Exit Directly"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "直接退出"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "直接退出"
}
},
"zh-HK" : {
"stringUnit" : {
"state" : "translated",
"value" : "直接退出"
}
}
}
},
"Cannot select two touchpad controls at the same time" : {
"extractionState" : "manual",
"localizations" : {
@@ -16411,6 +16440,64 @@
}
}
},
"Save Changes" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Save Changes"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "保存修改"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "儲存修改"
}
},
"zh-HK" : {
"stringUnit" : {
"state" : "translated",
"value" : "儲存修改"
}
}
}
},
"Save changes before closing?" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Save changes before closing?"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "关闭前是否保存修改?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "關閉前是否儲存修改?"
}
},
"zh-HK" : {
"stringUnit" : {
"state" : "translated",
"value" : "關閉前是否儲存修改?"
}
}
}
},
"Saving over the 'Default' profile is not allowed" : {
"extractionState" : "manual",
"localizations" : {
@@ -20138,6 +20225,35 @@
}
}
},
"emulatedGyroModeDisablesBuiltinMotionControlTip" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Turning this option on will disable VoidLink's built-in motion control. If you need the app to convert gyroscope input directly into mouse or controller-stick signals, remember to turn this option off."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "打开该选项,将导致 VoidLink 内置的体感控制功能失效。如果您需要在 App 内部直接将陀螺仪转换为鼠标或手柄摇杆信号,请记得关闭该选项。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "開啟該選項,將導致 VoidLink 內建的體感控制功能失效。如果您需要在 App 內部直接將陀螺儀轉換為滑鼠或手把搖桿訊號,請記得關閉該選項。"
}
},
"zh-HK" : {
"stringUnit" : {
"state" : "translated",
"value" : "開啟該選項,將導致 VoidLink 內置的體感控制功能失效。如果您需要在 App 內部直接將陀螺儀轉換為滑鼠或手掣搖桿訊號,請記得關閉該選項。"
}
}
}
},
"yuv444StackTip" : {
"extractionState" : "manual",
"localizations" : {
@@ -20172,4 +20288,4 @@
}
},
"version" : "1.1"
}
}
+26
View File
@@ -638,6 +638,32 @@ import UIKit
}
)
}
@objc public static func isFirstEnablingEmulatedGyroMode() -> Bool {
let key = "hasEnabledEmulatedGyroMode20260825"
let defaults = UserDefaults.standard
let launchedBefore = defaults.bool(forKey: key)
if !launchedBefore {
defaults.set(true, forKey: key)
return true
}
return false
}
@objc public static func handleEmulatedGyroModeTip(in vc: UIViewController?) {
if isFirstEnablingEmulatedGyroMode() {
AlertControllerUtil.showAlert(
in: vc,
title: LocalizationHelper.localizedString(forKey: "Tips"),
message: "\n\(LocalizationHelper.localizedString(forKey: "emulatedGyroModeDisablesBuiltinMotionControlTip"))",
withCancel: false,
buttonTitle: LocalizationHelper.localizedString(forKey: "This tip won't be shown again"),
countdown: 7
)
}
}
@objc public static func gamepadOverlayFeatureTipTitle() -> String {
LocalizationHelper.localizedString(forKey: "Gamepad Overlay")
+14
View File
@@ -192,6 +192,10 @@ public extension UISegmentedControl {
}
private func storePreviousSelectedSegmentIndex(_ value: Int) {
guard canStorePreviousSelectedSegmentIndex(value) else {
return
}
objc_setAssociatedObject(
self,
&previousSelectedSegmentIndexKey,
@@ -200,6 +204,16 @@ public extension UISegmentedControl {
)
}
private func canStorePreviousSelectedSegmentIndex(_ value: Int) -> Bool {
guard value != UISegmentedControl.noSegment else {
return true
}
guard value >= 0 && value < numberOfSegments else {
return false
}
return isEnabledForSegment(at: value)
}
private func storeLastKnownSelectedSegmentIndex(_ value: Int) {
objc_setAssociatedObject(
self,
@@ -208,6 +208,7 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg
private var latestTouchLocation: CGPoint = .zero
private var vibrationGenerator = UIImpactFeedbackGenerator(style: .medium)
private var widgetSizeTransition: WidgetSizeTransition = .keepWidgetSize
private var hasSavedSinceLastWidgetReload = false
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
let dataMan = DataManager()
@@ -390,6 +391,7 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg
}
self.loadWidgets(from: oscProfile)
self.hasSavedSinceLastWidgetReload = false
if !self.quickSwitchEnabled {
self.originalTrackPointEnabled = OnScreenWidgetView.trackPointEnabled
@@ -1158,6 +1160,31 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg
}
@IBAction func closeTapped(_ sender: Any?) {
guard profilesManager.getSelectedProfile().name != "Default",
!hasSavedSinceLastWidgetReload else {
dismissLayoutEditor()
return
}
AlertControllerUtil.cancelButtonString = "Exit Directly".localized
AlertControllerUtil.showAlert(
in: self,
title: "Save Changes".localized,
message: "Save changes before closing?".localized,
withCancel: true,
buttonTitle: "Save".localized,
countdown: 0,
completion: { [weak self] in
guard let self else { return }
if !AlertControllerUtil.actionCancelled {
self.saveTapped(nil)
}
self.dismissLayoutEditor()
}
)
}
private func dismissLayoutEditor() {
clearSickInput()
dismiss(animated: true) {
OnScreenWidgetView.trackPointEnabled = self.originalTrackPointEnabled
@@ -1573,6 +1600,7 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg
clearSickInput()
OSCProfilesManager.setLayoutViewBounds(view.bounds)
let success = profilesManager.updateSelectedProfile(layoutOSC.oscButtonLayerPool)
hasSavedSinceLastWidgetReload = success
guard sender != nil else { return }
let message = success
? LocalizationHelper.localizedString(forKey: "profileSaveTip")
@@ -1402,8 +1402,13 @@ static NSMutableSet* hostList;
// Enable frame pacing mode selector only if not in stream view AND not in performance mode
BOOL shouldEnableFrameQueueSettings = !_settingsExpandedInStreamView && (self.settingsViewController.renderingBackendSelector.selectedSegmentIndex != RENDER_METAL);
// [self.settingsViewController.framePacingModeSelector setEnabled:shouldEnableFrameQueueSettings];
[self.settingsViewController.framePacingModeSelector setEnabled:!_settingsExpandedInStreamView forSegmentAtIndex:FramePacingModeOff];
[self.settingsViewController.framePacingModeSelector setEnabled:!_settingsExpandedInStreamView forSegmentAtIndex:FramePacingModeLegacy];
if(self.settingsViewController.framePacingModeSelector.selectedSegmentIndex != FramePacingModeOff
&& self.settingsViewController.framePacingModeSelector.selectedSegmentIndex != FramePacingModeLegacy) {
[self.settingsViewController.framePacingModeSelector setEnabled:!_settingsExpandedInStreamView forSegmentAtIndex:FramePacingModeOff];
[self.settingsViewController.framePacingModeSelector setEnabled:!_settingsExpandedInStreamView forSegmentAtIndex:FramePacingModeLegacy];
}
else [self.settingsViewController.framePacingModeSelector setEnabled:!_settingsExpandedInStreamView];
if(FrameInterpolator.deviceSupportsInterpolation){
[self.settingsViewController.framePacingModeSelector setEnabled:_settingsViewController.framerateSelector.selectedSegmentIndex != _settingsViewController.framerateSelector.numberOfSegments-1
@@ -2813,7 +2818,8 @@ static NSMutableSet* hostList;
dispatch_async(dispatch_get_main_queue(), ^{
switch (item) {
case RadialMenuItemSettings:
[[self revealViewController] revealToggleAnimated:YES];
if(self.isStreaming && !self.settingsViewExpanded) [self->streamFrameViewController expandSettingsView];
else [[self revealViewController] revealToggleAnimated:YES];
break;
case RadialMenuItemAllSettings:
[self.revealViewController allSettingSelected];
@@ -2347,6 +2347,8 @@ BOOL isCustomResolution(int resolutionSelected) {
[self.dualSenseTransientSlider addTarget:self action:@selector(dualSenseTransientSliderMoved:) forControlEvents:UIControlEventValueChanged];
self.gyroModeSelector.selectedSegmentIndex = self->tempSettings.gyroMode.intValue;
[self.gyroModeSelector addTarget:self action:@selector(emulatedGyroModeChanged:) forControlEvents:(UIControlEventValueChanged)]; // Update label display when slider is being moved.
[self.gyroSensitivitySlider setValue: (uint16_t)(self->tempSettings.gyroSensitivity.floatValue * 100) animated:NO]; // Load old setting.
[self.gyroSensitivitySlider addTarget:self action:@selector(gyroSensitivitySliderMoved:) forControlEvents:(UIControlEventValueChanged)]; // Update label display when slider is being moved.
[self.gyroSensitivitySlider sendActionsForControlEvents:UIControlEventValueChanged]; // Load old setting.
@@ -3458,6 +3460,11 @@ BOOL isCustomResolution(int resolutionSelected) {
[self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)sender.value]; // Update label display
}
- (void)emulatedGyroModeChanged:(UISegmentedControl* )sender {
if(sender.selectedSegmentIndex == GyroModeOff) return;
[GenericUtils handleEmulatedGyroModeTipIn:self];
}
- (void) localVolumeSliderMoved:(UISlider* )sender {
[self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)sender.value]; // Update label display
if(_mainFrameViewController.settingsExpandedInStreamView) [Connection setVolume:sender.value/100];
@@ -2044,6 +2044,7 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil
}
- (void)gamepadPresenceChanged {
if(PublicUtils.iOS26Available) return;
#if !TARGET_OS_TV
if (@available(iOS 11.0, *)) {
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
@@ -2079,6 +2080,7 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil
// also discard our edges deferring system gestures unless
// we willingly give up home bar hiding preference.
_userIsInteracting = YES;
if(PublicUtils.iOS26Available) return;
#if !TARGET_OS_TV
if (@available(iOS 11.0, *)) {
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
@@ -2089,6 +2091,7 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil
- (void)userInteractionEnded {
// Enable home bar hiding again if conditions allow
_userIsInteracting = NO;
if(PublicUtils.iOS26Available) return;
#if !TARGET_OS_TV
if (@available(iOS 11.0, *)) {
[self setNeedsUpdateOfHomeIndicatorAutoHidden];
@@ -2370,6 +2373,9 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil
});
}
- (void)startStreamViewInteractionTimer {
[_streamView startInteractionTimer];
}
- (void)dealloc {
if (VLSharedStreamFrameViewController == self) {