diff --git a/VoidLink/Base.lproj/iPad.storyboard b/VoidLink/Base.lproj/iPad.storyboard index 81d8b8f0..0abcef6f 100644 --- a/VoidLink/Base.lproj/iPad.storyboard +++ b/VoidLink/Base.lproj/iPad.storyboard @@ -394,12 +394,13 @@ - + + @@ -3937,19 +3938,19 @@ - + - + - + - + diff --git a/VoidLink/Base.lproj/iPhone.storyboard b/VoidLink/Base.lproj/iPhone.storyboard index e3809593..dc6824b5 100644 --- a/VoidLink/Base.lproj/iPhone.storyboard +++ b/VoidLink/Base.lproj/iPhone.storyboard @@ -2,6 +2,7 @@ + @@ -14,7 +15,7 @@ - + @@ -2530,12 +2531,13 @@ - + + diff --git a/VoidLink/Database/DataManager.h b/VoidLink/Database/DataManager.h index b102bfa9..4fbef2e1 100644 --- a/VoidLink/Database/DataManager.h +++ b/VoidLink/Database/DataManager.h @@ -59,7 +59,8 @@ typedef NS_ENUM(NSInteger, ControllerMouseStick) { typedef NS_ENUM(NSInteger, FramePacingMode) { FramePacingModeOff, FramePacingModeLegacy, - FramePacingModeQueue + FramePacingModeQueue, + FramePacingModeInterpolation }; typedef NS_ENUM(NSInteger, SettingsMenuMode) { diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index b2209062..7d58ea11 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -9304,6 +9304,98 @@ } } }, + "Interpolation failed for an unknown reason." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知原因导致的插帧失败" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知原因導致插幀失敗" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知原因導致插幀失敗" + } + } + } + }, + "Interpolation overhead is too high. Falling back to normal mode." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "插帧性能开销过大,回退普通模式" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "插幀效能開銷過大,回退至一般模式" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "插幀效能開銷過大,回退至一般模式" + } + } + } + }, + "Interpolator error:" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "插帧器错误:" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "插幀器錯誤:" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "插幀器錯誤:" + } + } + } + }, + "Interpolator started." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "插帧器已启动。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "插幀器已啟動。" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "插幀器已啟動。" + } + } + } + }, "framePacingStackTip" : { "extractionState" : "manual", "localizations" : { @@ -19823,4 +19915,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/VoidLink/Stream/FrameInterpolator.swift b/VoidLink/Stream/FrameInterpolator.swift index f8f9b4c5..37762080 100644 --- a/VoidLink/Stream/FrameInterpolator.swift +++ b/VoidLink/Stream/FrameInterpolator.swift @@ -1,3 +1,11 @@ +// +// FrameInterpolator.swift +// VoidLink +// +// Created by True砖家 on 2026/8/6. +// Copyright © 2026 True砖家 @ Bilibili. All rights reserved. +// + import AVFoundation import CoreVideo import ObjectiveC.runtime @@ -19,12 +27,14 @@ final class FrameInterpolator: NSObject { private var previousFrame: Frame? private var pendingFrames: [PendingFrame] = [] private var isProcessing = false + private var resetRequested = false private var width = 0 private var height = 0 private var slowUntil = CFTimeInterval(0) private var completedProcessingCount = 0 private var consecutiveSlowFrames = 0 private var loggedEvents = Set() + private var overlayGeneration = 0 var isEnabled = false { didSet { @@ -45,14 +55,168 @@ final class FrameInterpolator: NSObject { return false #endif }() + + static var dimensionLimitsAreKnown: Bool = { + #if targetEnvironment(simulator) + return false + #else + if #available(iOS 26.0, tvOS 26.0, *) { + let limits = VTLowLatencyFrameInterpolationConfiguration + .runtimeDimensionLimits(spatialScaleFactor: 1) + guard + let maximumDimension = limits.maximumDimension, + let maximumPixelCount = limits.maximumPixelCount, + maximumDimension > 0, + maximumPixelCount > 0 + else {return false} + return true + } + else { + return false + } + #endif + }() + + static func interpolatableDimensionsBy(_ dimensions: CMVideoDimensions) -> CMVideoDimensions { + guard deviceSupportsInterpolation else { + return CMVideoDimensions(width: 0, height: 0) + } + +#if targetEnvironment(simulator) + return CMVideoDimensions(width: 0, height: 0) +#else + guard #available(iOS 26.0, tvOS 26.0, *) else { + return CMVideoDimensions(width: 0, height: 0) + } + + let limits = VTLowLatencyFrameInterpolationConfiguration + .runtimeDimensionLimits(spatialScaleFactor: 1) + guard + let maximumDimension = limits.maximumDimension, + let maximumPixelCount = limits.maximumPixelCount, + maximumDimension > 0, + maximumPixelCount > 0 + else { + return CMVideoDimensions(width: 0, height: 0) + } + + return constrainedDimensions( + dimensions, + maximumDimension: maximumDimension, + maximumPixelCount: maximumPixelCount + ) + #endif + } + + static func interpolatableDimensionsBy1080p(_ dimensions: CMVideoDimensions) -> CMVideoDimensions { + guard deviceSupportsInterpolation else { + return CMVideoDimensions(width: 0, height: 0) + } + + return constrainedDimensions( + dimensions, + maximumDimension: 1920, + maximumPixelCount: 1920 * 1080 + ) + } + + static func interpolatableDimensionsBy720p(_ dimensions: CMVideoDimensions) -> CMVideoDimensions { + guard deviceSupportsInterpolation else { + return CMVideoDimensions(width: 0, height: 0) + } + + return constrainedDimensions( + dimensions, + maximumDimension: 1280, + maximumPixelCount: 1280 * 720 + ) + } + + @objc(sourcePixelBufferAttributesForDimensions:) + static func sourcePixelBufferAttributes(for dimensions: CMVideoDimensions) -> NSDictionary? { + #if targetEnvironment(simulator) + return nil + #else + guard + #available(iOS 26.0, tvOS 26.0, *), + dimensions.width > 0, + dimensions.height > 0, + VTLowLatencyFrameInterpolationConfiguration.isSupported, + let configuration = VTLowLatencyFrameInterpolationConfiguration( + frameWidth: Int(dimensions.width), + frameHeight: Int(dimensions.height), + numberOfInterpolatedFrames: 1 + ) + else { + return nil + } + + return configuration.sourcePixelBufferAttributes as NSDictionary + #endif + } + + private static func constrainedDimensions( + _ dimensions: CMVideoDimensions, + maximumDimension: Int, + maximumPixelCount: Int + ) -> CMVideoDimensions { + guard + dimensions.width > 0, + dimensions.height > 0, + maximumDimension > 0, + maximumPixelCount > 0 + else { + return CMVideoDimensions(width: 0, height: 0) + } + + let width = Int(dimensions.width) + let height = Int(dimensions.height) + let (sourcePixelCount, pixelCountOverflow) = width.multipliedReportingOverflow(by: height) + let fullHDWidth = 1920 + let fullHDHeight = 1080 + let fullHDPixelCount = fullHDWidth * fullHDHeight + let isSixteenByNine = width * 9 == height * 16 + if !pixelCountOverflow, + isSixteenByNine, + sourcePixelCount > fullHDPixelCount, + maximumDimension >= fullHDWidth, + maximumPixelCount >= fullHDPixelCount { + return CMVideoDimensions(width: Int32(fullHDWidth), height: Int32(fullHDHeight)) + } + + if !pixelCountOverflow, + max(width, height) <= maximumDimension, + sourcePixelCount <= maximumPixelCount { + return dimensions + } + + let sourceWidth = Double(width) + let sourceHeight = Double(height) + let dimensionScale = Double(maximumDimension) / max(sourceWidth, sourceHeight) + let pixelCountScale = sqrt(Double(maximumPixelCount) / (sourceWidth * sourceHeight)) + let scale = min(dimensionScale, pixelCountScale) + + let scaledWidth = Int((sourceWidth * scale).rounded(.down)) & ~(31) + let scaledHeight = Int((sourceHeight * scale).rounded(.down)) & ~(31) + guard + scaledWidth > 0, + scaledHeight > 0, + scaledWidth <= Int(Int32.max), + scaledHeight <= Int(Int32.max) + else { + return CMVideoDimensions(width: 0, height: 0) + } + + return CMVideoDimensions(width: Int32(scaledWidth), height: Int32(scaledHeight)) + } var isAvailable: Bool { isEnabled && Self.deviceSupportsInterpolation } func reset() { - queue.async { [weak self] in - self?.resetLocked() + queue.async { + self.requestResetLocked() } } @@ -63,6 +227,11 @@ final class FrameInterpolator: NSObject { return } + guard !self.resetRequested else { + completion([frame]) + return + } + self.pendingFrames.append(PendingFrame(frame: frame, completion: completion)) self.drainPendingFrames() } @@ -181,14 +350,16 @@ final class FrameInterpolator: NSObject { isProcessing = true let startedAt = CACurrentMediaTime() - processor.process(parameters: parameters) { [weak self] _, error in - guard let self else { - completion([frame]) - return - } - + processor.process(parameters: parameters) { [self] _, error in self.queue.async { self.isProcessing = false + + if self.resetRequested { + self.resetLocked() + completion([frame]) + return + } + self.previousFrame = frame let elapsed = CACurrentMediaTime() - startedAt @@ -201,15 +372,24 @@ final class FrameInterpolator: NSObject { ) else { if let error { - self.logOnce("process-error-\((error as NSError).code)", "processing failed: \(error)") + self.logOnce("process-error", error: error) } else { - self.logOnce("sample-buffer-failed", "processing completed but the interpolated CMSampleBuffer could not be created") + self.logOnce( + "sample-buffer-failed", + "processing completed but the interpolated CMSampleBuffer could not be created", + overlayText: "Interpolation failed for an unknown reason.".localized + ) } completion([frame]) self.drainPendingFrames() return } + self.logOnce( + "first-interpolated-frame", + "produced the first interpolated frame", + overlayText: "Interpolator started.".localized + ) self.completedProcessingCount += 1 if sourceFrameInterval.isFinite, sourceFrameInterval > 0, elapsed >= sourceFrameInterval { self.consecutiveSlowFrames += 1 @@ -221,7 +401,8 @@ final class FrameInterpolator: NSObject { self.consecutiveSlowFrames = 0 self.logOnce( "sustained-overrun", - "processing repeatedly exceeded the source-frame interval; temporarily falling back to original frames" + "processing repeatedly exceeded the source-frame interval; temporarily falling back to original frames", + overlayText: "Interpolation overhead is too high. Falling back to normal mode.".localized ) } @@ -262,6 +443,40 @@ final class FrameInterpolator: NSObject { } let inputPixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer) + var extendedLeft = 0 + var extendedRight = 0 + var extendedTop = 0 + var extendedBottom = 0 + CVPixelBufferGetExtendedPixels( + pixelBuffer, + &extendedLeft, + &extendedRight, + &extendedTop, + &extendedBottom + ) + logOnce( + "input-layout-\(width)x\(height)", + "input pixel buffer layout for \(width)x\(height): " + + "extended left=\(extendedLeft), right=\(extendedRight), " + + "top=\(extendedTop), bottom=\(extendedBottom)" + ) + let runtimeLimits = VTLowLatencyFrameInterpolationConfiguration + .runtimeDimensionLimits(spatialScaleFactor: 1) + logOnce( + "runtime-limits-\(width)x\(height)", + "runtime limits for spatial scale 1: minimum=\(String(describing: runtimeLimits.minimumDimensions)), " + + "legacyMaximum=\(String(describing: runtimeLimits.maximumDimensions)), " + + "maximumDimension=\(String(describing: runtimeLimits.maximumDimension)), " + + "maximumPixelCount=\(String(describing: runtimeLimits.maximumPixelCount))" + ) + logOnce( + "source-attributes-\(width)x\(height)", + "source pixel buffer attributes for \(width)x\(height): \(configuration.sourcePixelBufferAttributes)" + ) + logOnce( + "destination-attributes-\(width)x\(height)", + "destination pixel buffer attributes for \(width)x\(height): \(configuration.destinationPixelBufferAttributes)" + ) if !configuration.supportedPixelFormats.contains(inputPixelFormat) { let supportedFormats = configuration.supportedPixelFormats .map(pixelFormatName) @@ -289,6 +504,7 @@ final class FrameInterpolator: NSObject { &pool ) guard status == kCVReturnSuccess, let pool else { + processor.endSession() logOnce("pool-error-\(status)", "failed to create destination pixel buffer pool: \(status)") return false } @@ -366,11 +582,51 @@ final class FrameInterpolator: NSObject { CMTimeAdd(first, CMTimeMultiplyByFloat64(CMTimeSubtract(second, first), multiplier: 0.5)) } - private func logOnce(_ event: String, _ message: String) { - guard loggedEvents.insert(event).inserted else { + private func logOnce(_ event: String, _ message: String, overlayText: String? = nil) { + guard loggedEvents.insert(event).inserted || event == "sustained-overrun" else { return } - NSLog("[FrameInterpolator] %@", message) + if let overlayText { + showTransientHUDText(overlayText) + NSLog("[FrameInterpolator] %@", message) + } + } + + private func logOnce(_ event: String, error: Error) { + let nsError = error as NSError + logOnce( + event, + "processing failed: \(error)", + overlayText: "\("Interpolator error:".localized) \(nsError.localizedDescription)" + ) + } + + private func showTransientHUDText(_ text: String) { + overlayGeneration &+= 1 + + let generation = overlayGeneration + + DispatchQueue.main.async { + guard let streamFrameVC = StreamFrameViewController.sharedInstance() else { + return + } + + streamFrameVC.updateTransientHUDText(text) + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self, weak streamFrameVC] in + guard let self, let streamFrameVC else { + return + } + + self.queue.async { + guard self.overlayGeneration == generation else { + return + } + DispatchQueue.main.async { + streamFrameVC.updateTransientHUDText(nil) + } + } + } + } } private func pixelFormatName(_ pixelFormat: OSType) -> String { @@ -384,12 +640,30 @@ final class FrameInterpolator: NSObject { return "\(fourCC) (\(pixelFormat))" } + private func requestResetLocked() { + pendingFrames.removeAll() + guard !isProcessing else { + resetRequested = true + return + } + + resetLocked() + } + private func resetLocked(keepPendingFrames: Bool = false) { + #if !targetEnvironment(simulator) + if #available(iOS 26.0, tvOS 26.0, *), + let processor = processor as? VTFrameProcessor { + processor.endSession() + } + #endif + processor = nil pixelBufferPool = nil formatDescription = nil previousFrame = nil isProcessing = false + resetRequested = false width = 0 height = 0 slowUntil = 0 @@ -485,6 +759,5 @@ extension VTLowLatencyFrameInterpolationConfiguration { let supported = limits.supports(width: 1920, height: 1080) print("1920x1080 supported:", supported) } - } #endif diff --git a/VoidLink/Stream/VideoDecoderRenderer.m b/VoidLink/Stream/VideoDecoderRenderer.m index 65331876..c812a425 100644 --- a/VoidLink/Stream/VideoDecoderRenderer.m +++ b/VoidLink/Stream/VideoDecoderRenderer.m @@ -174,7 +174,7 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size, _asyncFrameDequeue = tempSettings.asyncFrameDequeue; NSLog(@"_asyncFrameDequeue %d", _asyncFrameDequeue); _enableTimebase = false; - _queueSize = tempSettings.frameQueueSize.intValue; + _queueSize = kEnableFrameInterpolation ? 4 : tempSettings.frameQueueSize.intValue; _needRequeuing = _queueSize>0; _frameQueue = [FrameQueue sharedInstance]; @@ -205,9 +205,10 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size, [[ImGuiPlots sharedInstance] clearData]; DataManager* dataMan = [[DataManager alloc] init]; - if ([[dataMan getSettings].renderingBackend integerValue] == RENDER_AVSB) { + TemporarySettings* settings = [dataMan getSettings]; + if ([settings.renderingBackend integerValue] == RENDER_AVSB) { _renderingBackend = RENDER_AVSB; - + if (kEnableFrameInterpolation) { _frameInterpolator = [[FrameInterpolator alloc] init]; _frameInterpolator.isEnabled = YES; @@ -297,6 +298,55 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size, } mutableCopy]; #endif +#if !TARGET_OS_SIMULATOR + if (interpolationRequested) { + CMVideoDimensions dimensions = CMVideoFormatDescriptionGetDimensions(_formatDesc); + NSDictionary *interpolationSourceAttributes = + [FrameInterpolator sourcePixelBufferAttributesForDimensions:dimensions]; + CFDictionaryRef resolvedAttributes = NULL; + CVReturn resolveStatus = kCVReturnInvalidArgument; + + if (interpolationSourceAttributes != nil) { + NSArray *attributeRequirements = @[ + destinationPixelBufferAttributes, + interpolationSourceAttributes + ]; + resolveStatus = CVPixelBufferCreateResolvedAttributesDictionary( + kCFAllocatorDefault, + (__bridge CFArrayRef)attributeRequirements, + &resolvedAttributes); + } + + if (resolveStatus == kCVReturnSuccess && resolvedAttributes != NULL) { + destinationPixelBufferAttributes = + [(__bridge NSDictionary *)resolvedAttributes mutableCopy]; + CFRelease(resolvedAttributes); + } else { + if (resolvedAttributes != NULL) { + CFRelease(resolvedAttributes); + } + Log(LOG_W, + @"Unable to resolve frame-interpolation source attributes for %dx%d (status %d); using native decode output", + dimensions.width, + dimensions.height, + resolveStatus); + [self->_frameInterpolator reset]; + self->_frameInterpolator = nil; + interpolationRequested = NO; + destinationPixelBufferAttributes = [@{ + (id)kCVPixelBufferPixelFormatTypeKey : nativePixelFormat + } mutableCopy]; + + NSInteger targetFrameRate = MIN(self->_frameRate, self->_maxRefreshRate); + if (@available(iOS 15.0, tvOS 15.0, *)) { + self->_displayLink.preferredFrameRateRange = CAFrameRateRangeMake(targetFrameRate, targetFrameRate, targetFrameRate); + } else { + self->_displayLink.preferredFramesPerSecond = targetFrameRate; + } + } + } +#endif + if (@available(iOS 17.0, tvOS 17.0, *)) { destinationPixelBufferAttributes[(id)kVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoder] = @YES; destinationPixelBufferAttributes[(id)kVTDecompressionPropertyKey_GeneratePerFrameHDRDisplayMetadata] = @YES; @@ -313,7 +363,13 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size, Log(LOG_W, @"Falling back from 420v frame-interpolation decode output to native pixel format %@", nativePixelFormat); [self->_frameInterpolator reset]; self->_frameInterpolator = nil; - destinationPixelBufferAttributes[(id)kCVPixelBufferPixelFormatTypeKey] = nativePixelFormat; + destinationPixelBufferAttributes = [@{ + (id)kCVPixelBufferPixelFormatTypeKey : nativePixelFormat + } mutableCopy]; + if (@available(iOS 17.0, tvOS 17.0, *)) { + destinationPixelBufferAttributes[(id)kVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoder] = @YES; + destinationPixelBufferAttributes[(id)kVTDecompressionPropertyKey_GeneratePerFrameHDRDisplayMetadata] = @YES; + } [self setupDecompressionSessionWithAttributes:destinationPixelBufferAttributes]; NSInteger targetFrameRate = MIN(self->_frameRate, self->_maxRefreshRate); diff --git a/VoidLink/Utility/GenericUtils.swift b/VoidLink/Utility/GenericUtils.swift index 26f609d9..3160f760 100644 --- a/VoidLink/Utility/GenericUtils.swift +++ b/VoidLink/Utility/GenericUtils.swift @@ -533,11 +533,14 @@ import UIKit } @objc public static func handleLegacyFramePacingTip(in vc: UIViewController?, with selector: UISegmentedControl, passAlert: Bool = false, uiAction: (() -> Void)? = nil) { - if passAlert || selector.selectedSegmentIndex == FramePacingMode.queue.rawValue { + if passAlert + || selector.selectedSegmentIndex == FramePacingMode.queue.rawValue + || selector.selectedSegmentIndex == FramePacingMode.interpolation.rawValue{ uiAction?() return } - if selector.previousSelectedSegmentIndex != FramePacingMode.queue.rawValue { + if selector.previousSelectedSegmentIndex != FramePacingMode.queue.rawValue + && selector.previousSelectedSegmentIndex != FramePacingMode.interpolation.rawValue { uiAction?() return } diff --git a/VoidLink/ViewControllers/MainFrameViewController.h b/VoidLink/ViewControllers/MainFrameViewController.h index f8c29708..56357a34 100644 --- a/VoidLink/ViewControllers/MainFrameViewController.h +++ b/VoidLink/ViewControllers/MainFrameViewController.h @@ -10,6 +10,7 @@ // #import +#import #import "DiscoveryManager.h" #import "PairManager.h" #import "StreamConfiguration.h" @@ -49,7 +50,7 @@ - (NSInteger)requestForBitrate:(NSInteger)bitrateKbps; #endif -- (void)fillResolutionTable:(CGSize*)resolutionTable externalDisplayMode:(NSInteger)externalDisplayMode; +- (void)fillResolutionTable:(CMVideoDimensions *)resolutionTable externalDisplayMode:(NSInteger)externalDisplayMode; - (void)setNeedsUpdateAllowedOrientation; @end diff --git a/VoidLink/ViewControllers/MainFrameViewController.m b/VoidLink/ViewControllers/MainFrameViewController.m index 40988cc3..412a91c3 100644 --- a/VoidLink/ViewControllers/MainFrameViewController.m +++ b/VoidLink/ViewControllers/MainFrameViewController.m @@ -837,7 +837,7 @@ static NSMutableSet* hostList; - (void) reloadStreamConfig { DataManager* dataMan = [[DataManager alloc] init]; TemporarySettings* streamSettings = [dataMan getSettings]; - + [VideoDecoderRenderer setFrameInterpolationEnabled: streamSettings.framePacingMode.intValue == FramePacingModeInterpolation]; _streamConfig.frameRate = [streamSettings.framerate intValue]; if (@available(iOS 10.3, *)) { UIWindow *window = UIApplication.sharedApplication.windows.firstObject; @@ -854,6 +854,8 @@ static NSMutableSet* hostList; _streamConfig.height = [streamSettings.height intValue]; _streamConfig.width = [streamSettings.width intValue]; + + NSLog(@"saveSettings.width %d, %d", _streamConfig.width, _streamConfig.height); #if TARGET_OS_TV // Don't allow streaming 4K on the Apple TV HD struct utsname systemInfo; @@ -2099,7 +2101,7 @@ static NSMutableSet* hostList; // }); //} --(void) fillResolutionTable:(CGSize*)resolutionTable externalDisplayMode:(NSInteger)externalDisplayMode{ +-(void) fillResolutionTable:(CMVideoDimensions *)resolutionTable externalDisplayMode:(NSInteger)externalDisplayMode{ UIWindow *window = self.view.window; NSLog(@" window %@", window); @@ -2117,35 +2119,46 @@ static NSMutableSet* hostList; bool needSwapWidthAndHeight = appWindowWidth < appWindowHeight; - resolutionTable[0] = CGSizeMake(1280, 720); - resolutionTable[1] = CGSizeMake(1920, 1080); - resolutionTable[2] = CGSizeMake(3840, 2160); + resolutionTable[0] = (CMVideoDimensions){ .width = 1280, .height = 720 }; + resolutionTable[1] = (CMVideoDimensions){ .width = 1920, .height = 1080 }; + resolutionTable[2] = (CMVideoDimensions){ .width = 3840, .height = 2160 }; for(uint8_t i=0;i<6;i++){ - CGFloat longSideLen = resolutionTable[i].height > resolutionTable[i].width ? resolutionTable[i].height : resolutionTable[i].width; - CGFloat shortSideLen = resolutionTable[i].height < resolutionTable[i].width ? resolutionTable[i].height : resolutionTable[i].width; - if(needSwapWidthAndHeight) resolutionTable[i] = CGSizeMake(shortSideLen, longSideLen); - else resolutionTable[i] = CGSizeMake(longSideLen, shortSideLen); + int32_t longSideLen = MAX(resolutionTable[i].width, resolutionTable[i].height); + int32_t shortSideLen = MIN(resolutionTable[i].width, resolutionTable[i].height); + if(needSwapWidthAndHeight) resolutionTable[i] = (CMVideoDimensions){ .width = shortSideLen, .height = longSideLen }; + else resolutionTable[i] = (CMVideoDimensions){ .width = longSideLen, .height = shortSideLen }; } // add app window resolution and not swap width and height - resolutionTable[3] = CGSizeMake(safeAreaWidth, appWindowHeight); - resolutionTable[4] = CGSizeMake(appWindowWidth, appWindowHeight); + resolutionTable[3] = (CMVideoDimensions){ .width = (int32_t)safeAreaWidth, .height = (int32_t)appWindowHeight }; + resolutionTable[4] = (CMVideoDimensions){ .width = (int32_t)appWindowWidth, .height = (int32_t)appWindowHeight }; } -(void) updateResolutionAccordingly { DataManager* dataMan = [[DataManager alloc] init]; Settings *currentSettings = [dataMan retrieveSettings]; - CGSize tempResolutionTable[6] = {0}; - tempResolutionTable[5] = CGSizeMake(currentSettings.width.intValue, currentSettings.height.intValue); + CMVideoDimensions tempResolutionTable[6] = {0}; + tempResolutionTable[5] = (CMVideoDimensions){ + .width = (int32_t)currentSettings.width.intValue, + .height = (int32_t)currentSettings.height.intValue, + }; [self fillResolutionTable:tempResolutionTable externalDisplayMode:currentSettings.externalDisplayMode.intValue]; int selectedIndex = currentSettings.resolutionSelected.intValue; if (selectedIndex >= 0 && selectedIndex < 6) { - CGSize selectedSize = tempResolutionTable[selectedIndex]; - currentSettings.width = @(selectedSize.width); - currentSettings.height = @(selectedSize.height); + CMVideoDimensions originalDimensions = tempResolutionTable[selectedIndex]; + + CMVideoDimensions targetDimensions; + if(currentSettings.framePacingMode.intValue == FramePacingModeInterpolation) { + targetDimensions = [FrameInterpolator interpolatableDimensionsBy:originalDimensions]; + if(!(targetDimensions.width>0 && targetDimensions.height>0)) targetDimensions = [FrameInterpolator interpolatableDimensionsBy720p:originalDimensions]; + } + else targetDimensions = originalDimensions; + + currentSettings.width = @(targetDimensions.width); + currentSettings.height = @(targetDimensions.height); NSLog(@"Updated resolution to: %@ x %@", currentSettings.width, currentSettings.height); } diff --git a/VoidLink/ViewControllers/SettingsViewController.m b/VoidLink/ViewControllers/SettingsViewController.m index 560424eb..f40f9f24 100644 --- a/VoidLink/ViewControllers/SettingsViewController.m +++ b/VoidLink/ViewControllers/SettingsViewController.m @@ -206,7 +206,7 @@ static const int bitrateTable[] = { const int RESOLUTION_TABLE_SIZE = 6; const int RESOLUTION_TABLE_CUSTOM_INDEX = RESOLUTION_TABLE_SIZE - 1; -CGSize resolutionTable[RESOLUTION_TABLE_SIZE]; +CMVideoDimensions resolutionTable[RESOLUTION_TABLE_SIZE]; -(uint16_t)controllerTypeToSegmentIndex:(uint16_t)type{ uint16_t index; @@ -882,6 +882,7 @@ BOOL isCustomResolution(int resolutionSelected) { self.framePacingStack.hasInfoTag = YES; [self addSetting:self.framePacingStack ofId:@"framePacingStack" to:videoSection]; + if(!FrameInterpolator.deviceSupportsInterpolation) [self.framerateSelector setEnabled:false forSegmentAtIndex:FramePacingModeInterpolation]; self.frameQueueSizeStack.hasDynamicLabel = YES; [self addSetting:self.frameQueueSizeStack ofId:@"frameQueueSizeStack" to:videoSection]; @@ -2129,7 +2130,10 @@ BOOL isCustomResolution(int resolutionSelected) { self.resolutionSelector.selectedSegmentIndex = 3; [self.resolutionSelector setNeedsLayout]; - resolutionTable[5] = CGSizeMake([self->tempSettings.width integerValue], [self->tempSettings.height integerValue]); // custom initial value + resolutionTable[5] = (CMVideoDimensions){ + .width = (int32_t)[self->tempSettings.width integerValue], + .height = (int32_t)[self->tempSettings.height integerValue], + }; // custom initial value [self updateResolutionTable]; @@ -2689,6 +2693,7 @@ BOOL isCustomResolution(int resolutionSelected) { [self setHidden:(sender.selectedSegmentIndex == FramePacingModeOff || sender.selectedSegmentIndex == FramePacingModeLegacy) forStack:self.frameQueueSizeStack]; // [self setHidden:(sender.selectedSegmentIndex != FramePacingModeQueue) forStack:self.frameTimebaseStack]; [self setHidden:(sender.selectedSegmentIndex != FramePacingModeQueue + || sender.selectedSegmentIndex != FramePacingModeInterpolation || self.renderingBackendSelector.selectedSegmentIndex != 0) forStack:self.asyncFrameDequeueStack]; if(sender.selectedSegmentIndex == FramePacingModeOff || sender.selectedSegmentIndex == FramePacingModeLegacy){ @@ -2698,6 +2703,16 @@ BOOL isCustomResolution(int resolutionSelected) { [self.enableGraphsSwitch setEnabled:sender.selectedSegmentIndex == FramePacingModeQueue]; [self.graphOpacityStepper setEnabled:self.enableGraphsSwitch.isOn]; [self setHidden:(sender.selectedSegmentIndex == FramePacingModeOff || sender.selectedSegmentIndex == FramePacingModeLegacy) forStack:self.performanceGraphStack]; + + bool enableInterpolation = sender.selectedSegmentIndex == FramePacingModeInterpolation; + [self widget:self.frameQueueSizeSlider setEnabled:!enableInterpolation]; + [self updateResolutionDisplayLabel]; + if(enableInterpolation){ + if(self.framerateSelector.selectedSegmentIndex == 2) self.framerateSelector.selectedSegmentIndex = 1; + if(!FrameInterpolator.dimensionLimitsAreKnown){ + NSLog(@"FrameInterpolator dimensionLimitsUnknown"); + } + } }]; /* @@ -3648,6 +3663,9 @@ BOOL isCustomResolution(int resolutionSelected) { [[NSUserDefaults standardUserDefaults] setBool:YES forKey:key]; [[NSUserDefaults standardUserDefaults] synchronize]; + if(self.framerateSelector.selectedSegmentIndex == 2 + && self.framePacingModeSelector.selectedSegmentIndex == FramePacingModeInterpolation) self.framerateSelector.selectedSegmentIndex = self.framerateSelector.previousSelectedSegmentIndex; + // NSInteger fps = [self getChosenFrameRate]; // [self touchMoveEventIntervalSliderMoved:self.touchMoveEventIntervalSlider]; [self updateBitrate]; @@ -3655,8 +3673,9 @@ BOOL isCustomResolution(int resolutionSelected) { - (void) updateBitrate { NSInteger fps = [self getChosenFrameRate]; - NSInteger width = [self getChosenStreamWidth]; - NSInteger height = [self getChosenStreamHeight]; + CMVideoDimensions dimensions = [self getChosenStreamDimensions]; + NSInteger width = dimensions.width; + NSInteger height = dimensions.height; NSInteger defaultBitrate; // This logic is shamelessly stolen from Moonlight Qt: @@ -3792,7 +3811,10 @@ BOOL isCustomResolution(int resolutionSelected) { width = MAX(width, 256); height = MAX(height, 256); - resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX] = CGSizeMake(width, height); + resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX] = (CMVideoDimensions){ + .width = (int32_t)width, + .height = (int32_t)height, + }; [self updateBitrate]; [self updateResolutionDisplayLabel]; @@ -3814,8 +3836,9 @@ BOOL isCustomResolution(int resolutionSelected) { } - (void) updateResolutionDisplayLabel { - NSInteger width = [self getChosenStreamWidth]; - NSInteger height = [self getChosenStreamHeight]; + CMVideoDimensions dimensions = [self getChosenStreamDimensions]; + NSInteger width = dimensions.width; + NSInteger height = dimensions.height; [self findDynamicLabelFromStack:_resolutionSelectorStack].text = [NSString stringWithFormat:@"%ld × %ld", (long)width, (long)height]; } @@ -3888,22 +3911,26 @@ BOOL isCustomResolution(int resolutionSelected) { } } -- (NSInteger) getChosenStreamHeight { +- (CMVideoDimensions)getChosenStreamDimensions { // because the 4k resolution can be removed + CMVideoDimensions originalDimensions; if (self.customResolutionSwitch.isOn) { - return resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX].height; + originalDimensions = resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX]; + } else { + originalDimensions = resolutionTable[self.resolutionSelector.selectedSegmentIndex]; } - - return resolutionTable[self.resolutionSelector.selectedSegmentIndex].height; -} - -- (NSInteger) getChosenStreamWidth { - // because the 4k resolution can be removed - if (self.customResolutionSwitch.isOn) { - return resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX].width; + + CMVideoDimensions targetDimensions = self.framePacingModeSelector.selectedSegmentIndex == FramePacingModeInterpolation + ? [FrameInterpolator interpolatableDimensionsBy:originalDimensions] + : originalDimensions; + + if(targetDimensions.width == 0 || targetDimensions.height == 0) { + targetDimensions = [FrameInterpolator interpolatableDimensionsBy720p:originalDimensions]; } + + NSLog(@"dimensions: %d, %d", targetDimensions.width, targetDimensions.height); - return resolutionTable[self.resolutionSelector.selectedSegmentIndex].width; + return targetDimensions; } - (UIStackView *)findFlatStackViewFrom:(UIView *)view { @@ -4250,10 +4277,17 @@ BOOL isCustomResolution(int resolutionSelected) { CGFloat settingsMenuOffset = _rememberFoldStateSwitch.isOn ? _scrollView.contentOffset.y : 0; - NSInteger height = self.mainFrameViewController.isStreaming ? currentSettings.height.intValue : [self getChosenStreamHeight]; - NSInteger width = self.mainFrameViewController.isStreaming ? currentSettings.width.intValue : [self getChosenStreamWidth]; + CMVideoDimensions dimensions; + if (self.mainFrameViewController.isStreaming) { + dimensions.width = (int32_t)currentSettings.width.intValue; + dimensions.height = (int32_t)currentSettings.height.intValue; + } else { + dimensions = [self getChosenStreamDimensions]; + } + NSInteger height = dimensions.height; + NSInteger width = dimensions.width; - // NSLog(@"saveSettings aspectRatio %d, %ld, %ld",self.mainFrameViewController.isStreaming, width, height); + NSLog(@"saveSettings %d, %ld, %ld",self.mainFrameViewController.isStreaming, width, height); NSInteger framerate = [self getChosenFrameRate]; diff --git a/VoidLink/ViewControllers/StreamFrameViewController.h b/VoidLink/ViewControllers/StreamFrameViewController.h index ab9bb4be..509f25b2 100644 --- a/VoidLink/ViewControllers/StreamFrameViewController.h +++ b/VoidLink/ViewControllers/StreamFrameViewController.h @@ -67,5 +67,7 @@ - (void)returnToMainFrame; - (void)bringUpToolboxMenu; - (void)bringUpToolboxMenuWithoutWidgetLayoutTool; +- (void)updateOverlayText:(NSString*)text; +- (void)updateTransientHUDText:(NSString*)text; @end diff --git a/VoidLink/ViewControllers/StreamFrameViewController.m b/VoidLink/ViewControllers/StreamFrameViewController.m index af7043d8..f974420d 100644 --- a/VoidLink/ViewControllers/StreamFrameViewController.m +++ b/VoidLink/ViewControllers/StreamFrameViewController.m @@ -89,6 +89,7 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil NSTimer *_inactivityTimer; NSTimer *_statsUpdateTimer; PaddedLabel *_overlayView; + PaddedLabel *_transientHUDView; UITapGestureRecognizer *_menuTapGestureRecognizer; UITapGestureRecognizer *_menuDoubleTapGestureRecognizer; UITapGestureRecognizer *_playPauseTapGestureRecognizer; @@ -1353,6 +1354,11 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil // We set our bounds to the maximum width in order to work around a bug where // sizeToFit interacts badly with the UITextView's line breaks, causing the // width to get smaller and smaller each time as more line breaks are inserted. + //sdfdsf; + if (_overlayView.superview == nil) { + [self.view addSubview:_overlayView]; + } + [_overlayView setBounds:CGRectMake(self.view.frame.origin.x, _overlayView.frame.origin.y, self.view.frame.size.width, @@ -1367,6 +1373,47 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil } } +- (void)updateTransientHUDText:(NSString*)text { + if (![NSThread isMainThread]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self updateTransientHUDText:text]; + }); + return; + } + + if (text == nil) { + _transientHUDView.hidden = YES; + return; + } + + if (_transientHUDView == nil) { + _transientHUDView = [[PaddedLabel alloc] initWithFrame:CGRectZero]; + _transientHUDView.translatesAutoresizingMaskIntoConstraints = NO; + _transientHUDView.textInsets = UIEdgeInsetsMake(10, 14, 10, 14); + _transientHUDView.numberOfLines = 0; + _transientHUDView.textAlignment = NSTextAlignmentLeft; + _transientHUDView.textColor = [UIColor.whiteColor colorWithAlphaComponent:0.7]; + _transientHUDView.backgroundColor = [UIColor.blackColor colorWithAlphaComponent:0.5]; + _transientHUDView.font = [UIFont systemFontOfSize:PublicUtils.isIPhone ? 18 : 22 + weight:UIFontWeightSemibold]; + _transientHUDView.layer.cornerRadius = 8; + _transientHUDView.layer.masksToBounds = YES; + _transientHUDView.userInteractionEnabled = NO; + + [self.view addSubview:_transientHUDView]; + UILayoutGuide *safeArea = self.view.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [_transientHUDView.leadingAnchor constraintEqualToAnchor:safeArea.leadingAnchor constant:35], + [_transientHUDView.topAnchor constraintEqualToAnchor:safeArea.topAnchor constant:12], + [_transientHUDView.widthAnchor constraintLessThanOrEqualToAnchor:safeArea.widthAnchor multiplier:0.8] + ]]; + } + + _transientHUDView.text = text; + _transientHUDView.hidden = NO; + [self.view bringSubviewToFront:_transientHUDView]; +} + - (void)returnToMainFrame { if (@available(iOS 13.0, *)) { // [ControllerNavigator setUINavigationDelegate:[_mainFrameViewcontroller isInAppView] ? _mainFrameViewcontroller : _mainFrameViewcontroller.hostCollectionVC]; diff --git a/VoidLink/mul.lproj/iPad.xcstrings b/VoidLink/mul.lproj/iPad.xcstrings index ef7bd065..c2c964ea 100644 --- a/VoidLink/mul.lproj/iPad.xcstrings +++ b/VoidLink/mul.lproj/iPad.xcstrings @@ -163,6 +163,36 @@ } } }, + "2Dp-Qg-chi.segmentTitles[3]" : { + "comment" : "Class = \"UISegmentedControl\"; 2Dp-Qg-chi.segmentTitles[3] = \"×2 Interpolation\"; ObjectID = \"2Dp-Qg-chi\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "×2 Interpolation" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "×2 插帧" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "x2 影格插補" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "x2 影格插補" + } + } + } + }, "2TR-Kd-ro4.normalTitle" : { "comment" : "Class = \"UIButton\"; normalTitle = \"Button\"; ObjectID = \"2TR-Kd-ro4\";", "extractionState" : "extracted_with_value", @@ -4598,8 +4628,8 @@ }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", - "value" : "帧步进控制" + "state" : "needs_review", + "value" : "帧步调" } }, "zh-Hant" : { diff --git a/VoidLink/mul.lproj/iPhone.xcstrings b/VoidLink/mul.lproj/iPhone.xcstrings index a75aa4d2..3c649beb 100644 --- a/VoidLink/mul.lproj/iPhone.xcstrings +++ b/VoidLink/mul.lproj/iPhone.xcstrings @@ -1891,6 +1891,36 @@ } } }, + "CC6-pu-Uoh.segmentTitles[3]" : { + "comment" : "Class = \"UISegmentedControl\"; CC6-pu-Uoh.segmentTitles[3] = \"×2 Interpolation\"; ObjectID = \"CC6-pu-Uoh\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "×2 Interpolation" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "×2 插帧" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "x2 影格插補" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "x2 影格插補" + } + } + } + }, "CEd-Wx-Gml.text" : { "comment" : "Class = \"UILabel\"; text = \"Duck Other Apps\"; ObjectID = \"CEd-Wx-Gml\";", "extractionState" : "extracted_with_value", @@ -2060,7 +2090,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "needs_review", - "value" : "帧步进控制" + "value" : "帧步调" } }, "zh-Hant" : {