diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index 19880a6e..edf4111d 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -5090,6 +5090,29 @@ } } }, + "Apply" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "套用" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "套用" + } + } + } + }, "Arrow direction pad" : { "extractionState" : "manual", "localizations" : { @@ -8208,6 +8231,29 @@ } } }, + "Enable Metal Renderer?" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用 Metal 渲染?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用 Metal 渲染?" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用 Metal 渲染?" + } + } + } + }, "enableOswForNativeTouchSwitchTip" : { "extractionState" : "manual", "localizations" : { @@ -10923,6 +10969,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" : "插幀器已啟動。" + } + } + } + }, "Interval of Multi-Touch Move Events: %d μs" : { "extractionState" : "manual", "localizations" : { @@ -12233,25 +12371,25 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "\nHold up! \n\n- Standard render has better performance on most devices now.\n\n- Changing the rendering mode requires restarting the application to ensure a clean start.\n\n- Metal rendering is an experimental feature that may cause performance degradation on some devices.\n\n- Metal render currently is unable to handle HDR content correctly on most devices.\n\n- Metal rendering mode does not support PiP.\n\n- Tapping “Quit Now” means I acknowledge the above information." + "value" : "\nHold up! \n\n- Standard render has better performance on most devices now.\n\n- The change takes effect the next time you start a stream. No app restart is needed.\n\n- Metal rendering is an experimental feature that may cause performance degradation on some devices.\n\n- Metal render currently is unable to handle HDR content correctly on most devices.\n\n- Metal rendering mode does not support PiP.\n\n- Tapping “Apply” means I acknowledge the above information." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "\n等等!\n\n- 标准渲染模式目前在大多数设备上具有更好的性能。\n\n- 切换渲染模式需要重启应用程序,以确保一次干净的启动。\n\n- Metal 渲染是一项实验性功能,在某些设备上可能会导致性能下降。\n\n- Metal 渲染模式下不支持画中画。\n\n- 目前 Metal 渲染器在大多数设备上无法正确处理 HDR 内容。\n\n点击“立即退出”表示我已知晓并认可以上信息。" + "value" : "\n等等!\n\n- 标准渲染模式目前在大多数设备上具有更好的性能。\n\n- 渲染模式的更改将在下次开始串流时生效,无需重启应用程序。\n\n- Metal 渲染是一项实验性功能,在某些设备上可能会导致性能下降。\n\n- Metal 渲染模式下不支持画中画。\n\n- 目前 Metal 渲染器在大多数设备上无法正确处理 HDR 内容。\n\n点击“应用”表示我已知晓并认可以上信息。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "\n等等!\n\n- 目前在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式需要重新启动应用程式,以确保干净的启动流程。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。\n\n点选「立即结束」表示我已了解并同意上述说明。" + "value" : "\n等等!\n\n- 目前在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式将在下次开始串流时生效,无需重新启动应用程式。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。\n\n点选「套用」表示我已了解并同意上述说明。" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "\n等等!\n\n- 目前在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式需要重新启动应用程式,以确保干净的启动流程。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。\n\n点选「立即结束」表示我已了解并同意上述说明。" + "value" : "\n等等!\n\n- 目前在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式将在下次开始串流时生效,无需重新启动应用程式。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。\n\n点选「套用」表示我已了解并同意上述说明。" } } } @@ -15659,25 +15797,25 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "- Standard render has better performance on most devices now.\n\n- Changing the rendering mode requires restarting the application to ensure a clean start.\n\n- Metal rendering is an experimental feature that may cause performance degradation on some devices.\n\n- Metal render currently is unable to handle HDR content correctly on most devices.\n\n- Metal rendering mode does not support PiP." + "value" : "- Standard render has better performance on most devices now.\n\n- Changing the rendering mode takes effect the next time you start a stream.\n\n- Metal rendering is an experimental feature that may cause performance degradation on some devices.\n\n- Metal render currently is unable to handle HDR content correctly on most devices.\n\n- Metal rendering mode does not support PiP." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "- 目前, 标准渲染模式在大多数设备上具有更好的性能。\n\n- 切换渲染模式需要重启应用程序,以确保一次干净的启动。\n\n- Metal 渲染是一项实验性功能,在某些设备上可能会导致性能下降。\n\n- Metal 渲染模式下不支持画中画。\n\n- 目前 Metal 渲染器在大多数设备上无法正确处理 HDR 内容。\n" + "value" : "- 目前, 标准渲染模式在大多数设备上具有更好的性能。\n\n- 渲染模式的更改将在下次开始串流时生效。\n\n- Metal 渲染是一项实验性功能,在某些设备上可能会导致性能下降。\n\n- Metal 渲染模式下不支持画中画。\n\n- 目前 Metal 渲染器在大多数设备上无法正确处理 HDR 内容。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "- 目前,在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式需要重新启动应用程式,以确保干净的启动流程。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。" + "value" : "- 目前,在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式将在下次开始串流时生效。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "- 目前,在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式需要重新启动应用程式,以确保干净的启动流程。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。" + "value" : "- 目前,在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式将在下次开始串流时生效。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。" } } } @@ -17613,25 +17751,25 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "\n- Standard render has better performance on most devices now.\n\n- Changing the rendering mode requires restarting the application to ensure a clean start." + "value" : "\n- Standard render has better performance on most devices now.\n\n- The change takes effect the next time you start a stream." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "\n- 标准渲染模式目前在大多数设备上具有更好的性能。\n\n- 更改渲染模式需要重启应用以确保干净启动。您是否要立即退出应用?" + "value" : "\n- 目前, 标准渲染模式在大多数设备上具有更好的性能。\n\n- 渲染模式的更改将在下次开始串流时生效。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "\n- 目前在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 更改渲染模式需要重新啟動應用程式以確保乾淨啟動。您是否要立即退出應用程式?" + "value" : "\n- 目前,在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式将在下次开始串流时生效。" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "\n- 目前在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 更改渲染模式需要重新啟動應用程式以確保乾淨啟動。您是否要立即退出應用程式?" + "value" : "\n- 目前,在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式将在下次开始串流时生效。" } } } diff --git a/VoidLink/Metal/MetalVideoRenderer.m b/VoidLink/Metal/MetalVideoRenderer.m index 715499e4..55d72125 100644 --- a/VoidLink/Metal/MetalVideoRenderer.m +++ b/VoidLink/Metal/MetalVideoRenderer.m @@ -106,7 +106,17 @@ static const NSUInteger MaxFramesInFlight = 3; // best to tone-map Windows content static BOOL useEDR = YES; -CFStringRef __currentColorSpace; +// Name of the colorspace most recently applied to the layer, for the stats overlay. +// Class-level because the stats path only has access to the class; guarded by a lock +// since it is written on the render thread and read from other threads, and multiple +// renderer instances can briefly overlap during a session switch. +static NSString *__currentColorSpace; + +static void setCurrentColorSpaceName(NSString *name) { + @synchronized ([MetalVideoRenderer class]) { + __currentColorSpace = name; + } +} @implementation MetalVideoRenderer { dispatch_queue_t _sq; @@ -225,11 +235,9 @@ CFStringRef __currentColorSpace; if (_renderPassDescriptor) { _renderPassDescriptor = nil; } - - if (__currentColorSpace) { - CFRelease(__currentColorSpace); - __currentColorSpace = NULL; - } + + // Do NOT clear __currentColorSpace here: a new renderer instance may already be + // live (session switch) and have set its own value. } #if !TARGET_OS_TV @@ -405,9 +413,11 @@ CFStringRef __currentColorSpace; paramBuffer.cscParams = (fullRange ? k_CscParams_Bt709Full : k_CscParams_Bt709Lim); break; case COLORSPACE_REC_2020: { + // Frames without a format description (e.g. produced for the AVSB + // backend) must not crash us here CFDictionaryRef ext = [frame getFormatDescExtensions]; - CFStringRef frame_trc = CFDictionaryGetValue(ext, kCVImageBufferTransferFunctionKey); - if (CFEqual(frame_trc, kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ)) { + CFStringRef frame_trc = ext ? CFDictionaryGetValue(ext, kCVImageBufferTransferFunctionKey) : NULL; + if (frame_trc && CFEqual(frame_trc, kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ)) { isHDR = YES; newColorSpace = CGColorSpaceCreateWithName(_nonFullHdrColorSpace); newPixelFormat = _nonFullHdrPixelFormat; @@ -478,11 +488,7 @@ CFStringRef __currentColorSpace; }); } #endif - if (__currentColorSpace) { - CFRelease(__currentColorSpace); - __currentColorSpace = NULL; - } - __currentColorSpace = CGColorSpaceCopyName(newColorSpace); + setCurrentColorSpaceName(newColorSpace ? CFBridgingRelease(CGColorSpaceCopyName(newColorSpace)) : nil); CGColorSpaceRelease(newColorSpace); } @@ -904,10 +910,9 @@ CFStringRef __currentColorSpace; } + (NSString *)currentColorSpace { - if (__currentColorSpace) { - return (__bridge NSString *)__currentColorSpace; + @synchronized ([MetalVideoRenderer class]) { + return __currentColorSpace; } - return nil; } @end diff --git a/VoidLink/Metal/MetalView.h b/VoidLink/Metal/MetalView.h index 4564f51d..b93212b2 100644 --- a/VoidLink/Metal/MetalView.h +++ b/VoidLink/Metal/MetalView.h @@ -24,7 +24,9 @@ @interface MetalView : UIView @property (nonatomic, nonnull, readonly) CAMetalLayer *metalLayer; -@property (nonatomic, nullable) id delegate; +// weak: the delegate (MetalViewController) strongly owns this view; a strong +// reference here creates a retain cycle that leaks the whole Metal stack. +@property (nonatomic, weak, nullable) id delegate; @property (nonatomic) float framerate; - (void)initCommon; diff --git a/VoidLink/Metal/MetalView.m b/VoidLink/Metal/MetalView.m index ca967738..361d3e38 100644 --- a/VoidLink/Metal/MetalView.m +++ b/VoidLink/Metal/MetalView.m @@ -45,10 +45,20 @@ Log(LOG_I, @"[MetalView] sending renderThread a cancel message"); [_renderThread cancel]; Log(LOG_I, @"[MetalView] waiting on renderThread to finish"); - while (!_renderThread.isFinished) { + // Bounded wait only: shutdown typically runs on the main thread, and the render + // thread may be blocked in a dispatch_sync onto the main queue (layer colorspace + // changes). Spinning here forever would deadlock; if we time out, the thread + // exits on its own once the main queue is serviced again (it retains the view + // via its block, so this is safe). + CFTimeInterval deadline = CACurrentMediaTime() + 1.0; + while (!_renderThread.isFinished && CACurrentMediaTime() < deadline) { usleep(100); } - Log(LOG_I, @"[MetalView] renderThread has finished"); + if (_renderThread.isFinished) { + Log(LOG_I, @"[MetalView] renderThread has finished"); + } else { + Log(LOG_W, @"[MetalView] renderThread still busy after 1s, letting it exit asynchronously"); + } _renderThread = nil; } } @@ -68,6 +78,13 @@ return; } + // Never run two render loops at once (the view can move between windows, + // e.g. external display or re-parenting) + if (_renderThread) { + Log(LOG_W, @"[MetalView] movedToWindow with a live renderThread, restarting it"); + [self shutdown]; + } + // Render on a new thread _renderThread = [[NSThread alloc] initWithBlock:^{ while (![NSThread currentThread].isCancelled) { diff --git a/VoidLink/Metal/MetalViewController.h b/VoidLink/Metal/MetalViewController.h index c9264a39..c240f520 100644 --- a/VoidLink/Metal/MetalViewController.h +++ b/VoidLink/Metal/MetalViewController.h @@ -24,4 +24,8 @@ - (void)pauseRendering; - (void)resumeRendering; +// Idempotent teardown: stops the render thread, display link and renderer. +// Must be called when the stream session ends; also runs from viewDidDisappear. +- (void)shutdown; + @end diff --git a/VoidLink/Metal/MetalViewController.m b/VoidLink/Metal/MetalViewController.m index 2f4b56b9..d8496fe4 100644 --- a/VoidLink/Metal/MetalViewController.m +++ b/VoidLink/Metal/MetalViewController.m @@ -109,13 +109,18 @@ } - (void)waitToRenderTo:(nonnull CAMetalLayer *)layer { - // Skip waiting when renderer is paused - if (_renderer.isStopping) { + // Snapshot into a strong local: shutdown (main thread) can nil _renderer while + // the render thread is inside this method. + MetalVideoRenderer *renderer = _renderer; + + // Skip waiting when renderer is paused or gone + if (!renderer || renderer.isStopping) { + _frameSlotAcquired = NO; return; } if (@available(iOS 13.0, *)) { - _frameSlotAcquired = [_renderer waitToRenderTo:layer]; + _frameSlotAcquired = [renderer waitToRenderTo:layer]; } if (_frameSlotAcquired) { @@ -128,22 +133,27 @@ if (!_frameSlotAcquired) { return; } - CFTimeInterval timeout = (1.0f / _framerate) - _renderer.averageGPUTime; + // Snapshot for the same reason as in waitToRenderTo + MetalVideoRenderer *renderer = _renderer; + if (!renderer) { + return; + } + CFTimeInterval timeout = (1.0f / _framerate) - renderer.averageGPUTime; Frame *frame = [_frameQueue dequeueWithTimeoutSync:timeout]; - if (!_renderer.isStopping) { + if (!renderer.isStopping) { // Only render if not paused if (frame) { //if (@available(iOS 13.0, *)) { - [_renderer renderFrame:frame toLayer:layer]; + [renderer renderFrame:frame toLayer:layer]; //} } else { - dispatch_semaphore_signal([_renderer inFlightSemaphore]); + dispatch_semaphore_signal([renderer inFlightSemaphore]); } } else { // When paused, we still dequeue frames to prevent accumulation // but don't render them. Also sleep a bit to reduce CPU usage - dispatch_semaphore_signal([_renderer inFlightSemaphore]); + dispatch_semaphore_signal([renderer inFlightSemaphore]); usleep(100000); } } @@ -177,11 +187,18 @@ Log(LOG_I, @"[MetalViewController] viewDidDisappear"); + [self shutdown]; +} + +- (void)shutdown { if (_displayLink) { [_displayLink invalidate]; _displayLink = nil; } + // Stop the renderer before the render thread: isStopping makes the thread's + // renderFrame return early, so it can't enter the dispatch_sync-to-main path + // while we wait for it below. if (_renderer) { [_renderer shutdown]; _renderer = nil; diff --git a/VoidLink/Stream/Connection.m b/VoidLink/Stream/Connection.m index 0354a405..b51d6f8a 100644 --- a/VoidLink/Stream/Connection.m +++ b/VoidLink/Stream/Connection.m @@ -80,6 +80,10 @@ int DrDecoderSetup(int videoFormat, int width, int height, int redrawRate, void* void DrCleanup(void) { [renderer cleanup]; + // Drop the static reference so the old renderer (and its decoder resources) + // doesn't outlive the session; otherwise it stays alive until the next + // Connection init overwrites it, which can interleave with a new session. + renderer = nil; } -(BandwidthTracker *) getBwTracker diff --git a/VoidLink/Stream/VideoDecoderRenderer.m b/VoidLink/Stream/VideoDecoderRenderer.m index 9c7e9099..f1b8d73e 100644 --- a/VoidLink/Stream/VideoDecoderRenderer.m +++ b/VoidLink/Stream/VideoDecoderRenderer.m @@ -77,6 +77,12 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size, FramePacingMode _framePacingMode; bool _enableTimebase; bool _asyncFrameDequeue; + + // Cached UIApplication background state. UIKit's applicationState must only be + // read on the main thread, but the decode path needs it on the VTDecoder queue. + // Updated on the main thread via notifications; a stale read here is harmless + // (it only gates metrics collection). + volatile BOOL _appInBackground; // CMTime playTime; // NSTimeInterval previousLinkTime; @@ -207,7 +213,7 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size, _needRequeuing = _queueSize>0; _frameQueue = [FrameQueue sharedInstance]; - [_frameQueue start]; + [_frameQueue startForOwner:self]; [_frameQueue setHighWaterMark:MAX(1, _queueSize)]; [self reinitializeDisplayLayer]; @@ -222,6 +228,18 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size, sActiveRenderer = self; } + // Renderer init runs on the main thread, so reading applicationState here is legal. + // The decode queue reads the cached flag instead (UIKit forbids off-main reads). + _appInBackground = ([UIApplication sharedApplication].applicationState == UIApplicationStateBackground); + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appDidEnterBackground) + name:UIApplicationDidEnterBackgroundNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appWillEnterForeground) + name:UIApplicationWillEnterForegroundNotification + object:nil]; + return self; } @@ -327,6 +345,14 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size, }); } +- (void)appDidEnterBackground { + _appInBackground = YES; +} + +- (void)appWillEnterForeground { + _appInBackground = NO; +} + # pragma mark DisplayLink vsync callback - (void)setupWithVideoFormat:(int)videoFormat width:(int)videoWidth height:(int)videoHeight frameRate:(int)frameRate fullRange:(BOOL)fullRange request10BitCodec:(BOOL)request10BitCodec @@ -570,7 +596,7 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit); // we missed a callback // Log(LOG_W, @"*** slow frametime %.3f ms", frametime * 1000.0); } - if ([[UIApplication sharedApplication] applicationState] != UIApplicationStateBackground) { + if (!self->_appInBackground) { [[ImGuiPlots sharedInstance] observeFloat:PLOT_FRAMETIME value:frametime * 1000.0]; } } @@ -675,7 +701,7 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit); } dispatch_async(dispatch_get_main_queue(), ^{ - [self->_frameQueue stop]; + [self->_frameQueue stopForOwner:self]; if (self->_renderingBackend == RENDER_AVSB) { [self->_displayLink invalidate]; @@ -1310,7 +1336,7 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit); framesDropped += [self->_frameQueue enqueue:outputFrame withSlackSize:3]; } - if ([[UIApplication sharedApplication] applicationState] != UIApplicationStateBackground) { + if (!self->_appInBackground) { static PlotMetrics frameQueueMetrics = {}; [[ImGuiPlots sharedInstance] observeFloatReturnMetrics:PLOT_QUEUED_FRAMES value:[self->_frameQueue count] plotMetrics:&frameQueueMetrics]; [self safeCopyMetricsTo:&self->_frameQueueMetrics from:&frameQueueMetrics]; @@ -1334,7 +1360,7 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit); int framesDropped = [self->_frameQueue enqueue:frame withSlackSize:3]; - if ([[UIApplication sharedApplication] applicationState] != UIApplicationStateBackground) { + if (!self->_appInBackground) { static PlotMetrics frameQueueMetrics = {}; [[ImGuiPlots sharedInstance] observeFloatReturnMetrics:PLOT_QUEUED_FRAMES value:[self->_frameQueue count] plotMetrics:&frameQueueMetrics]; [self safeCopyMetricsTo:&self->_frameQueueMetrics from:&frameQueueMetrics]; diff --git a/VoidLink/Utility/Frame.m b/VoidLink/Utility/Frame.m index f69c25af..c7c34707 100644 --- a/VoidLink/Utility/Frame.m +++ b/VoidLink/Utility/Frame.m @@ -108,7 +108,11 @@ static char FrameInterpolatedKey; - (CFDictionaryRef)getFormatDescExtensions { if (!_formatDescExt && _formatDesc) { - _formatDescExt = CFRetain(CMFormatDescriptionGetExtensions(_formatDesc)); + // Can legitimately be NULL, and CFRetain(NULL) crashes + CFDictionaryRef ext = CMFormatDescriptionGetExtensions(_formatDesc); + if (ext) { + _formatDescExt = CFRetain(ext); + } } return _formatDescExt; } diff --git a/VoidLink/Utility/FrameQueue.h b/VoidLink/Utility/FrameQueue.h index 610d7e0d..d70d8a60 100644 --- a/VoidLink/Utility/FrameQueue.h +++ b/VoidLink/Utility/FrameQueue.h @@ -29,8 +29,8 @@ NS_ASSUME_NONNULL_BEGIN - (CFTimeInterval)estimatedFramerate; - (int)currentSoftCap; - (void)waitForEnqueue; -- (void)start; -- (void)stop; +- (void)startForOwner:(id)owner; +- (void)stopForOwner:(id)owner; @end diff --git a/VoidLink/Utility/FrameQueue.m b/VoidLink/Utility/FrameQueue.m index f59e1281..d47177e6 100644 --- a/VoidLink/Utility/FrameQueue.m +++ b/VoidLink/Utility/FrameQueue.m @@ -24,6 +24,10 @@ int _currentSoftCap; dispatch_queue_t _sq; dispatch_semaphore_t _frameSemaphore; + + // The renderer session currently consuming from the queue. A stale session's + // async stop must not pause a newer session's queue. + __weak id _owner; } + (instancetype)sharedInstance { @@ -301,7 +305,14 @@ - (void)clear { os_unfair_lock_lock(&_lock); + // Release any frames still held in the ring buffer, otherwise their + // pixel/sample buffers stay retained for the lifetime of the singleton. + for (int i = 0; i < _capacity; i++) { + [_buffer replaceObjectAtIndex:i withObject:[NSNull null]]; + } _head = _tail = _count = 0; + _droppedLast = NO; + _ptsCorrection = CMTimeMake(0, 90000); _frameDropMetrics = [[FloatBuffer alloc] initWithCapacity:512]; os_unfair_lock_unlock(&_lock); } @@ -336,15 +347,31 @@ return cap; } -- (void)stop { +- (void)stopForOwner:(id)owner { + @synchronized (self) { + // Ignore stops from a previous session: cleanup is dispatched async and can + // land after a new renderer has already (re)started the queue. + if (_owner != nil && owner != _owner) { + Log(LOG_W, @"FrameQueue stop ignored: %p is not the current owner", owner); + return; + } + _owner = nil; + } // new frames will no longer be coming in, make sure consumer side is not left waiting self.paused = YES; Log(LOG_I, @"FrameQueue stopped"); dispatch_semaphore_signal(_frameSemaphore); + // Don't keep the previous session's frames (and their pixel buffers) alive + [self clear]; } -- (void)start { - // (re)start for a new renderer +- (void)startForOwner:(id)owner { + @synchronized (self) { + _owner = owner; + } + // Drop any frames left over from a previous renderer session; feeding them to a + // renderer with a different backend or video format can crash it. + [self clear]; self.paused = NO; Log(LOG_I, @"FrameQueue started"); } diff --git a/VoidLink/ViewControllers/SettingsViewController.m b/VoidLink/ViewControllers/SettingsViewController.m index 19b89071..be63526b 100644 --- a/VoidLink/ViewControllers/SettingsViewController.m +++ b/VoidLink/ViewControllers/SettingsViewController.m @@ -2681,39 +2681,55 @@ BOOL isCustomResolution(int resolutionSelected) { // Check if the rendering backend has actually changed if (previousBackend != sender.selectedSegmentIndex) { - // Show alert to prompt user to restart the app - NSString *message = [LocalizationHelper localizedStringForKey: sender.selectedSegmentIndex == 1 ? @"metalRenderTip" : @"standardRenderTip"]; - - UIAlertController *alertController = [UIAlertController alertControllerWithTitle:[LocalizationHelper localizedStringForKey:@"Restart Required"] - message:message - preferredStyle:UIAlertControllerStyleAlert]; - - UIAlertAction *quitAction = [UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Quit Now"] - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * _Nonnull action) { - + // The renderer is picked up fresh at every stream start, so the change takes + // effect on the next session — no app restart needed. + void (^applyChange)(void) = ^{ Settings* directSettings = [self->dataMan retrieveSettings]; directSettings.renderingBackend = [NSNumber numberWithInteger:sender.selectedSegmentIndex]; [self->dataMan saveData]; [self saveSettings]; - - exit(0); - }]; - - UIAlertAction *laterAction = [UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Learn More"] - style:UIAlertActionStyleCancel - handler:^(UIAlertAction * _Nonnull action) { - self.renderingBackendSelector.selectedSegmentIndex = 0; + }; + + void (^revertChange)(void) = ^{ + self.renderingBackendSelector.selectedSegmentIndex = previousBackend; [self.renderingBackendSelector sendActionsForControlEvents:UIControlEventValueChanged]; - NSURL *url = [NSURL URLWithString:[LocalizationHelper localizedStringForKey:@"betterPerformanceLink"]]; - if ([[UIApplication sharedApplication] canOpenURL:url]) { - [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; - } - }]; - - [alertController addAction:laterAction]; - [alertController addAction:quitAction]; - [self presentViewController:alertController animated:YES completion:nil]; + }; + + if (sender.selectedSegmentIndex == RENDER_METAL) { + // Metal is experimental: confirm its caveats before applying + NSString *message = [LocalizationHelper localizedStringForKey:@"metalRenderTip"]; + + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:[LocalizationHelper localizedStringForKey:@"Enable Metal Renderer?"] + message:message + preferredStyle:UIAlertControllerStyleAlert]; + + [alertController addAction:[UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Apply"] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * _Nonnull action) { + applyChange(); + }]]; + + [alertController addAction:[UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Learn More"] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * _Nonnull action) { + revertChange(); + NSURL *url = [NSURL URLWithString:[LocalizationHelper localizedStringForKey:@"betterPerformanceLink"]]; + if ([[UIApplication sharedApplication] canOpenURL:url]) { + [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; + } + }]]; + + [alertController addAction:[UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Cancel"] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * _Nonnull action) { + revertChange(); + }]]; + + [self presentViewController:alertController animated:YES completion:nil]; + } else { + // Switching back to the standard renderer needs no confirmation + applyChange(); + } } } diff --git a/VoidLink/ViewControllers/StreamFrameViewController.m b/VoidLink/ViewControllers/StreamFrameViewController.m index f974420d..d51b0aec 100644 --- a/VoidLink/ViewControllers/StreamFrameViewController.m +++ b/VoidLink/ViewControllers/StreamFrameViewController.m @@ -1244,8 +1244,8 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil - (void)willMoveToParentViewController:(UIViewController *)parent { // Only cleanup when we're being destroyed if (parent == nil) { - _streamView = nil; [_streamView cleanUp]; + _streamView = nil; [_controllerSupport cleanup]; [UIApplication sharedApplication].idleTimerDisabled = NO; @@ -1255,6 +1255,10 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil _inactivityTimer = nil; } if (self.metalViewController) { + // Explicit shutdown: viewDidDisappear is not guaranteed to fire here + // (e.g. teardown while backgrounded), and it's what stops the render + // thread, display link and renderer. + [self.metalViewController shutdown]; [self.metalViewController.view removeFromSuperview]; [self.metalViewController removeFromParentViewController]; self.metalViewController = nil;