Add screen recording, crop, and a Cap-style result window
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
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user