|
| 1 | +import AppKit |
| 2 | +import SwiftUI |
| 3 | + |
| 4 | +// MARK: - VideoWindowController |
| 5 | + |
| 6 | +/// Manages the floating video window. |
| 7 | +@available(macOS 26.0, *) |
| 8 | +@MainActor |
| 9 | +final class VideoWindowController { |
| 10 | + static let shared = VideoWindowController() |
| 11 | + |
| 12 | + private var window: NSWindow? |
| 13 | + private var hostingView: NSHostingView<AnyView>? |
| 14 | + private let logger = DiagnosticsLogger.player |
| 15 | + |
| 16 | + /// Reference to PlayerService to sync showVideo state |
| 17 | + private weak var playerService: PlayerService? |
| 18 | + |
| 19 | + /// Flag to prevent re-entrant close handling |
| 20 | + private var isClosing = false |
| 21 | + |
| 22 | + // Corner snapping |
| 23 | + enum Corner: Int { |
| 24 | + case topLeft, topRight, bottomLeft, bottomRight |
| 25 | + } |
| 26 | + |
| 27 | + private var currentCorner: Corner = .bottomRight |
| 28 | + |
| 29 | + private init() { |
| 30 | + self.loadCorner() |
| 31 | + } |
| 32 | + |
| 33 | + /// Shows the video window. |
| 34 | + func show( |
| 35 | + playerService: PlayerService, |
| 36 | + webKitManager: WebKitManager |
| 37 | + ) { |
| 38 | + self.logger.debug("VideoWindowController.show() called") |
| 39 | + |
| 40 | + // Store reference to sync state on close |
| 41 | + self.playerService = playerService |
| 42 | + |
| 43 | + // Start grace period to prevent race condition when video element is moved |
| 44 | + playerService.videoWindowDidOpen() |
| 45 | + |
| 46 | + if let existingWindow = self.window { |
| 47 | + // Window exists - just bring it to front |
| 48 | + self.logger.debug("Window already exists, bringing to front") |
| 49 | + self.isClosing = false // Reset in case of interrupted close |
| 50 | + existingWindow.makeKeyAndOrderFront(nil) |
| 51 | + // Ensure video mode is active |
| 52 | + SingletonPlayerWebView.shared.updateDisplayMode(.video) |
| 53 | + return |
| 54 | + } |
| 55 | + |
| 56 | + self.logger.info("Creating new video window") |
| 57 | + |
| 58 | + let contentView = VideoPlayerWindow() |
| 59 | + .environment(playerService) |
| 60 | + .environment(webKitManager) |
| 61 | + |
| 62 | + let hostingView = NSHostingView(rootView: AnyView(contentView)) |
| 63 | + self.hostingView = hostingView |
| 64 | + |
| 65 | + let window = NSWindow( |
| 66 | + contentRect: NSRect(x: 0, y: 0, width: 480, height: 270), |
| 67 | + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], |
| 68 | + backing: .buffered, |
| 69 | + defer: false |
| 70 | + ) |
| 71 | + |
| 72 | + window.contentView = hostingView |
| 73 | + window.isReleasedWhenClosed = false |
| 74 | + window.title = "Video" |
| 75 | + window.titlebarAppearsTransparent = true |
| 76 | + window.titleVisibility = .hidden |
| 77 | + window.isMovableByWindowBackground = true |
| 78 | + // Normal window level (not always-on-top) for better UX |
| 79 | + window.level = .normal |
| 80 | + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] |
| 81 | + window.aspectRatio = NSSize(width: 16, height: 9) |
| 82 | + window.minSize = NSSize(width: 320, height: 180) |
| 83 | + window.backgroundColor = .black |
| 84 | + |
| 85 | + // Set accessibility identifier for UI testing |
| 86 | + window.identifier = NSUserInterfaceItemIdentifier(AccessibilityID.VideoWindow.container) |
| 87 | + |
| 88 | + // Position at saved corner |
| 89 | + self.positionAtCorner(window: window, corner: self.currentCorner) |
| 90 | + |
| 91 | + window.makeKeyAndOrderFront(nil) |
| 92 | + self.window = window |
| 93 | + self.isClosing = false |
| 94 | + |
| 95 | + // Observe window close (for red X button) |
| 96 | + NotificationCenter.default.addObserver( |
| 97 | + self, |
| 98 | + selector: #selector(self.windowWillClose), |
| 99 | + name: NSWindow.willCloseNotification, |
| 100 | + object: window |
| 101 | + ) |
| 102 | + |
| 103 | + // Update WebView display mode for video |
| 104 | + self.logger.info("Calling updateDisplayMode(.video)") |
| 105 | + SingletonPlayerWebView.shared.updateDisplayMode(.video) |
| 106 | + } |
| 107 | + |
| 108 | + /// Closes the video window programmatically (called when showVideo becomes false). |
| 109 | + func close() { |
| 110 | + self.logger.debug("VideoWindowController.close() called") |
| 111 | + |
| 112 | + // Prevent re-entrant calls |
| 113 | + guard !self.isClosing else { |
| 114 | + self.logger.debug("Already closing, skipping") |
| 115 | + return |
| 116 | + } |
| 117 | + |
| 118 | + guard let window = self.window else { |
| 119 | + self.logger.debug("No window to close") |
| 120 | + return |
| 121 | + } |
| 122 | + |
| 123 | + self.isClosing = true |
| 124 | + self.logger.info("Closing video window") |
| 125 | + |
| 126 | + // Remove observer before closing to prevent windowWillClose from firing |
| 127 | + NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: window) |
| 128 | + |
| 129 | + // Save corner position |
| 130 | + self.currentCorner = self.nearestCorner(for: window) |
| 131 | + self.saveCorner() |
| 132 | + |
| 133 | + // Clean up |
| 134 | + self.performCleanup() |
| 135 | + |
| 136 | + // Actually close the window |
| 137 | + window.close() |
| 138 | + } |
| 139 | + |
| 140 | + /// Called when window is closed via the red X button. |
| 141 | + @objc private func windowWillClose(_ notification: Notification) { |
| 142 | + self.logger.info("windowWillClose notification received") |
| 143 | + |
| 144 | + // Prevent re-entrant calls |
| 145 | + guard !self.isClosing else { |
| 146 | + self.logger.debug("Already closing, skipping windowWillClose") |
| 147 | + return |
| 148 | + } |
| 149 | + self.isClosing = true |
| 150 | + |
| 151 | + // Update corner based on final position |
| 152 | + if let window = notification.object as? NSWindow { |
| 153 | + self.currentCorner = self.nearestCorner(for: window) |
| 154 | + self.saveCorner() |
| 155 | + } |
| 156 | + |
| 157 | + // Clean up |
| 158 | + self.performCleanup() |
| 159 | + |
| 160 | + // Sync PlayerService state - this handles close via red button |
| 161 | + // This will trigger MainWindow.onChange which calls close(), but isClosing prevents re-entry |
| 162 | + if self.playerService?.showVideo == true { |
| 163 | + self.logger.debug("Syncing playerService.showVideo to false") |
| 164 | + self.playerService?.showVideo = false |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + /// Shared cleanup logic for both close paths. |
| 169 | + private func performCleanup() { |
| 170 | + self.logger.debug("performCleanup called") |
| 171 | + |
| 172 | + // Clear grace period |
| 173 | + self.playerService?.videoWindowDidClose() |
| 174 | + |
| 175 | + // Return WebView to hidden mode (removes video container CSS) |
| 176 | + SingletonPlayerWebView.shared.updateDisplayMode(.hidden) |
| 177 | + |
| 178 | + // Clear references |
| 179 | + self.window = nil |
| 180 | + self.hostingView = nil |
| 181 | + |
| 182 | + // Reset close guard so future close operations can proceed |
| 183 | + self.isClosing = false |
| 184 | + } |
| 185 | + |
| 186 | + private func positionAtCorner(window: NSWindow, corner: Corner) { |
| 187 | + guard let screen = NSScreen.main else { return } |
| 188 | + let screenFrame = screen.visibleFrame |
| 189 | + let windowSize = window.frame.size |
| 190 | + let padding: CGFloat = 20 |
| 191 | + |
| 192 | + let origin = |
| 193 | + switch corner { |
| 194 | + case .topLeft: |
| 195 | + NSPoint( |
| 196 | + x: screenFrame.minX + padding, |
| 197 | + y: screenFrame.maxY - windowSize.height - padding |
| 198 | + ) |
| 199 | + case .topRight: |
| 200 | + NSPoint( |
| 201 | + x: screenFrame.maxX - windowSize.width - padding, |
| 202 | + y: screenFrame.maxY - windowSize.height - padding |
| 203 | + ) |
| 204 | + case .bottomLeft: |
| 205 | + NSPoint( |
| 206 | + x: screenFrame.minX + padding, |
| 207 | + y: screenFrame.minY + padding |
| 208 | + ) |
| 209 | + case .bottomRight: |
| 210 | + NSPoint( |
| 211 | + x: screenFrame.maxX - windowSize.width - padding, |
| 212 | + y: screenFrame.minY + padding |
| 213 | + ) |
| 214 | + } |
| 215 | + |
| 216 | + window.setFrameOrigin(origin) |
| 217 | + } |
| 218 | + |
| 219 | + private func nearestCorner(for window: NSWindow) -> Corner { |
| 220 | + guard let screen = NSScreen.main else { return .bottomRight } |
| 221 | + let screenFrame = screen.visibleFrame |
| 222 | + let windowCenter = NSPoint( |
| 223 | + x: window.frame.midX, |
| 224 | + y: window.frame.midY |
| 225 | + ) |
| 226 | + let screenCenter = NSPoint( |
| 227 | + x: screenFrame.midX, |
| 228 | + y: screenFrame.midY |
| 229 | + ) |
| 230 | + |
| 231 | + let isLeft = windowCenter.x < screenCenter.x |
| 232 | + let isTop = windowCenter.y > screenCenter.y |
| 233 | + |
| 234 | + switch (isLeft, isTop) { |
| 235 | + case (true, true): return .topLeft |
| 236 | + case (false, true): return .topRight |
| 237 | + case (true, false): return .bottomLeft |
| 238 | + case (false, false): return .bottomRight |
| 239 | + } |
| 240 | + } |
| 241 | + |
| 242 | + private func saveCorner() { |
| 243 | + UserDefaults.standard.set(self.currentCorner.rawValue, forKey: "videoWindowCorner") |
| 244 | + } |
| 245 | + |
| 246 | + private func loadCorner() { |
| 247 | + let raw = UserDefaults.standard.integer(forKey: "videoWindowCorner") |
| 248 | + self.currentCorner = Corner(rawValue: raw) ?? .bottomRight |
| 249 | + } |
| 250 | +} |
0 commit comments