import AppKit import SwiftUI /// Shared recording state, observed by the menu bar menu and control bar. final class RecordingState: ObservableObject { @Published var isRecording = false @Published var elapsed: TimeInterval = 0 } /// Owns a recording session: the red border around the recorded region and /// the floating timer/stop bar. Both chrome windows are excluded from the /// video. On stop, the file is auto-saved to ~/Movies and revealed in Finder. final class RecordingController { static let shared = RecordingController() let state = RecordingState() private var recorder: ScreenRecorder? private var chromeWindows: [NSWindow] = [] private var timer: Timer? /// `region` is in AppKit global points; `windowID` records that window /// instead of the region. Called by CaptureController after the /// selection overlays are hidden. func begin(region: NSRect, windowID: CGWindowID?, excludingWindowIDs: [Int]) { guard !state.isRecording else { return } showChrome(region: region) let excluded = excludingWindowIDs + chromeWindows.map { $0.windowNumber } state.isRecording = true let recorder = ScreenRecorder() self.recorder = recorder // Give the window server a beat so the chrome shows up in the // shareable-content snapshot and gets excluded from the video. DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in guard let self, self.recorder === recorder else { return } recorder.start(region: region, windowID: windowID, excludingWindowIDs: excluded) { [weak self] ok in DispatchQueue.main.async { guard let self else { return } if ok { self.startTimer() } else { self.finish(url: nil) ResultWindow.shared.show(image: ResultWindow.failureImage("Could not start the recording. Check Screen Recording permission.")) } } } } } func stop() { guard state.isRecording else { return } state.isRecording = false timer?.invalidate() timer = nil recorder?.stop { [weak self] url in DispatchQueue.main.async { self?.finish(url: url) } } } /// Best-effort finalize on quit so the .mov isn't left truncated. /// Blocks briefly โ€” only call from applicationWillTerminate. func finalizeForTermination() { guard let recorder else { return } let semaphore = DispatchSemaphore(value: 0) recorder.stop { _ in semaphore.signal() } _ = semaphore.wait(timeout: .now() + 3) } private func finish(url: URL?) { state.isRecording = false hideChrome() recorder = nil state.elapsed = 0 if let url { CaptureController.log("rec: saved \(url.path)") NSWorkspace.shared.activateFileViewerSelecting([url]) } } private func startTimer() { let started = Date() state.elapsed = 0 timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in self?.state.elapsed = Date().timeIntervalSince(started) } } // MARK: - Chrome private func showChrome(region: NSRect) { // Red border around the recorded region. let border = NSPanel( contentRect: region, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) border.level = .screenSaver border.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] border.backgroundColor = .clear border.isOpaque = false border.hasShadow = false border.ignoresMouseEvents = true border.contentView = RecordingBorderView() border.orderFrontRegardless() // Floating timer/stop bar at the bottom centre of the region. let bar = NSPanel( contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) bar.level = .screenSaver bar.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] bar.backgroundColor = .clear bar.isOpaque = false bar.hasShadow = false let hosting = NSHostingView(rootView: RecordingBarView(state: state) { [weak self] in self?.stop() }) let size = hosting.fittingSize bar.setContentSize(size) bar.setFrameOrigin(NSPoint(x: region.midX - size.width / 2, y: region.minY + 14)) bar.contentView = hosting bar.orderFrontRegardless() chromeWindows = [border, bar] } private func hideChrome() { chromeWindows.forEach { $0.orderOut(nil) } chromeWindows = [] } } /// Draws the red rounded border around the recorded region. private final class RecordingBorderView: NSView { override func draw(_ dirtyRect: NSRect) { let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 2, dy: 2), xRadius: 8, yRadius: 8) NSColor.systemRed.setStroke() path.lineWidth = 3 path.stroke() } } /// The floating capsule bar: red dot, elapsed time, stop button. struct RecordingBarView: View { @ObservedObject var state: RecordingState let onStop: () -> Void private var timeString: String { let total = Int(state.elapsed) return String(format: "%02d:%02d", total / 60, total % 60) } var body: some View { HStack(spacing: 10) { Circle() .fill(Color.red) .frame(width: 8, height: 8) Text(timeString) .font(.system(size: 12, weight: .medium).monospacedDigit()) .foregroundStyle(.white) Button(action: onStop) { Image(systemName: "stop.fill") .font(.system(size: 10, weight: .bold)) .foregroundStyle(.white) .frame(width: 22, height: 22) .background(Circle().fill(Color.red)) .contentShape(Circle()) } .buttonStyle(.plain) .help("Stop recording (โŒƒโ‡งโŒ˜R)") } .padding(.horizontal, 12) .padding(.vertical, 7) .background(Capsule().fill(Color.black.opacity(0.78))) } }