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" : {