Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/Sources/MTV/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
41 changes: 40 additions & 1 deletion src/Sources/MTV/ApplicationLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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)
}
}
Expand All @@ -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 {
Expand Down
90 changes: 47 additions & 43 deletions src/Sources/MTV/Views.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Expand Down
Loading