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) + } } } }