From 20a21dd90d457adbc79ac29ad82dc7346b8a1f44 Mon Sep 17 00:00:00 2001 From: Force1ess <72636351+Force1ess@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:50:48 +0800 Subject: [PATCH] Keep the browse scroll view out of fullscreen transitions AppKit aborts a window's display cycle when a SwiftUI scroll view keeps resizing its document view during a fullscreen animation: every pass marks vibrancy as needing an update, which asks for another pass, and the cycle ends in NSGenericException once there have been more passes than the window has views. The launch transition hits it whenever the pointer is inside the window and the library is still filling in, so the scroll view is absent until the transition's own display cycles are over. Notification delivery is synchronous, and the gate lifts only after two window updates, because both edges have to land outside the animation's display passes. --- src/Sources/MTV/AppModel.swift | 4 +- src/Sources/MTV/ApplicationLifecycle.swift | 41 +++++++++- src/Sources/MTV/Views.swift | 90 +++++++++++----------- 3 files changed, 90 insertions(+), 45 deletions(-) diff --git a/src/Sources/MTV/AppModel.swift b/src/Sources/MTV/AppModel.swift index 0737bcd..9d7b32b 100644 --- a/src/Sources/MTV/AppModel.swift +++ b/src/Sources/MTV/AppModel.swift @@ -41,6 +41,7 @@ struct LibraryScanObservation: Codable { @MainActor final class AppModel: ObservableObject { var mediaFocusLayout: MediaFocusLayout? + @Published var windowIsTransitioning = false let login = LoginController() let dictationKey = RemoteDictationKey() let externalApp = ExternalAppSession() @@ -2764,7 +2765,8 @@ struct LibraryScanObservation: Codable { UserDefaults.standard.set(loudnessEqualization, forKey: "mtv.audio.loudnessEqualization") } case .metadata: prepareAutomaticMetadata() - case .fullscreen: NSApp.keyWindow?.toggleFullScreen(nil) + case .fullscreen: + windowIsTransitioning = true; NSApp.keyWindow?.toggleFullScreen(nil) case .accounts: for plugin in plugins where plugin.manifest.authentication != nil { login.refresh(plugin) } accountSettingsIndex = 0; accountSettingsVisible = true diff --git a/src/Sources/MTV/ApplicationLifecycle.swift b/src/Sources/MTV/ApplicationLifecycle.swift index 6a63644..1a52fb7 100644 --- a/src/Sources/MTV/ApplicationLifecycle.swift +++ b/src/Sources/MTV/ApplicationLifecycle.swift @@ -13,6 +13,8 @@ let launchLog = Logger(subsystem: "local.mtv.preview", category: "launch") private var startupFullScreenPending = false private var control: AppControl? private var windowFocusObservers: [NSObjectProtocol] = [] + private var transitionObservers: [NSObjectProtocol] = [] + private var pendingUngateUpdates = 0 private var terminationSignal: DispatchSourceSignal? private var isTerminating = false private var readyToTerminate = false @@ -85,6 +87,7 @@ let launchLog = Logger(subsystem: "local.mtv.preview", category: "launch") window.isRestorable = false window.collectionBehavior.insert(.fullScreenPrimary) window.delegate = self + observeFullScreenTransition(window) let hostingView = NSHostingView(rootView: RootView(model: model)) if #unavailable(macOS 26.0) { // AppKit owns the window size on Sequoia. Avoid feeding SwiftUI's @@ -99,6 +102,40 @@ let launchLog = Logger(subsystem: "local.mtv.preview", category: "launch") if CommandLine.arguments.contains("--background-recovery") { mainWindow?.orderFront(nil) } else { mainWindow?.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true) } } + /// AppKit livelocks its display cycle when a SwiftUI scroll view resizes its document + /// view on every pass of a fullscreen animation, so views gate on this flag. Delivery is + /// synchronous because the animation's first display pass must already see it, and the gate + /// lifts only after the transition's own display cycles are done. + private func observeFullScreenTransition(_ window: NSWindow) { + for name in [NSWindow.willEnterFullScreenNotification, NSWindow.willExitFullScreenNotification] { + transitionObservers.append(NotificationCenter.default.addObserver( + forName: name, object: window, queue: nil + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.pendingUngateUpdates = 0 + self?.model.windowIsTransitioning = true + } + }) + } + for name in [NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification] { + transitionObservers.append(NotificationCenter.default.addObserver( + forName: name, object: window, queue: nil + ) { [weak self] _ in + MainActor.assumeIsolated { self?.pendingUngateUpdates = 1 } + }) + } + transitionObservers.append(NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, object: window, queue: nil + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.pendingUngateUpdates > 0 else { return } + self.pendingUngateUpdates += 1 + guard self.pendingUngateUpdates > 2 else { return } + self.pendingUngateUpdates = 0 + self.model.windowIsTransitioning = false + } + }) + } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { showMainWindow() @@ -120,6 +157,7 @@ let launchLog = Logger(subsystem: "local.mtv.preview", category: "launch") DispatchQueue.main.async { [weak self, weak window] in guard let self, let window, !self.isTerminating, window.isVisible, !window.styleMask.contains(.fullScreen) else { return } + model.windowIsTransitioning = true window.toggleFullScreen(nil) } } @@ -135,8 +173,9 @@ let launchLog = Logger(subsystem: "local.mtv.preview", category: "launch") private func beginTermination() { guard !isTerminating else { return } isTerminating = true - for observer in windowFocusObservers { NotificationCenter.default.removeObserver(observer) } + for observer in windowFocusObservers + transitionObservers { NotificationCenter.default.removeObserver(observer) } windowFocusObservers.removeAll() + transitionObservers.removeAll() TVPointerFocus.setPlaybackCursorHidden(false) control?.stop(); control = nil Task { diff --git a/src/Sources/MTV/Views.swift b/src/Sources/MTV/Views.swift index b4c2bbd..ffc6338 100644 --- a/src/Sources/MTV/Views.swift +++ b/src/Sources/MTV/Views.swift @@ -120,53 +120,57 @@ struct RootView: View { .padding(.horizontal, 64).padding(.bottom, 14).background(ink) } ScrollViewReader { scroll in - ScrollView { - VStack(alignment: .leading, spacing: model.section == .settings ? 24 : 40) { - if model.section == .home { - hero.foregroundStyle(.white).frame(height: max(390, min(560, canvas.height * 0.54))) - .padding(.horizontal, -64) - appRow - if !model.continueWatching.entries.isEmpty { ContinueWatchingRow(model: model) } - if !model.watchlist.entries.isEmpty { SavedContentShelf(model: model, shelf: .watchlist) } - if let error = model.watchlist.error { - Label(L("待看片单暂不可用:") + error, systemImage: "exclamationmark.circle") - .font(.system(size: 16)).foregroundStyle(.secondary) + // AppKit livelocks its display cycle when a scroll view resizes its document view + // on every pass of a fullscreen animation, so the scroll view stays absent until it ends. + if !model.windowIsTransitioning { + ScrollView { + VStack(alignment: .leading, spacing: model.section == .settings ? 24 : 40) { + if model.section == .home { + hero.foregroundStyle(.white).frame(height: max(390, min(560, canvas.height * 0.54))) + .padding(.horizontal, -64) + appRow + if !model.continueWatching.entries.isEmpty { ContinueWatchingRow(model: model) } + if !model.watchlist.entries.isEmpty { SavedContentShelf(model: model, shelf: .watchlist) } + if let error = model.watchlist.error { + Label(L("待看片单暂不可用:") + error, systemImage: "exclamationmark.circle") + .font(.system(size: 16)).foregroundStyle(.secondary) + } + if let error = model.continueWatching.error { + Label(L("继续观看暂不可用:") + error, systemImage: "exclamationmark.circle") + .font(.system(size: 16)).foregroundStyle(.secondary) + } + } else { + sectionHeading.padding(.top, model.section == .settings ? 0 : 30) } - if let error = model.continueWatching.error { - Label(L("继续观看暂不可用:") + error, systemImage: "exclamationmark.circle") - .font(.system(size: 16)).foregroundStyle(.secondary) + if model.section == .home || model.section == .library || model.section == .bilibili + { + mediaGrid(width: canvas.width) + } else if model.section == .extensions { + extensions + } else { + settings } - } else { - sectionHeading.padding(.top, model.section == .settings ? 0 : 30) - } - if model.section == .home || model.section == .library || model.section == .bilibili - { - mediaGrid(width: canvas.width) - } else if model.section == .extensions { - extensions - } else { - settings + }.padding(.horizontal, 64).padding(.bottom, 70).id("top") + } + .overlayPreferenceValue(MediaTitleAnchorKey.self) { anchors in + MediaFocusObserver(model: model, anchors: anchors).allowsHitTesting(false) + } + .onChange(of: canvas) { _, _ in + guard model.section != .settings, model.focus.row >= model.mediaFocusOffset else { return } + scroll.scrollTo("row-\(model.focus.row)", anchor: .center) + } + .onChange(of: model.focus.row) { _, row in + guard model.section != .settings else { return } + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.28)) { + let showHeading = row == 0 || (model.hasModuleSubnavigation && row < model.mediaFocusOffset) + scroll.scrollTo(showHeading ? "top" : "row-\(row)", anchor: showHeading || row == 1 ? .top : .center) } - }.padding(.horizontal, 64).padding(.bottom, 70).id("top") - } - .overlayPreferenceValue(MediaTitleAnchorKey.self) { anchors in - MediaFocusObserver(model: model, anchors: anchors).allowsHitTesting(false) - } - .onChange(of: canvas) { _, _ in - guard model.section != .settings, model.focus.row >= model.mediaFocusOffset else { return } - scroll.scrollTo("row-\(model.focus.row)", anchor: .center) - } - .onChange(of: model.focus.row) { _, row in - guard model.section != .settings else { return } - withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.28)) { - let showHeading = row == 0 || (model.hasModuleSubnavigation && row < model.mediaFocusOffset) - scroll.scrollTo(showHeading ? "top" : "row-\(row)", anchor: showHeading || row == 1 ? .top : .center) } - } - .onChange(of: model.section) { _, _ in - if model.section == .settings { scroll.scrollTo("top", anchor: .top); return } - let row = model.focus.row - scroll.scrollTo(row == 0 ? "top" : "row-\(row)", anchor: row <= 1 ? .top : .center) + .onChange(of: model.section) { _, _ in + if model.section == .settings { scroll.scrollTo("top", anchor: .top); return } + let row = model.focus.row + scroll.scrollTo(row == 0 ? "top" : "row-\(row)", anchor: row <= 1 ? .top : .center) + } } } }