diff --git a/VoidLink/Base.lproj/iPad.storyboard b/VoidLink/Base.lproj/iPad.storyboard index a2b93428..909bdac8 100644 --- a/VoidLink/Base.lproj/iPad.storyboard +++ b/VoidLink/Base.lproj/iPad.storyboard @@ -500,10 +500,11 @@ - + + @@ -2943,7 +2944,7 @@ - + @@ -2952,13 +2953,13 @@ - + - + - + diff --git a/VoidLink/Base.lproj/iPhone.storyboard b/VoidLink/Base.lproj/iPhone.storyboard index d3538fe4..28e66e55 100644 --- a/VoidLink/Base.lproj/iPhone.storyboard +++ b/VoidLink/Base.lproj/iPhone.storyboard @@ -1700,10 +1700,11 @@ - + + @@ -2937,19 +2938,19 @@ - + - + - + - + diff --git a/VoidLink/Database/DataManager.h b/VoidLink/Database/DataManager.h index 4136420d..b5900842 100644 --- a/VoidLink/Database/DataManager.h +++ b/VoidLink/Database/DataManager.h @@ -27,11 +27,6 @@ typedef NS_ENUM(NSInteger, ControllerGyroSwitchMode) { ControllerGyroSwitchHoldDown }; -typedef NS_ENUM(NSInteger, AudioEngine) { - SystemAudioEngine, - SDLAudioEngine -}; - typedef NS_ENUM(NSInteger, TouchMode) { RelativeTouch, NativeTouch, diff --git a/VoidLink/Input/InertialScroller.swift b/VoidLink/Input/InertialScroller.swift index 8f037679..a0427ad7 100644 --- a/VoidLink/Input/InertialScroller.swift +++ b/VoidLink/Input/InertialScroller.swift @@ -2,7 +2,7 @@ // InertialScroller.swift // VoidLink // -// Created by True砖家 on 2025/11/5. +// Created by True砖家 on 2025/12/5. // Copyright © 2025 True砖家 on Bilibili. All rights reserved. // diff --git a/VoidLink/Input/MicHandler.swift b/VoidLink/Input/MicHandler.swift index 8bb33ca0..4d82c57b 100644 --- a/VoidLink/Input/MicHandler.swift +++ b/VoidLink/Input/MicHandler.swift @@ -16,6 +16,7 @@ import Collections @objcMembers public class MicHandler: NSObject { + private var notificationTokens = [NSObjectProtocol]() private let engine = AVAudioEngine() private let playerNode = AVAudioPlayerNode() private var audioSink: Any? @@ -48,6 +49,16 @@ public class MicHandler: NSObject { @objc public init(useBuiltinMic:Bool) { super.init() + + let token = NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.handleInterruption(notification) + } + notificationTokens.append(token) + self.useBuiltinMic = useBuiltinMic do { try configureSession() @@ -140,6 +151,29 @@ public class MicHandler: NSObject { /* ----------- Audio Session -------------*/ + private func handleInterruption(_ notification: Notification) { + guard let info = notification.userInfo, + let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { return } + + switch type { + case .began: + self.stopTapping(stopEngine: true) + case .ended: + do { + try configureEngine() + } catch { + notify(error) + } + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + self.startTapping() + } + @unknown default: + break + } + } + private func configureOpus(sampleRate: Int32, channels: Int) throws { var err: Int32 = 0 guard let enc = opus_encoder_create(sampleRate, Int32(channels), OPUS_APPLICATION_VOIP, &err), err == OPUS_OK else { @@ -336,6 +370,10 @@ public class MicHandler: NSObject { } @objc public func clean() { + for token in notificationTokens { + NotificationCenter.default.removeObserver(token) + } + notificationTokens.removeAll() self.timer?.clean() } diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index eab2bfca..7c23756a 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -858,6 +858,35 @@ } } }, + "audioConfigStackTip" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "- Stereo: Stereo mode driven by the system audio engine. Offers better audio quality and continues playback when the screen is off.\n\n- Stereo (SDL): Stereo mode driven by the SDL engine. This mode is essentially equivalent to Moonlight-iOS audio. Playback cannot be maintained after the screen is turned off.\n\n- 5.1 / 7.1 Channels: both are driven by the SDL engine. Playback cannot be maintained after the screen is turned off. Available for iOS 18+." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "- 双声道:由系统音频引擎驱动的双声道模式。音质更好、熄屏可维持播放。\n\n- 双声道(SDL):由SDL引擎驱动的双声道模式。此模式基本上等价于moonlight-iOS音频。熄屏后无法维持播放。\n\n- 5.1 / 7.1 声道:均为SDL引擎驱动。熄屏后无法维持播放。需 iOS 18 以上支持。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "- 雙聲道:由系統音訊引擎驅動的雙聲道模式。音質較佳,且在熄屏後仍可維持播放。\n\n- 雙聲道(SDL):由 SDL 引擎驅動的雙聲道模式。此模式基本等同於 Moonlight-iOS 的音訊。熄屏後無法維持播放。\n\n5.1/7.1 聲道:皆由 SDL 引擎驅動。熄屏後無法維持播放,需 iOS 18 以上支援。" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "- 雙聲道:由系統音訊引擎驅動的雙聲道模式。音質較佳,且在熄屏後仍可維持播放。\n\n- 雙聲道(SDL):由 SDL 引擎驅動的雙聲道模式。此模式基本等同於 Moonlight-iOS 的音訊。熄屏後無法維持播放。\n\n5.1/7.1 聲道:皆由 SDL 引擎驅動。熄屏後無法維持播放,需 iOS 18 以上支援。" + } + } + } + }, "Auto tap disabled" : { "extractionState" : "manual", "localizations" : { diff --git a/VoidLink/Stream/Connection.m b/VoidLink/Stream/Connection.m index ad2e2626..2acf3dc4 100644 --- a/VoidLink/Stream/Connection.m +++ b/VoidLink/Stream/Connection.m @@ -51,6 +51,7 @@ static float volume = 1.0; static int audioFrameSize; static bool useSystemAudioEngine; +static bool audioSessionInterrupted; static AVAudioEngine *audioEngine; static AVAudioPlayerNode *audioPlayerNode; static AVAudioPCMBuffer *pcmBuffer; @@ -304,7 +305,8 @@ int ArInit(int audioConfiguration, POPUS_MULTISTREAM_CONFIGURATION opusConfig, v error:nil]; if(tempSettings.redirectMic) if(@available(iOS 13.0, *)) [session setAllowHapticsAndSystemSoundsDuringRecording:YES error:nil]; [session setActive:YES error:nil]; - + audioSessionInterrupted = false; + AudioEngineInit(audioConfig.sampleRate, audioConfig.channelCount); return 0; @@ -423,7 +425,7 @@ void ArDecodeAndPlaySample(char* sampleData, int sampleLength) float* fbuf = (float*)audioBuffer; - if(audioConfig.channelCount <= 2) { + if(useSystemAudioEngine){ // 创建 AVAudioPCMBuffer AVAudioFrameCount frameCount = decodeLen; AVAudioPCMBuffer *buffer = [[AVAudioPCMBuffer alloc] initWithPCMFormat:audioFormat frameCapacity:frameCount]; @@ -437,7 +439,7 @@ void ArDecodeAndPlaySample(char* sampleData, int sampleLength) } } // 播放 - [audioPlayerNode scheduleBuffer:buffer completionHandler:nil]; + if(!audioSessionInterrupted) [audioPlayerNode scheduleBuffer:buffer completionHandler:nil]; } else{ @@ -458,6 +460,7 @@ void ArDecodeAndPlaySample(char* sampleData, int sampleLength) Log(LOG_E, @"Failed to queue audio sample: %s\n", SDL_GetError()); } } + } } @@ -534,6 +537,7 @@ void ClSetControllerLED(uint16_t controllerNumber, uint8_t r, uint8_t g, uint8_t LiInterruptConnection(); [audioPlayerNode stop]; [audioEngine stop]; + [[NSNotificationCenter defaultCenter] removeObserver:self]; // We dispatch this async to get out because this can be invoked // on a thread inside common and we don't want to deadlock. It also avoids @@ -545,6 +549,27 @@ void ClSetControllerLED(uint16_t controllerNumber, uint8_t r, uint8_t g, uint8_t }); } +- (void)handleAudioSessionInterruption:(NSNotification *)notification { + NSDictionary *info = notification.userInfo; + AVAudioSessionInterruptionType type = + [info[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue]; + + switch (type) { + case AVAudioSessionInterruptionTypeBegan: + audioSessionInterrupted = true; + [audioPlayerNode stop]; + [audioEngine stop]; + break; + case AVAudioSessionInterruptionTypeEnded: + AudioEngineInit(audioConfig.sampleRate, audioConfig.channelCount); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.5*NSEC_PER_SEC), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + audioSessionInterrupted = false; + }); + default: + break; + } +} + -(id) initWithConfig:(StreamConfiguration*)config renderer:(VideoDecoderRenderer*)myRenderer connectionCallbacks:(id)callbacks { self = [super init]; @@ -662,7 +687,12 @@ void ClSetControllerLED(uint16_t controllerNumber, uint8_t r, uint8_t g, uint8_t _clCallbacks.rumbleTriggers = ClRumbleTriggers; _clCallbacks.setMotionEventState = ClSetMotionEventState; _clCallbacks.setControllerLED = ClSetControllerLED; - + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleAudioSessionInterruption:) + name:AVAudioSessionInterruptionNotification + object:nil]; + return self; } diff --git a/VoidLink/ViewControllers/MainFrameViewController.m b/VoidLink/ViewControllers/MainFrameViewController.m index 963c7dc7..ec78583c 100644 --- a/VoidLink/ViewControllers/MainFrameViewController.m +++ b/VoidLink/ViewControllers/MainFrameViewController.m @@ -850,6 +850,7 @@ static NSMutableSet* hostList; } int numberOfChannels = MIN([streamSettings.audioConfig intValue], physicalOutputChannels); + Log(LOG_I, @"Selected number of audio channels %d", numberOfChannels); if (numberOfChannels >= 8) { _streamConfig.audioConfiguration = AUDIO_CONFIGURATION_71_SURROUND; @@ -861,6 +862,7 @@ static NSMutableSet* hostList; _streamConfig.audioConfiguration = AUDIO_CONFIGURATION_STEREO; } + Connection.useSystemAudioEngine = streamSettings.audioConfig.intValue == 2; switch (streamSettings.preferredCodec) { case CODEC_PREF_AV1: @@ -1191,7 +1193,24 @@ static NSMutableSet* hostList; [settingsViewController.emulatedControllerTypeSelector setEnabled:!_settingsExpandedInStreamView]; [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.citrixX1MouseStack]; [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.externalDisplayModeStack]; - [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.audioConfigStack]; + + if(settingsViewController.audioConfigSelector.numberOfSegments>2){ + if(_settingsExpandedInStreamView){ + if(settingsViewController.audioConfigSelector.selectedSegmentIndex>=2){ + [settingsViewController.audioConfigSelector setEnabled:false]; + } + else { + [settingsViewController.audioConfigSelector setEnabled:false forSegmentAtIndex:2]; + [settingsViewController.audioConfigSelector setEnabled:false forSegmentAtIndex:3]; + } + } + else{ + [settingsViewController.audioConfigSelector setEnabled:true]; + [settingsViewController.audioConfigSelector setEnabled:true forSegmentAtIndex:2]; + [settingsViewController.audioConfigSelector setEnabled:true forSegmentAtIndex:3]; + } + } + [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.duckOtherAppStack]; [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.pipStack]; [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.appThemeStack]; diff --git a/VoidLink/ViewControllers/SettingsViewController.m b/VoidLink/ViewControllers/SettingsViewController.m index 9f71d5cc..f10ec842 100644 --- a/VoidLink/ViewControllers/SettingsViewController.m +++ b/VoidLink/ViewControllers/SettingsViewController.m @@ -895,7 +895,7 @@ BOOL isCustomResolution(int resolutionSelected) { [self addSetting:self.muteInBackgroundStack ofId:@"muteInBackgroundStack" withInfoTag:NO withDynamicLabel:NO to:audioSection]; // [self addSetting:self.audioEngineStack ofId:@"audioEngineStack" withInfoTag:YES withDynamicLabel:NO to:audioSection]; // cancel audio engine selector due to system engine is unable to playback multi-channel audio - [self addSetting:self.audioConfigStack ofId:@"audioConfigStack" withInfoTag:NO withDynamicLabel:NO to:audioSection]; + [self addSetting:self.audioConfigStack ofId:@"audioConfigStack" withInfoTag:YES withDynamicLabel:NO to:audioSection]; [audioSection addToParentStack:_parentStack]; // [audioSection setExpanded:NO]; @@ -1442,6 +1442,10 @@ BOOL isCustomResolution(int resolutionSelected) { tipText = [LocalizationHelper localizedStringForKey:@"physicaStickMinOffsetTip"]; showOnlineDocAction = false; } + if([sender.superview.accessibilityIdentifier isEqualToString: @"audioConfigStack"]){ + tipText = [LocalizationHelper localizedStringForKey:@"audioConfigStackTip"]; + showOnlineDocAction = false; + } UIAlertController *tipsAlertController = [UIAlertController alertControllerWithTitle: [LocalizationHelper localizedStringForKey:@"Tips"] message:tipText preferredStyle:UIAlertControllerStyleAlert]; @@ -1927,22 +1931,30 @@ BOOL isCustomResolution(int resolutionSelected) { self.audioEngineSelector.selectedSegmentIndex = self->tempSettings.audioEngine.intValue; if (@available(iOS 18.0, tvOS 18.0, *)) {}else{ - [self.audioConfigSelector removeSegmentAtIndex:1 animated:false]; - [self.audioConfigSelector removeSegmentAtIndex:1 animated:false]; // segment 2 goes away when you remove index 2 + [self.audioConfigSelector removeSegmentAtIndex:2 animated:false]; + [self.audioConfigSelector removeSegmentAtIndex:2 animated:false]; // segment 2 goes away when you remove index 2 + /* [self.audioConfigSelector setTitle:[LocalizationHelper localizedStringForKey:@"Stereo (surround sound available for iOS18+)"] forSegmentAtIndex:0]; - [self.audioConfigSelector setEnabled:NO]; + [self.audioConfigSelector setEnabled:NO];*/ } switch ([self->tempSettings.audioConfig integerValue]) { case 2: [self.audioConfigSelector setSelectedSegmentIndex:0]; break; - case 6: + case 3: [self.audioConfigSelector setSelectedSegmentIndex:1]; break; - case 8: + case 6: [self.audioConfigSelector setSelectedSegmentIndex:2]; break; + case 8: + [self.audioConfigSelector setSelectedSegmentIndex:3]; + break; } + // 2 - stereo (system) + // 3 - stereo (SDL) + // 6 - 5.1 (SDL) + // 8 - 7.1 (SDL) // Unlock Display Orientation setting bool unlockDisplayOrientationSelectorEnabled = [self isFullScreenRequired] || [self isIPhone];//need "requires fullscreen" enabled in the app bunddle to make runtime orientation limitation working @@ -3332,7 +3344,12 @@ BOOL isCustomResolution(int resolutionSelected) { NSInteger framerate = [self getChosenFrameRate]; - NSInteger audioConfig = [@[@2, @6, @8][[self.audioConfigSelector selectedSegmentIndex]] integerValue]; + NSInteger audioConfig = [@[@2, @3, @6, @8][[self.audioConfigSelector selectedSegmentIndex]] integerValue]; + // 2 - stereo (system) + // 3 - stereo (SDL) + // 6 - 5.1 (SDL) + // 8 - 7.1 (SDL) + NSInteger renderingBackend = [self.renderingBackendSelector selectedSegmentIndex]; NSInteger framePacingMode = [self.framePacingModeSelector selectedSegmentIndex]; NSInteger onscreenControls = [self.onScreenWidgetSelector selectedSegmentIndex]; diff --git a/VoidLink/ViewControllers/StreamFrameViewController.m b/VoidLink/ViewControllers/StreamFrameViewController.m index c44bf79d..6bca3197 100644 --- a/VoidLink/ViewControllers/StreamFrameViewController.m +++ b/VoidLink/ViewControllers/StreamFrameViewController.m @@ -376,7 +376,6 @@ } else [micHandler stopTappingWithStopEngine:false]; - Connection.useSystemAudioEngine = _settings.audioEngine.intValue == SystemAudioEngine; Connection.muteInBackground = _settings.muteInBackground; if(!viewJustLoaded) [_controllerSupport updateControllerSupport:self.streamConfig delegate:self]; diff --git a/VoidLink/mul.lproj/iPad.xcstrings b/VoidLink/mul.lproj/iPad.xcstrings index e4561e20..a2459e32 100644 --- a/VoidLink/mul.lproj/iPad.xcstrings +++ b/VoidLink/mul.lproj/iPad.xcstrings @@ -3110,7 +3110,37 @@ } }, "iZL-dA-jk9.segmentTitles[1]" : { - "comment" : "Class = \"UISegmentedControl\"; iZL-dA-jk9.segmentTitles[1] = \"5.1‑channel\"; ObjectID = \"iZL-dA-jk9\";", + "comment" : "Class = \"UISegmentedControl\"; iZL-dA-jk9.segmentTitles[1] = \"Stereo(SDL)\"; ObjectID = \"iZL-dA-jk9\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Stereo(SDL)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "双声道(SDL)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "雙聲道(SDL)" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "雙聲道(SDL)" + } + } + } + }, + "iZL-dA-jk9.segmentTitles[2]" : { + "comment" : "Class = \"UISegmentedControl\"; iZL-dA-jk9.segmentTitles[2] = \"5.1‑channel\"; ObjectID = \"iZL-dA-jk9\";", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -3139,8 +3169,8 @@ } } }, - "iZL-dA-jk9.segmentTitles[2]" : { - "comment" : "Class = \"UISegmentedControl\"; iZL-dA-jk9.segmentTitles[2] = \"7.1‑channel\"; ObjectID = \"iZL-dA-jk9\";", + "iZL-dA-jk9.segmentTitles[3]" : { + "comment" : "Class = \"UISegmentedControl\"; iZL-dA-jk9.segmentTitles[3] = \"7.1‑channel\"; ObjectID = \"iZL-dA-jk9\";", "extractionState" : "extracted_with_value", "localizations" : { "en" : { diff --git a/VoidLink/mul.lproj/iPhone.xcstrings b/VoidLink/mul.lproj/iPhone.xcstrings index 9fa0a3d3..7b564f15 100644 --- a/VoidLink/mul.lproj/iPhone.xcstrings +++ b/VoidLink/mul.lproj/iPhone.xcstrings @@ -2408,7 +2408,37 @@ } }, "hfR-K8-Ti4.segmentTitles[1]" : { - "comment" : "Class = \"UISegmentedControl\"; hfR-K8-Ti4.segmentTitles[1] = \"5.1-channel\"; ObjectID = \"hfR-K8-Ti4\";", + "comment" : "Class = \"UISegmentedControl\"; hfR-K8-Ti4.segmentTitles[1] = \"Stereo(SDL)\"; ObjectID = \"hfR-K8-Ti4\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Stereo(SDL)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "双声道(SDL)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "雙聲道(SDL)" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "雙聲道(SDL)" + } + } + } + }, + "hfR-K8-Ti4.segmentTitles[2]" : { + "comment" : "Class = \"UISegmentedControl\"; hfR-K8-Ti4.segmentTitles[2] = \"5.1-channel\"; ObjectID = \"hfR-K8-Ti4\";", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -2425,20 +2455,20 @@ }, "zh-Hant" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "5.1聲道" } }, "zh-HK" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "5.1聲道" } } } }, - "hfR-K8-Ti4.segmentTitles[2]" : { - "comment" : "Class = \"UISegmentedControl\"; hfR-K8-Ti4.segmentTitles[2] = \"7.1-channel\"; ObjectID = \"hfR-K8-Ti4\";", + "hfR-K8-Ti4.segmentTitles[3]" : { + "comment" : "Class = \"UISegmentedControl\"; hfR-K8-Ti4.segmentTitles[3] = \"7.1-channel\"; ObjectID = \"hfR-K8-Ti4\";", "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -2455,13 +2485,13 @@ }, "zh-Hant" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "7.1聲道" } }, "zh-HK" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "7.1聲道" } }