Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Azayaka.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */
170297D62BCD586A0035EB26 /* Updates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 170297D52BCD586A0035EB26 /* Updates.swift */; };
17322D4B2958D07E00185BB6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17322D4A2958D07E00185BB6 /* AppDelegate.swift */; };
0F1C092E83AF4B228AA7BC19 /* CameraOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraOverlay.swift; sourceTree = "<group>"; };
046C136414964EC58D53B5CD /* CameraOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1C092E83AF4B228AA7BC19 /* CameraOverlay.swift */; };
17322D4F2958D08000185BB6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 17322D4E2958D08000185BB6 /* Assets.xcassets */; };
17322D5A2959286100185BB6 /* Processing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17322D592959286100185BB6 /* Processing.swift */; };
17322D5C295928BE00185BB6 /* Recording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17322D5B295928BE00185BB6 /* Recording.swift */; };
Expand Down Expand Up @@ -75,6 +77,7 @@
isa = PBXGroup;
children = (
17322D4A2958D07E00185BB6 /* AppDelegate.swift */,
0F1C092E83AF4B228AA7BC19 /* CameraOverlay.swift */,
17BCDC7F295BAC9300448A97 /* Types.swift */,
17322D5D295929A000185BB6 /* Menu.swift */,
17DBD7C4295B0973005C0690 /* Preferences.swift */,
Expand Down Expand Up @@ -176,6 +179,7 @@
17DBD7C5295B0973005C0690 /* Preferences.swift in Sources */,
17BCDC80295BAC9400448A97 /* Types.swift in Sources */,
17322D4B2958D07E00185BB6 /* AppDelegate.swift in Sources */,
046C136414964EC58D53B5CD /* CameraOverlay.swift in Sources */,
17606AED2C694FE0008A064E /* Shortcuts.swift in Sources */,
17322D5C295928BE00185BB6 /* Recording.swift in Sources */,
17F7E18B2C65097400599CEF /* ClassicProcessing.swift in Sources */,
Expand Down Expand Up @@ -331,6 +335,7 @@
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Azayaka needs this permission to record your microphone alongside your display's content.";
INFOPLIST_KEY_NSCameraUsageDescription = "Azayaka needs this permission to show the camera overlay during recordings.";
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
Expand Down Expand Up @@ -365,6 +370,7 @@
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Azayaka needs this permission to record your microphone alongside your display's content.";
INFOPLIST_KEY_NSCameraUsageDescription = "Azayaka needs this permission to show the camera overlay during recordings.";
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
Expand Down
56 changes: 53 additions & 3 deletions Azayaka/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SCStreamDelegate, SCStreamOu
var vwInput, awInput, micInput: AVAssetWriterInput!
let audioEngine = AVAudioEngine()
var startTime: Date?
var cameraOverlay: CameraOverlay?

func applicationDidFinishLaunching(_ aNotification: Notification) {
lazy var userDesktop = (NSSearchPathForDirectoriesInDomains(.desktopDirectory, .userDomainMask, true) as [String]).first!
Expand All @@ -79,14 +80,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, SCStreamDelegate, SCStreamOu
Preferences.kAudioFormat: AudioFormat.aac.rawValue,
Preferences.kAudioQuality: AudioQuality.high.rawValue,
Preferences.kRecordMic: false,
Preferences.kRecordSystemAudio: true,
Preferences.kSelectedMicrophone: "",

Preferences.kFileName: "Recording at %t".local,
Preferences.kSaveDirectory: saveDirectory,
Preferences.kAutoClipboard: false,

Preferences.kUpdateCheck: true,
Preferences.kCountdownSecs: 0,
Preferences.kSystemRecorder: false
Preferences.kSystemRecorder: false,

Preferences.kShowCamera: false,
Preferences.kSelectedCamera: "",
Preferences.kCameraSize: 150.0
]
)
// create a menu bar item
Expand All @@ -101,19 +108,38 @@ class AppDelegate: NSObject, NSApplicationDelegate, SCStreamDelegate, SCStreamOu
if let error = error { print("Notification authorisation denied: \(error.localizedDescription)") }
}

NotificationCenter.default.addObserver( // update the content & menu when a display device has changed
NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: NSApplication.shared,
queue: OperationQueue.main
) { [self] notification -> Void in
Task { await updateAvailableContent(buildMenu: true) }
}

#if !DEBUG // no point in checking for updates if we're not on a release
NotificationCenter.default.addObserver(forName: .cameraOverlayChanged, object: nil, queue: .main) { [weak self] _ in
guard let self else { return }
if self.ud.bool(forKey: Preferences.kShowCamera) {
self.showCameraOverlay()
} else {
self.hideCameraOverlay()
}
}

NotificationCenter.default.addObserver(forName: .cameraOverlaySizeChanged, object: nil, queue: .main) { [weak self] _ in
guard let self else { return }
let size = CGFloat(self.ud.double(forKey: Preferences.kCameraSize))
self.cameraOverlay?.updateSize(size)
}

#if !DEBUG
if ud.bool(forKey: Preferences.kUpdateCheck) {
UpdateHandler.checkForUpdates()
}
#endif

if ud.bool(forKey: Preferences.kShowCamera) {
showCameraOverlay()
}
}

func updateAvailableContent(buildMenu: Bool) async -> Bool { // returns status of getting content from SCK
Expand Down Expand Up @@ -177,13 +203,37 @@ class AppDelegate: NSObject, NSApplicationDelegate, SCStreamDelegate, SCStreamOu
if stream != nil {
stopRecording()
}
hideCameraOverlay()
}

