Files
kapture/Sources/Kapture/RecordingController.swift
T
TZGyn 6a2afef1d2 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
2026-10-24 21:48:59 +08:00

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)))
}
}