Recording: Ctrl+Shift+Cmd+R (or the menu) opens the same selection flow — a marquee records that region, clicking a window records it (following moves). Video is H.264 .mov at 60fps via SCStream -> AVAssetWriter with the cursor included, auto-saved to ~/Movies and revealed in Finder on stop. A red border and a floating timer/stop bar show during recording, both excluded from the video. Audio is wired behind a flag for later. The result window gains a Crop button (crop to the selection; annotations translate and stay editable) and is restyled after Cap: sliding-pill Screenshot/Text switcher, capsule buttons in Cap blue, circular icon buttons, and the content on a rounded card — light and dark. Generated with OpenCode Assisted-by: Kimi:k3
182 lines
6.4 KiB
Swift
182 lines
6.4 KiB
Swift
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)))
|
|
}
|
|
}
|