func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return true
}

func showCameraOverlay() {
let size = CGFloat(ud.double(forKey: Preferences.kCameraSize))
if cameraOverlay == nil {
cameraOverlay = CameraOverlay(size: size)
} else {
cameraOverlay?.updateSize(size)
}
let deviceID = ud.string(forKey: Preferences.kSelectedCamera)
cameraOverlay?.startCamera(deviceID: deviceID)
cameraOverlay?.orderFront(nil)
}

func hideCameraOverlay() {
cameraOverlay?.stopCamera()
cameraOverlay?.orderOut(nil)
cameraOverlay = nil
}
}

extension String {
var local: String { return NSLocalizedString(self, comment: "") }
}

extension Notification.Name {
static let cameraOverlayChanged = Notification.Name("cameraOverlayChanged")
static let cameraOverlaySizeChanged = Notification.Name("cameraOverlaySizeChanged")
}
2 changes: 2 additions & 0 deletions Azayaka/Azayaka.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>
150 changes: 150 additions & 0 deletions Azayaka/CameraOverlay.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//
// CameraOverlay.swift
// Azayaka
//
// Floating circular camera preview overlay (Loom-style).
// Uses a non-activating NSPanel so it doesn't steal focus.
// Captured as part of the screen recording (not excluded).
//

import AVFoundation
import Cocoa


class CameraOverlay: NSPanel {
private var captureSession: AVCaptureSession?
private var previewLayer: AVCaptureVideoPreviewLayer?
private var circleView: NSView!
private var initialLocation: NSPoint?

init(size: CGFloat = 150) {
let frame = NSRect(x: 100, y: 100, width: size, height: size)
super.init(
contentRect: frame,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)

level = .floating
isOpaque = false
backgroundColor = .clear
hasShadow = true
isMovableByWindowBackground = false
collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenNone]
if #available(macOS 13.0, *) {
collectionBehavior.insert(.auxiliary)
}

circleView = NSView(frame: NSRect(origin: .zero, size: frame.size))
circleView.wantsLayer = true
circleView.layer?.cornerRadius = size / 2
circleView.layer?.masksToBounds = true
circleView.layer?.borderWidth = 2
circleView.layer?.borderColor = NSColor.white.withAlphaComponent(0.3).cgColor
contentView = circleView
}

func startCamera(deviceID: String? = nil) {
stopCamera()

let authStatus = AVCaptureDevice.authorizationStatus(for: .video)
switch authStatus {
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
if granted {
DispatchQueue.main.async { self?.setupCaptureSession(deviceID: deviceID) }
}
}
return
case .denied, .restricted:
DispatchQueue.main.async {
let alert = NSAlert()
alert.messageText = "Azayaka needs camera permissions!".local
alert.informativeText = "Azayaka needs permission to access your camera for the overlay.".local
alert.addButton(withTitle: "Open Settings".local)
alert.addButton(withTitle: "No thanks".local)
alert.alertStyle = .warning
if alert.runModal() == .alertFirstButtonReturn {
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera")!)
}
}
return
case .authorized:
setupCaptureSession(deviceID: deviceID)
@unknown default:
return
}
}

private func setupCaptureSession(deviceID: String? = nil) {
captureSession = AVCaptureSession()
captureSession?.sessionPreset = .medium

let device: AVCaptureDevice?
if let deviceID, !deviceID.isEmpty,
let specific = AVCaptureDevice(uniqueID: deviceID) {
device = specific
} else {
device = AVCaptureDevice.default(for: .video)
}

guard let device, let input = try? AVCaptureDeviceInput(device: device) else { return }
guard captureSession?.canAddInput(input) == true else { return }
captureSession?.addInput(input)

let preview = AVCaptureVideoPreviewLayer(session: captureSession!)
preview.videoGravity = .resizeAspectFill
preview.frame = circleView.bounds
preview.cornerRadius = circleView.bounds.width / 2
preview.masksToBounds = true
circleView.layer?.addSublayer(preview)
previewLayer = preview

captureSession?.startRunning()
}

func stopCamera() {
captureSession?.stopRunning()
captureSession = nil
previewLayer?.removeFromSuperlayer()
previewLayer = nil
}

func updateSize(_ size: CGFloat) {
CATransaction.begin()
CATransaction.setDisableActions(true)
let origin = frame.origin
setFrame(NSRect(x: origin.x, y: origin.y, width: size, height: size), display: true)
circleView.frame = NSRect(origin: .zero, size: NSSize(width: size, height: size))
circleView.layer?.cornerRadius = size / 2
previewLayer?.frame = circleView.bounds
previewLayer?.cornerRadius = size / 2
CATransaction.commit()
}

// MARK: - Dragging

override func mouseDown(with event: NSEvent) {
initialLocation = event.locationInWindow
}

override func mouseDragged(with event: NSEvent) {
guard let initialLocation else { return }
let screenLocation = event.locationInWindow
let origin = frame.origin
let newOrigin = NSPoint(
x: origin.x + (screenLocation.x - initialLocation.x),
y: origin.y + (screenLocation.y - initialLocation.y)
)
setFrameOrigin(newOrigin)
}

override func mouseUp(with event: NSEvent) {
initialLocation = nil
}

deinit {
stopCamera()
}
}
1 change: 1 addition & 0 deletions Azayaka/ClassicProcessing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import ScreenCaptureKit
import AVFoundation

// This file contains code related to the "classic" recorder. It uses an
// AVAssetWriter instead of the ScreenCaptureKit recorder found in macOS Sequoia.
Expand Down
Loading