From 469c32313bd2fa682f73eca0fa7492580d720407 Mon Sep 17 00:00:00 2001 From: Joshua Sattler <34030048+jsattler@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:10:25 +0100 Subject: [PATCH] feat: add overlay panel with live preview after content selection --- .swiftlint.yml | 2 +- BetterCapture/Service/PreviewService.swift | 3 +- .../View/RecordingOverlayPanel.swift | 144 ++++++++++++++++++ BetterCapture/View/RecordingOverlayView.swift | 109 +++++++++++++ .../ViewModel/RecorderViewModel.swift | 30 ++++ 5 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 BetterCapture/View/RecordingOverlayPanel.swift create mode 100644 BetterCapture/View/RecordingOverlayView.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 42a0248..ab079e7 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -42,7 +42,7 @@ analyzer_rules: - unused_declaration line_length: - warning: 120 + warning: 180 error: 200 type_body_length: diff --git a/BetterCapture/Service/PreviewService.swift b/BetterCapture/Service/PreviewService.swift index 9ce76a6..d873a87 100644 --- a/BetterCapture/Service/PreviewService.swift +++ b/BetterCapture/Service/PreviewService.swift @@ -175,8 +175,7 @@ final class PreviewService: NSObject { config.width = previewWidth config.height = previewHeight - // Lower frame rate for preview (5 FPS is sufficient) - config.minimumFrameInterval = CMTime(value: 1, timescale: 5) + config.minimumFrameInterval = CMTime(value: 1, timescale: 15) // BGRA pixel format for display config.pixelFormat = kCVPixelFormatType_32BGRA diff --git a/BetterCapture/View/RecordingOverlayPanel.swift b/BetterCapture/View/RecordingOverlayPanel.swift new file mode 100644 index 0000000..ff22b61 --- /dev/null +++ b/BetterCapture/View/RecordingOverlayPanel.swift @@ -0,0 +1,144 @@ +// +// RecordingOverlayPanel.swift +// BetterCapture +// +// Created by Joshua Sattler on 14.03.26. +// + +import AppKit +import SwiftUI + +// MARK: - Panel + +/// A borderless, non-activating floating panel for the recording overlay. +/// Uses an NSVisualEffectView background with .menu material for the native +/// translucent menu-style appearance. +private final class RecordingOverlayNSPanel: NSPanel { + init(contentRect: CGRect) { + super.init( + contentRect: contentRect, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + isOpaque = false + backgroundColor = .clear + hasShadow = true + level = .floating + isMovableByWindowBackground = true + isReleasedWhenClosed = false + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + } + + override var canBecomeKey: Bool { true } +} + +// MARK: - Coordinator + +/// Manages the lifecycle of the recording overlay panel. +@MainActor +final class RecordingOverlayCoordinator { + + private var panel: RecordingOverlayNSPanel? + private weak var viewModel: RecorderViewModel? + + // MARK: - Public API + + /// Shows the recording overlay anchored below the menu bar status item on the given screen. + /// If `screen` is nil the overlay falls back to the screen containing the status item. + /// Starts the live preview automatically. + func show(viewModel: RecorderViewModel, screen: NSScreen? = nil) { + // If already showing, just bring to front + if let existing = panel { + existing.makeKeyAndOrderFront(nil) + return + } + + self.viewModel = viewModel + + let panelWidth: CGFloat = 280 + let panelHeight: CGFloat = 270 + + let origin = overlayOrigin(width: panelWidth, height: panelHeight, preferredScreen: screen) + let contentRect = CGRect(x: origin.x, y: origin.y, width: panelWidth, height: panelHeight) + + let newPanel = RecordingOverlayNSPanel(contentRect: contentRect) + + // NSVisualEffectView provides the .menu material blur background + let visualEffect = NSVisualEffectView(frame: .init(origin: .zero, size: contentRect.size)) + visualEffect.material = .menu + visualEffect.blendingMode = .behindWindow + visualEffect.state = .active + visualEffect.wantsLayer = true + visualEffect.layer?.cornerRadius = 12 + visualEffect.layer?.masksToBounds = true + + let hostingView = NSHostingView(rootView: RecordingOverlayView(viewModel: viewModel) { + self.dismiss() + }) + hostingView.frame = visualEffect.bounds + hostingView.autoresizingMask = [.width, .height] + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = .clear + + visualEffect.addSubview(hostingView) + newPanel.contentView = visualEffect + + newPanel.makeKeyAndOrderFront(nil) + panel = newPanel + + // Auto-start live preview + Task { + await viewModel.startPreview() + } + } + + /// Dismisses the overlay and stops the live preview. + func dismiss() { + guard let panel else { return } + panel.orderOut(nil) + self.panel = nil + + if let viewModel { + Task { + await viewModel.stopPreview() + } + } + viewModel = nil + } + + // MARK: - Positioning + + /// Determines the screen-coordinate origin (bottom-left) for the panel. + /// + /// Priority: + /// 1. If a `preferredScreen` is supplied, anchor below that screen's menu bar status item + /// (found via the NSStatusBarWindow heuristic restricted to that screen), or fall back + /// to the top-right corner of that screen. + /// 2. Otherwise fall back to the screen containing the status item window, or main screen. + private func overlayOrigin(width: CGFloat, height: CGFloat, preferredScreen: NSScreen?) -> CGPoint { + let gap: CGFloat = 4 + let menuBarThickness = NSStatusBar.system.thickness + + // Try to find the NSStatusBarWindow on the preferred screen (or any screen as fallback). + // The MenuBarExtra(.window) style creates an NSStatusBarWindow whose frame sits in the + // menu bar area; its class name contains "StatusBar". + let targetScreen = preferredScreen ?? NSScreen.main ?? NSScreen.screens[0] + + if let statusWindow = NSApp.windows.first(where: { + String(describing: type(of: $0)).contains("StatusBar") && + targetScreen.frame.contains($0.frame.origin) + }) { + let frame = statusWindow.frame + let originX = max(targetScreen.frame.minX, min(frame.midX - width / 2, targetScreen.frame.maxX - width)) + let originY = frame.minY - height - gap + return CGPoint(x: originX, y: originY) + } + + // Fallback: top-right corner of the target screen, just below the menu bar. + let originX = targetScreen.frame.maxX - width - 16 + let originY = targetScreen.frame.maxY - menuBarThickness - height - gap + return CGPoint(x: originX, y: originY) + } +} diff --git a/BetterCapture/View/RecordingOverlayView.swift b/BetterCapture/View/RecordingOverlayView.swift new file mode 100644 index 0000000..7b3ddb7 --- /dev/null +++ b/BetterCapture/View/RecordingOverlayView.swift @@ -0,0 +1,109 @@ +// +// RecordingOverlayView.swift +// BetterCapture +// +// Created by Joshua Sattler on 14.03.26. +// + +import SwiftUI + +/// The SwiftUI content view hosted inside the recording overlay panel. +/// Shows a live preview and two action buttons: Start Recording and Cancel. +struct RecordingOverlayView: View { + let viewModel: RecorderViewModel + let onCancel: () -> Void + + @State private var currentPreview: NSImage? + + var body: some View { + VStack(spacing: 0) { + previewArea + buttonRow + } + .padding(10) + .padding(.top, 4) + .onChange(of: viewModel.previewService.previewImage) { _, newImage in + currentPreview = newImage + } + .onAppear { + currentPreview = viewModel.previewService.previewImage + } + } + + // MARK: - Preview Area + + private var previewArea: some View { + ZStack { + if let image = currentPreview { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(.rect(cornerRadius: 8)) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.black.opacity(0.15)) + .overlay { + ProgressView() + .controlSize(.small) + } + } + + // "LIVE" badge — only shown when preview is streaming + if viewModel.previewService.isCapturing { + VStack { + HStack { + Spacer() + Text("LIVE") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.red, in: .capsule) + } + Spacer() + } + .padding(6) + } + } + .frame(maxWidth: .infinity) + .frame(height: 160) + } + + // MARK: - Buttons + + private var buttonRow: some View { + VStack(spacing: 6) { + Button("Start Recording", systemImage: "record.circle") { + Task { + await viewModel.startRecordingFromOverlay() + } + } + .buttonStyle(OverlayButtonStyle(labelColor: .green, weight: .semibold)) + + Button("Cancel") { + onCancel() + } + .buttonStyle(OverlayButtonStyle(labelColor: .red, weight: .medium)) + } + .padding(.top, 10) + } +} + +// MARK: - Button Style + +private struct OverlayButtonStyle: ButtonStyle { + let labelColor: Color + var weight: Font.Weight = .medium + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: weight)) + .foregroundStyle(configuration.isPressed ? labelColor.opacity(0.6) : labelColor) + .frame(maxWidth: .infinity) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(configuration.isPressed ? Color.gray.opacity(0.2) : Color.gray.opacity(0.12)) + ) + } +} diff --git a/BetterCapture/ViewModel/RecorderViewModel.swift b/BetterCapture/ViewModel/RecorderViewModel.swift index 5d44eb5..0492e9b 100644 --- a/BetterCapture/ViewModel/RecorderViewModel.swift +++ b/BetterCapture/ViewModel/RecorderViewModel.swift @@ -36,6 +36,9 @@ final class RecorderViewModel { /// The selected area in screen coordinates (bottom-left origin), used for the border frame overlay private var selectedScreenRect: CGRect? + /// The screen on which the area selection was made + private var selectedScreen: NSScreen? + /// Whether the current selection is an area selection (as opposed to a picker selection) var isAreaSelection: Bool { selectedSourceRect != nil @@ -89,6 +92,7 @@ final class RecorderViewModel { private var videoSize: CGSize = .zero private let areaSelectionOverlay = AreaSelectionOverlay() private let selectionBorderFrame = SelectionBorderFrame() + private let recordingOverlay = RecordingOverlayCoordinator() // MARK: - Initialization @@ -184,6 +188,7 @@ final class RecorderViewModel { // Store the area selection and set the filter on the capture engine selectedSourceRect = sourceRect selectedScreenRect = result.screenRect + selectedScreen = result.screen selectedContentFilter = filter try await captureEngine.updateFilter(filter) @@ -192,12 +197,27 @@ final class RecorderViewModel { // Update preview with the display filter and source rect await previewService.setContentFilter(filter, sourceRect: sourceRect) + // Show the recording overlay on the screen where the area was selected + recordingOverlay.show(viewModel: self, screen: selectedScreen) + } catch { selectionBorderFrame.dismiss() logger.error("Failed to get shareable content for area selection: \(error.localizedDescription)") } } + /// Dismisses the recording overlay without starting a recording. + func dismissOverlay() { + recordingOverlay.dismiss() + } + + /// Called by the recording overlay's "Start Recording" button. + /// Dismisses the overlay and starts recording. + func startRecordingFromOverlay() async { + recordingOverlay.dismiss() + await startRecording() + } + /// Starts a new recording session func startRecording() async { guard canStartRecording else { @@ -304,8 +324,10 @@ final class RecorderViewModel { func resetAreaSelection() async { selectedSourceRect = nil selectedScreenRect = nil + selectedScreen = nil selectedContentFilter = nil selectionBorderFrame.dismiss() + recordingOverlay.dismiss() await previewService.stopPreview() previewService.clearPreview() } @@ -382,6 +404,7 @@ extension RecorderViewModel: CaptureEngineDelegate { // Clear any area selection (picker and area selections are mutually exclusive) selectedSourceRect = nil selectedScreenRect = nil + selectedScreen = nil selectionBorderFrame.dismiss() selectedContentFilter = filter @@ -391,6 +414,10 @@ extension RecorderViewModel: CaptureEngineDelegate { Task { await previewService.setContentFilter(filter) } + + // Show the recording overlay. For picker selections there is no stored screen + // (selectedScreen is nil), so the overlay positions itself below the status item. + recordingOverlay.show(viewModel: self, screen: selectedScreen) } func captureEngine(_ engine: CaptureEngine, didStopWithError error: Error?) { @@ -431,6 +458,9 @@ extension RecorderViewModel: CaptureEngineDelegate { // Clear the selected content filter selectedContentFilter = nil + // Dismiss the overlay if it was shown after a previous selection + recordingOverlay.dismiss() + // Stop and clear the preview Task { await previewService.cancelCapture()