mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 15:44:32 +00:00
Restored files from old Mac, force pushing
This commit is contained in:
parent
5dd95228b8
commit
5040381840
126 changed files with 10431 additions and 67 deletions
322
DynamicIsland/managers/BatteryActivityManager.swift
Normal file
322
DynamicIsland/managers/BatteryActivityManager.swift
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import Foundation
|
||||
import IOKit.ps
|
||||
|
||||
/// Manages and monitors battery status changes on the device
|
||||
/// - Note: This class uses the IOKit framework to monitor battery status
|
||||
class BatteryActivityManager {
|
||||
|
||||
static let shared = BatteryActivityManager()
|
||||
|
||||
var onBatteryLevelChange: ((Float) -> Void)?
|
||||
var onMaxCapacityChange: ((Float) -> Void)?
|
||||
var onPowerModeChange: ((Bool) -> Void)?
|
||||
var onPowerSourceChange: ((Bool) -> Void)?
|
||||
var onChargingChange: ((Bool) -> Void)?
|
||||
var onTimeToFullChargeChange: ((Int) -> Void)?
|
||||
|
||||
private var batterySource: CFRunLoopSource?
|
||||
private var observers: [(BatteryEvent) -> Void] = []
|
||||
private var previousBatteryInfo: BatteryInfo?
|
||||
private var notificationQueue: [BatteryEvent] = []
|
||||
private var isProcessingNotifications = false
|
||||
|
||||
enum BatteryEvent {
|
||||
case powerSourceChanged(isPluggedIn: Bool)
|
||||
case batteryLevelChanged(level: Float)
|
||||
case lowPowerModeChanged(isEnabled: Bool)
|
||||
case isChargingChanged(isCharging: Bool)
|
||||
case timeToFullChargeChanged(time: Int)
|
||||
case maxCapacityChanged(capacity: Float)
|
||||
case error(description: String)
|
||||
}
|
||||
|
||||
enum BatteryError: Error {
|
||||
case powerSourceUnavailable
|
||||
case batteryInfoUnavailable(String)
|
||||
case batteryParameterMissing(String)
|
||||
}
|
||||
|
||||
private let defaultBatteryInfo = BatteryInfo(
|
||||
isPluggedIn: false,
|
||||
isCharging: false,
|
||||
currentCapacity: 0,
|
||||
maxCapacity: 0,
|
||||
isInLowPowerMode: false,
|
||||
timeToFullCharge: 0
|
||||
)
|
||||
|
||||
private init() {
|
||||
startMonitoring()
|
||||
setupLowPowerModeObserver()
|
||||
}
|
||||
|
||||
/// Setup observer for low power mode changes
|
||||
private func setupLowPowerModeObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(lowPowerModeChanged),
|
||||
name: NSNotification.Name.NSProcessInfoPowerStateDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Called when low power mode is enabled or disabled
|
||||
@objc private func lowPowerModeChanged() {
|
||||
notifyBatteryChanges()
|
||||
}
|
||||
|
||||
/// Starts monitoring battery changes
|
||||
private func startMonitoring() {
|
||||
guard let powerSource = IOPSNotificationCreateRunLoopSource({ context in
|
||||
guard let context = context else { return }
|
||||
let manager = Unmanaged<BatteryActivityManager>.fromOpaque(context).takeUnretainedValue()
|
||||
manager.notifyBatteryChanges()
|
||||
}, Unmanaged.passUnretained(self).toOpaque())?.takeRetainedValue() else {
|
||||
return
|
||||
}
|
||||
batterySource = powerSource
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), powerSource, .defaultMode)
|
||||
}
|
||||
|
||||
/// Stops monitoring battery changes
|
||||
private func stopMonitoring() {
|
||||
if let powerSource = batterySource {
|
||||
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), powerSource, .defaultMode)
|
||||
batterySource = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks for changes in a property and notifies observers
|
||||
private func checkAndNotify<T: Equatable>(
|
||||
previous: T,
|
||||
current: T,
|
||||
eventGenerator: (T) -> BatteryEvent
|
||||
) {
|
||||
if previous != current {
|
||||
enqueueNotification(eventGenerator(current))
|
||||
}
|
||||
}
|
||||
|
||||
/// Notifies the observers of battery changes
|
||||
/// Checks for changes in battery status and notifies observers
|
||||
private func notifyBatteryChanges() {
|
||||
let batteryInfo = getBatteryInfo()
|
||||
|
||||
// Check for changes
|
||||
if let previousInfo = previousBatteryInfo {
|
||||
// Usar la función auxiliar para cada propiedad
|
||||
checkAndNotify(
|
||||
previous: previousInfo.isPluggedIn,
|
||||
current: batteryInfo.isPluggedIn,
|
||||
eventGenerator: { .powerSourceChanged(isPluggedIn: $0) }
|
||||
)
|
||||
|
||||
checkAndNotify(
|
||||
previous: previousInfo.currentCapacity,
|
||||
current: batteryInfo.currentCapacity,
|
||||
eventGenerator: { .batteryLevelChanged(level: $0) }
|
||||
)
|
||||
|
||||
checkAndNotify(
|
||||
previous: previousInfo.isCharging,
|
||||
current: batteryInfo.isCharging,
|
||||
eventGenerator: { .isChargingChanged(isCharging: $0) }
|
||||
)
|
||||
|
||||
checkAndNotify(
|
||||
previous: previousInfo.isInLowPowerMode,
|
||||
current: batteryInfo.isInLowPowerMode,
|
||||
eventGenerator: { .lowPowerModeChanged(isEnabled: $0) }
|
||||
)
|
||||
|
||||
checkAndNotify(
|
||||
previous: previousInfo.timeToFullCharge,
|
||||
current: batteryInfo.timeToFullCharge,
|
||||
eventGenerator: { .timeToFullChargeChanged(time: $0) }
|
||||
)
|
||||
|
||||
checkAndNotify(
|
||||
previous: previousInfo.maxCapacity,
|
||||
current: batteryInfo.maxCapacity,
|
||||
eventGenerator: { .maxCapacityChanged(capacity: $0) }
|
||||
)
|
||||
} else {
|
||||
// First time notification
|
||||
enqueueNotification(.powerSourceChanged(isPluggedIn: batteryInfo.isPluggedIn))
|
||||
enqueueNotification(.batteryLevelChanged(level: batteryInfo.currentCapacity))
|
||||
enqueueNotification(.isChargingChanged(isCharging: batteryInfo.isCharging))
|
||||
enqueueNotification(.lowPowerModeChanged(isEnabled: batteryInfo.isInLowPowerMode))
|
||||
enqueueNotification(.timeToFullChargeChanged(time: batteryInfo.timeToFullCharge))
|
||||
enqueueNotification(.maxCapacityChanged(capacity: batteryInfo.maxCapacity))
|
||||
}
|
||||
|
||||
// Update previous battery info
|
||||
previousBatteryInfo = batteryInfo
|
||||
|
||||
// Trigger optional callbacks
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.onBatteryLevelChange?(batteryInfo.currentCapacity)
|
||||
self.onPowerSourceChange?(batteryInfo.isPluggedIn)
|
||||
self.onChargingChange?(batteryInfo.isCharging)
|
||||
self.onPowerModeChange?(batteryInfo.isInLowPowerMode)
|
||||
self.onTimeToFullChargeChange?(batteryInfo.timeToFullCharge)
|
||||
self.onMaxCapacityChange?(batteryInfo.maxCapacity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueues a notification to be processed
|
||||
/// - Parameter event: The battery event
|
||||
private func enqueueNotification(_ event: BatteryEvent) {
|
||||
notificationQueue.append(event)
|
||||
processNextNotification()
|
||||
}
|
||||
|
||||
/// Processes the next notification in the queue
|
||||
/// If there are no more notifications, the queue is cleared
|
||||
/// and the processing flag is set to false
|
||||
private func processNextNotification() {
|
||||
guard !isProcessingNotifications, !notificationQueue.isEmpty else { return }
|
||||
isProcessingNotifications = true
|
||||
|
||||
let event = notificationQueue.removeFirst()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.notifyObservers(event: event)
|
||||
self.isProcessingNotifications = false
|
||||
|
||||
// Check if there are more items in the queue
|
||||
if !self.notificationQueue.isEmpty {
|
||||
self.processNextNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the battery information when the manager starts
|
||||
/// - Returns: Current battery information
|
||||
func initializeBatteryInfo() -> BatteryInfo {
|
||||
previousBatteryInfo = getBatteryInfo()
|
||||
guard let batteryInfo = previousBatteryInfo else {
|
||||
return BatteryInfo(
|
||||
isPluggedIn: false,
|
||||
isCharging: false,
|
||||
currentCapacity: 0,
|
||||
maxCapacity: 0,
|
||||
isInLowPowerMode: false,
|
||||
timeToFullCharge: 0
|
||||
)
|
||||
}
|
||||
return batteryInfo
|
||||
}
|
||||
|
||||
/// Get the current battery information
|
||||
/// - Returns: The current battery information
|
||||
private func getBatteryInfo() -> BatteryInfo {
|
||||
do {
|
||||
// Get power source information
|
||||
guard let snapshot = IOPSCopyPowerSourcesInfo()?.takeRetainedValue() else {
|
||||
throw BatteryError.powerSourceUnavailable
|
||||
}
|
||||
|
||||
guard let sources = IOPSCopyPowerSourcesList(snapshot)?.takeRetainedValue() as? [CFTypeRef],
|
||||
!sources.isEmpty else {
|
||||
throw BatteryError.batteryInfoUnavailable("No power sources available")
|
||||
}
|
||||
|
||||
let source = sources.first!
|
||||
|
||||
guard let description = IOPSGetPowerSourceDescription(snapshot, source)?.takeUnretainedValue() as? [String: Any] else {
|
||||
throw BatteryError.batteryInfoUnavailable("Could not get power source description")
|
||||
}
|
||||
|
||||
// Extract required battery parameters with error handling
|
||||
guard let currentCapacity = description[kIOPSCurrentCapacityKey] as? Float else {
|
||||
throw BatteryError.batteryParameterMissing("Current capacity")
|
||||
}
|
||||
|
||||
guard let maxCapacity = description[kIOPSMaxCapacityKey] as? Float else {
|
||||
throw BatteryError.batteryParameterMissing("Max capacity")
|
||||
}
|
||||
|
||||
guard let isCharging = description["Is Charging"] as? Bool else {
|
||||
throw BatteryError.batteryParameterMissing("Charging state")
|
||||
}
|
||||
|
||||
guard let powerSource = description[kIOPSPowerSourceStateKey] as? String else {
|
||||
throw BatteryError.batteryParameterMissing("Power source state")
|
||||
}
|
||||
|
||||
// Create battery info with the extracted parameters
|
||||
var batteryInfo = BatteryInfo(
|
||||
isPluggedIn: powerSource == kIOPSACPowerValue,
|
||||
isCharging: isCharging,
|
||||
currentCapacity: currentCapacity,
|
||||
maxCapacity: maxCapacity,
|
||||
isInLowPowerMode: ProcessInfo.processInfo.isLowPowerModeEnabled,
|
||||
timeToFullCharge: 0
|
||||
)
|
||||
|
||||
// Optional parameters
|
||||
if let timeToFullCharge = description[kIOPSTimeToFullChargeKey] as? Int {
|
||||
batteryInfo.timeToFullCharge = timeToFullCharge
|
||||
}
|
||||
|
||||
return batteryInfo
|
||||
|
||||
} catch BatteryError.powerSourceUnavailable {
|
||||
print("⚠️ Error: Power source information unavailable")
|
||||
return defaultBatteryInfo
|
||||
} catch BatteryError.batteryInfoUnavailable(let reason) {
|
||||
print("⚠️ Error: Battery information unavailable - \(reason)")
|
||||
return defaultBatteryInfo
|
||||
} catch BatteryError.batteryParameterMissing(let parameter) {
|
||||
print("⚠️ Error: Battery parameter missing - \(parameter)")
|
||||
return defaultBatteryInfo
|
||||
} catch {
|
||||
print("⚠️ Error: Unexpected error getting battery info - \(error.localizedDescription)")
|
||||
return defaultBatteryInfo
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds an observer to listen to battery changes
|
||||
/// - Parameter observer: The observer closure to be called on battery events
|
||||
/// - Returns: The ID of the observer for later removal
|
||||
func addObserver(_ observer: @escaping (BatteryEvent) -> Void) -> Int {
|
||||
observers.append(observer)
|
||||
return observers.count - 1
|
||||
}
|
||||
|
||||
/// Removes an observer by its ID
|
||||
/// - Parameter id: The ID of the observer to be removed
|
||||
func removeObserver(byId id: Int) {
|
||||
guard id >= 0 && id < observers.count else { return }
|
||||
observers.remove(at: id)
|
||||
}
|
||||
|
||||
/// Notifies all observers of a battery event
|
||||
/// - Parameter event: The battery event to notify
|
||||
private func notifyObservers(event: BatteryEvent) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
for observer in self.observers {
|
||||
observer(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopMonitoring()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Struct to hold battery information
|
||||
struct BatteryInfo {
|
||||
var isPluggedIn: Bool
|
||||
var isCharging: Bool
|
||||
var currentCapacity: Float
|
||||
var maxCapacity: Float
|
||||
var isInLowPowerMode: Bool
|
||||
var timeToFullCharge: Int
|
||||
}
|
||||
136
DynamicIsland/managers/CalendarManager.swift
Normal file
136
DynamicIsland/managers/CalendarManager.swift
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
//
|
||||
// CalenderManager.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 08/09/24.
|
||||
//
|
||||
|
||||
import EventKit
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
// MARK: - CalendarManager
|
||||
|
||||
class CalendarManager: ObservableObject {
|
||||
@Published var currentWeekStartDate: Date
|
||||
@Published var events: [EKEvent] = []
|
||||
@Published var allCalendars: [EKCalendar] = []
|
||||
private var selectedCalendars: [EKCalendar] = []
|
||||
private let eventStore = EKEventStore()
|
||||
@Published var authorizationStatus: EKAuthorizationStatus = .notDetermined
|
||||
|
||||
init() {
|
||||
self.currentWeekStartDate = CalendarManager.startOfDay(Date())
|
||||
if(Defaults[.showCalendar]) {
|
||||
checkCalendarAuthorization()
|
||||
}
|
||||
}
|
||||
|
||||
func checkCalendarAuthorization() {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
DispatchQueue.main.async {
|
||||
print("📅 Current calendar authorization status: \(status)")
|
||||
self.authorizationStatus = status
|
||||
}
|
||||
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
requestCalendarAccess()
|
||||
case .restricted, .denied:
|
||||
// Handle the case where the user has denied or restricted access
|
||||
NSLog("Calendar access denied or restricted")
|
||||
case .fullAccess:
|
||||
NSLog("Full access")
|
||||
self.allCalendars = eventStore.calendars(for: .event)
|
||||
updateSelectedCalendars()
|
||||
fetchEvents()
|
||||
case .writeOnly:
|
||||
NSLog("Write only")
|
||||
@unknown default:
|
||||
print("Unknown authorization status")
|
||||
}
|
||||
}
|
||||
|
||||
func requestCalendarAccess() {
|
||||
eventStore.requestFullAccessToEvents { [weak self] granted, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
print("📅 Calendar access error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
self?.authorizationStatus = granted ? .fullAccess : .denied
|
||||
if granted {
|
||||
print("📅 Calendar access granted")
|
||||
self?.fetchEvents()
|
||||
} else {
|
||||
print("📅 Calendar access denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateSelectedCalendars() {
|
||||
selectedCalendars = allCalendars.filter { getCalendarSelected($0) }
|
||||
}
|
||||
|
||||
func getCalendarSelected(_ calendar: EKCalendar) -> Bool {
|
||||
switch Defaults[.calendarSelectionState] {
|
||||
case .all:
|
||||
return true
|
||||
case .selected(let identifiers):
|
||||
return identifiers.contains(calendar.calendarIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
func setCalendarSelected(_ calendar: EKCalendar, isSelected: Bool) {
|
||||
var selectionState = Defaults[.calendarSelectionState]
|
||||
|
||||
switch selectionState {
|
||||
case .all:
|
||||
if !isSelected {
|
||||
let identifiers = Set(allCalendars.map { $0.calendarIdentifier }).subtracting([calendar.calendarIdentifier])
|
||||
selectionState = .selected(identifiers)
|
||||
}
|
||||
|
||||
case .selected(var identifiers):
|
||||
if isSelected {
|
||||
identifiers.insert(calendar.calendarIdentifier)
|
||||
} else {
|
||||
identifiers.remove(calendar.calendarIdentifier)
|
||||
}
|
||||
|
||||
selectionState = identifiers.count == allCalendars.count ? .all : .selected(identifiers)
|
||||
}
|
||||
|
||||
Defaults[.calendarSelectionState] = selectionState
|
||||
fetchEvents()
|
||||
}
|
||||
|
||||
func fetchEvents() {
|
||||
guard !self.selectedCalendars.isEmpty else {
|
||||
DispatchQueue.main.async {
|
||||
self.events = []
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let endOfWeek = Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!
|
||||
let predicate = eventStore.predicateForEvents(withStart: currentWeekStartDate, end: endOfWeek, calendars: self.selectedCalendars)
|
||||
let fetchedEvents = eventStore.events(matching: predicate)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.events = fetchedEvents.sorted { $0.startDate < $1.startDate }
|
||||
print("📅 Fetched \(self.events.count) calendar events")
|
||||
}
|
||||
}
|
||||
|
||||
static func startOfDay(_ date: Date) -> Date {
|
||||
return Calendar.current.startOfDay(for: date)
|
||||
}
|
||||
|
||||
func updateCurrentDate(_ date: Date) {
|
||||
print("📅 Updating current date to: \(date)")
|
||||
currentWeekStartDate = Calendar.current.startOfDay(for: date)
|
||||
fetchEvents()
|
||||
}
|
||||
}
|
||||
59
DynamicIsland/managers/DynamicIslandExtensionManager.swift
Normal file
59
DynamicIsland/managers/DynamicIslandExtensionManager.swift
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// DynamicIslandExtensionManager.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 04/08/24.
|
||||
// BoringExtensionManager.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 07/09/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
var clipboardExtension: String = "dynamicisland.TheDynamicClipboard"
|
||||
var hudExtension: String = "dynamicisland.TheDynamicHUDs"
|
||||
var downloadManagerExtension: String = "dynamicisland.TheDynamicDownloadManager"
|
||||
|
||||
struct Extension: Identifiable, Hashable {
|
||||
var id = UUID()
|
||||
var name: String
|
||||
var bundleIdentifier: String
|
||||
var status: StatusModel = .enabled
|
||||
}
|
||||
|
||||
enum StatusModel {
|
||||
case disabled
|
||||
case enabled
|
||||
}
|
||||
|
||||
class DynamicIslandExtensionManager: ObservableObject {
|
||||
|
||||
@Published var installedExtensions: [Extension] = [] {
|
||||
didSet {
|
||||
print("Extensions installed: \(installedExtensions)")
|
||||
}
|
||||
}
|
||||
|
||||
var extensions = [
|
||||
clipboardExtension,
|
||||
hudExtension
|
||||
]
|
||||
|
||||
init() {
|
||||
checkIfExtensionsAreInstalled()
|
||||
|
||||
DistributedNotificationCenter.default().addObserver(self, selector: #selector(checkIfExtensionsAreInstalled), name: NSNotification.Name("NSWorkspaceDidLaunchApplicationNotification"), object: nil)
|
||||
}
|
||||
|
||||
@objc func checkIfExtensionsAreInstalled() {
|
||||
installedExtensions = []
|
||||
for extensionName in extensions {
|
||||
if NSWorkspace.shared.urlForApplication(withBundleIdentifier: extensionName) != nil {
|
||||
let ext = Extension(name: extensionName.components(separatedBy: ".").last ?? extensionName, bundleIdentifier: extensionName)
|
||||
installedExtensions.append(ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
397
DynamicIsland/managers/MusicManager.swift
Normal file
397
DynamicIsland/managers/MusicManager.swift
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
//
|
||||
// MusicManager.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 03/08/24.
|
||||
//
|
||||
import AppKit
|
||||
import Combine
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
let defaultImage: NSImage = .init(
|
||||
systemSymbolName: "heart.fill",
|
||||
accessibilityDescription: "Album Art"
|
||||
)!
|
||||
|
||||
class MusicManager: ObservableObject {
|
||||
// MARK: - Properties
|
||||
static let shared = MusicManager()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var controllerCancellables = Set<AnyCancellable>()
|
||||
private var debounceToggle: DispatchWorkItem?
|
||||
|
||||
// Helper to check if macOS is too new for NowPlayingController
|
||||
public var isNowPlayingDeprecated: Bool {
|
||||
if #available(macOS 15.4, *) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Active controller
|
||||
private var activeController: (any MediaControllerProtocol)?
|
||||
|
||||
// Published properties for UI
|
||||
@Published var songTitle: String = "I'm Handsome"
|
||||
@Published var artistName: String = "Me"
|
||||
@Published var albumArt: NSImage = defaultImage
|
||||
@Published var isPlaying = false
|
||||
@Published var album: String = "Self Love"
|
||||
@Published var lastUpdated: Date = .distantPast
|
||||
@Published var ignoreLastUpdated = true
|
||||
@Published var isPlayerIdle: Bool = true
|
||||
@Published var animations: BoringAnimations = .init()
|
||||
@Published var avgColor: NSColor = .white
|
||||
@Published var bundleIdentifier: String? = nil
|
||||
@Published var songDuration: TimeInterval = 0
|
||||
@Published var elapsedTime: TimeInterval = 0
|
||||
@Published var timestampDate: Date = .init()
|
||||
@Published var playbackRate: Double = 1
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@Published var usingAppIconForArtwork: Bool = false
|
||||
|
||||
private var artworkData: Data? = nil
|
||||
|
||||
@Published var isFlipping: Bool = false
|
||||
private var flipWorkItem: DispatchWorkItem?
|
||||
|
||||
@Published var isTransitioning: Bool = false
|
||||
private var transitionWorkItem: DispatchWorkItem?
|
||||
|
||||
// MARK: - Initialization
|
||||
init() {
|
||||
// Listen for changes to the default controller preference
|
||||
NotificationCenter.default.publisher(for: Notification.Name.mediaControllerChanged)
|
||||
.sink { [weak self] _ in
|
||||
self?.setActiveControllerBasedOnPreference()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Initialize the active controller
|
||||
setActiveControllerBasedOnPreference()
|
||||
}
|
||||
|
||||
deinit {
|
||||
debounceToggle?.cancel()
|
||||
cancellables.removeAll()
|
||||
controllerCancellables.removeAll()
|
||||
flipWorkItem?.cancel()
|
||||
transitionWorkItem?.cancel()
|
||||
|
||||
// Release active controller
|
||||
activeController = nil
|
||||
}
|
||||
|
||||
// MARK: - Setup Methods
|
||||
private func createController(for type: MediaControllerType) -> (any MediaControllerProtocol)? {
|
||||
// Cleanup previous controller
|
||||
if let _ = activeController {
|
||||
controllerCancellables.removeAll()
|
||||
activeController = nil
|
||||
}
|
||||
|
||||
let newController: (any MediaControllerProtocol)?
|
||||
|
||||
switch type {
|
||||
case .nowPlaying:
|
||||
// Only create NowPlayingController if not deprecated on this macOS version
|
||||
if !self.isNowPlayingDeprecated {
|
||||
ignoreLastUpdated = false
|
||||
newController = NowPlayingController()
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
case .appleMusic:
|
||||
ignoreLastUpdated = true
|
||||
newController = AppleMusicController()
|
||||
case .spotify:
|
||||
ignoreLastUpdated = true
|
||||
newController = SpotifyController()
|
||||
case .youtubeMusic:
|
||||
ignoreLastUpdated = true
|
||||
newController = YouTubeMusicController()
|
||||
}
|
||||
|
||||
// Set up state observation for the new controller
|
||||
if let controller = newController {
|
||||
controller.playbackStatePublisher
|
||||
.sink { [weak self] state in
|
||||
guard let self = self,
|
||||
self.activeController === controller else { return }
|
||||
self.updateFromPlaybackState(state)
|
||||
}
|
||||
.store(in: &controllerCancellables)
|
||||
}
|
||||
|
||||
return newController
|
||||
}
|
||||
|
||||
private func setActiveControllerBasedOnPreference() {
|
||||
let preferredType = Defaults[.mediaController]
|
||||
print("Preferred Media Controller: \(preferredType)")
|
||||
|
||||
// If NowPlaying is deprecated but that's the preference, use Apple Music instead
|
||||
let controllerType = (self.isNowPlayingDeprecated && preferredType == .nowPlaying)
|
||||
? .appleMusic
|
||||
: preferredType
|
||||
|
||||
if let controller = createController(for: controllerType) {
|
||||
setActiveController(controller)
|
||||
} else if controllerType != .appleMusic, let fallbackController = createController(for: .appleMusic) {
|
||||
// Fallback to Apple Music if preferred controller couldn't be created
|
||||
setActiveController(fallbackController)
|
||||
}
|
||||
}
|
||||
|
||||
private func setActiveController(_ controller: any MediaControllerProtocol) {
|
||||
// Transition animation when changing controllers
|
||||
transitionWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
self?.isTransitioning = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
self?.isTransitioning = false
|
||||
}
|
||||
}
|
||||
transitionWorkItem = workItem
|
||||
DispatchQueue.main.async(execute: workItem)
|
||||
|
||||
// Set new active controller
|
||||
activeController = controller
|
||||
|
||||
// Get current state from active controller
|
||||
if let state = Mirror(reflecting: controller).children.first(where: { $0.label == "playbackState" })?.value as? PlaybackState {
|
||||
updateFromPlaybackState(state)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Update Methods
|
||||
private func updateFromPlaybackState(_ state: PlaybackState) {
|
||||
// Create a batch of updates to apply together
|
||||
let updateBatch = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Check for playback state changes (playing/paused)
|
||||
if state.isPlaying != self.isPlaying {
|
||||
self.lastUpdated = Date()
|
||||
withAnimation(.smooth) {
|
||||
self.isPlaying = state.isPlaying
|
||||
self.updateIdleState(state: state.isPlaying)
|
||||
}
|
||||
|
||||
if state.isPlaying && !state.title.isEmpty && !state.artist.isEmpty {
|
||||
self.updateSneakPeek()
|
||||
}
|
||||
}
|
||||
|
||||
// Check for changes in track metadata
|
||||
let titleChanged = state.title != self.songTitle
|
||||
let artistChanged = state.artist != self.artistName
|
||||
let albumChanged = state.album != self.album
|
||||
|
||||
// Check for artwork changes
|
||||
let artworkChanged = state.artwork != nil && state.artwork != self.artworkData
|
||||
let hasContentChange = titleChanged || artistChanged || albumChanged || artworkChanged
|
||||
|
||||
// Handle artwork and visual transitions for changed content
|
||||
if hasContentChange {
|
||||
self.triggerFlipAnimation()
|
||||
|
||||
if artworkChanged, let artwork = state.artwork {
|
||||
self.updateArtwork(artwork)
|
||||
} else if hasContentChange && state.artwork == nil {
|
||||
// Try to use app icon if no artwork but track changed
|
||||
if let appIconImage = AppIconAsNSImage(for: state.bundleIdentifier) {
|
||||
self.usingAppIconForArtwork = true
|
||||
self.updateAlbumArt(newAlbumArt: appIconImage)
|
||||
}
|
||||
}
|
||||
self.artworkData = state.artwork
|
||||
|
||||
// Only update sneak peek if there's actual content and something changed
|
||||
if !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
|
||||
self.updateSneakPeek()
|
||||
}
|
||||
}
|
||||
|
||||
let timeChanged = state.currentTime != self.elapsedTime
|
||||
let durationChanged = state.duration != self.songDuration
|
||||
let playbackRateChanged = state.playbackRate != self.playbackRate
|
||||
|
||||
if titleChanged {
|
||||
self.songTitle = state.title
|
||||
}
|
||||
|
||||
if artistChanged {
|
||||
self.artistName = state.artist
|
||||
}
|
||||
|
||||
if albumChanged {
|
||||
self.album = state.album
|
||||
}
|
||||
|
||||
if timeChanged {
|
||||
self.elapsedTime = state.currentTime
|
||||
}
|
||||
|
||||
if durationChanged {
|
||||
self.songDuration = state.duration
|
||||
}
|
||||
|
||||
if playbackRateChanged {
|
||||
self.playbackRate = state.playbackRate
|
||||
}
|
||||
|
||||
if state.bundleIdentifier != self.bundleIdentifier {
|
||||
self.bundleIdentifier = state.bundleIdentifier
|
||||
}
|
||||
|
||||
self.timestampDate = state.lastUpdated
|
||||
}
|
||||
|
||||
// Execute the batch update on the main thread
|
||||
DispatchQueue.main.async(execute: updateBatch)
|
||||
}
|
||||
|
||||
private func triggerFlipAnimation() {
|
||||
// Cancel any existing animation
|
||||
flipWorkItem?.cancel()
|
||||
|
||||
// Create a new animation
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
self?.isFlipping = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
self?.isFlipping = false
|
||||
}
|
||||
}
|
||||
|
||||
flipWorkItem = workItem
|
||||
DispatchQueue.main.async(execute: workItem)
|
||||
}
|
||||
|
||||
private func updateArtwork(_ artworkData: Data) {
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let artworkImage = NSImage(data: artworkData) {
|
||||
DispatchQueue.main.async {
|
||||
self.usingAppIconForArtwork = false
|
||||
self.updateAlbumArt(newAlbumArt: artworkImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateIdleState(state: Bool) {
|
||||
if state {
|
||||
isPlayerIdle = false
|
||||
debounceToggle?.cancel()
|
||||
} else {
|
||||
debounceToggle = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.lastUpdated.timeIntervalSinceNow < -Defaults[.waitInterval] {
|
||||
withAnimation {
|
||||
self.isPlayerIdle = !self.isPlaying
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(
|
||||
deadline: .now() + Defaults[.waitInterval], execute: debounceToggle!)
|
||||
}
|
||||
}
|
||||
|
||||
private var workItem: DispatchWorkItem?
|
||||
|
||||
func updateAlbumArt(newAlbumArt: NSImage) {
|
||||
workItem?.cancel()
|
||||
workItem = DispatchWorkItem { [weak self] in
|
||||
withAnimation(.smooth) {
|
||||
self?.albumArt = newAlbumArt
|
||||
if Defaults[.coloredSpectrogram] {
|
||||
self?.calculateAverageColor()
|
||||
}
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem!)
|
||||
}
|
||||
|
||||
func calculateAverageColor() {
|
||||
albumArt.averageColor { [weak self] color in
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(.smooth) {
|
||||
self?.avgColor = color ?? .white
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSneakPeek() {
|
||||
if isPlaying && Defaults[.enableSneakPeek] {
|
||||
if Defaults[.sneakPeekStyles] == .standard {
|
||||
coordinator.toggleSneakPeek(status: true, type: .music)
|
||||
} else {
|
||||
coordinator.toggleExpandingView(status: true, type: .music)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Methods for controlling playback
|
||||
func playPause() {
|
||||
activeController?.togglePlay()
|
||||
}
|
||||
|
||||
func play() {
|
||||
activeController?.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
activeController?.pause()
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
activeController?.togglePlay()
|
||||
}
|
||||
|
||||
func nextTrack() {
|
||||
activeController?.nextTrack()
|
||||
}
|
||||
|
||||
func previousTrack() {
|
||||
activeController?.previousTrack()
|
||||
}
|
||||
|
||||
func seek(to position: TimeInterval) {
|
||||
activeController?.seek(to: position)
|
||||
}
|
||||
|
||||
func openMusicApp() {
|
||||
guard let bundleID = bundleIdentifier else {
|
||||
print("Error: appBundleIdentifier is nil")
|
||||
return
|
||||
}
|
||||
|
||||
let workspace = NSWorkspace.shared
|
||||
if let appURL = workspace.urlForApplication(withBundleIdentifier: bundleID) {
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
workspace.openApplication(at: appURL, configuration: configuration) { (app, error) in
|
||||
if let error = error {
|
||||
print("Failed to launch app with bundle ID: \(bundleID), error: \(error)")
|
||||
} else {
|
||||
print("Launched app with bundle ID: \(bundleID)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print("Failed to find app with bundle ID: \(bundleID)")
|
||||
}
|
||||
}
|
||||
|
||||
func forceUpdate() {
|
||||
// Request immediate update from the active controller
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if self?.activeController?.isActive() == true {
|
||||
self?.activeController?.updatePlaybackInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
DynamicIsland/managers/NotchSpaceManager.swift
Normal file
19
DynamicIsland/managers/NotchSpaceManager.swift
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
//
|
||||
// NotchSpaceManager.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander Greco on 2024-10-27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class NotchSpaceManager {
|
||||
static let shared = NotchSpaceManager()
|
||||
let notchSpace: CGSSpace
|
||||
private var eventTap: CFMachPort?
|
||||
private var runLoopSource: CFRunLoopSource?
|
||||
|
||||
private init() {
|
||||
notchSpace = CGSSpace(level: 2147483647) // Max level
|
||||
}
|
||||
}
|
||||
312
DynamicIsland/managers/WebcamManager.swift
Normal file
312
DynamicIsland/managers/WebcamManager.swift
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
//
|
||||
// WebcamManager.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 19/08/24.
|
||||
//
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
class WebcamManager: NSObject, ObservableObject {
|
||||
@Published var previewLayer: AVCaptureVideoPreviewLayer? {
|
||||
didSet {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
private var captureSession: AVCaptureSession?
|
||||
@Published var isSessionRunning: Bool = false {
|
||||
didSet {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var authorizationStatus: AVAuthorizationStatus = .notDetermined {
|
||||
didSet {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var cameraAvailable: Bool = false {
|
||||
didSet {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
private let sessionQueue = DispatchQueue(label: "BoringNotch.WebcamManager.SessionQueue", qos: .userInitiated)
|
||||
|
||||
private var isCleaningUp: Bool = false
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
enum WebcamError: Error, LocalizedError {
|
||||
case deviceUnavailable
|
||||
case accessDenied
|
||||
case configurationFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .deviceUnavailable:
|
||||
return "No camera devices available"
|
||||
case .accessDenied:
|
||||
return "Camera access denied"
|
||||
case .configurationFailed(let message):
|
||||
return "Camera configuration failed: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
checkAndRequestVideoAuthorization()
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(deviceWasDisconnected), name: .AVCaptureDeviceWasDisconnected, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(deviceWasConnected), name: .AVCaptureDeviceWasConnected, object: nil)
|
||||
checkCameraAvailability()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
if let session = captureSession {
|
||||
if session.isRunning {
|
||||
session.stopRunning()
|
||||
}
|
||||
}
|
||||
captureSession = nil
|
||||
|
||||
previewLayer = nil
|
||||
}
|
||||
|
||||
// MARK: - Camera Management
|
||||
|
||||
/// Checks current authorization status and requests access if needed
|
||||
func checkAndRequestVideoAuthorization() {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
DispatchQueue.main.async {
|
||||
self.authorizationStatus = status
|
||||
}
|
||||
|
||||
switch status {
|
||||
case .authorized:
|
||||
checkCameraAvailability() // Check availability if authorized
|
||||
case .notDetermined:
|
||||
requestVideoAccess()
|
||||
case .denied, .restricted:
|
||||
NSLog("Camera access denied or restricted")
|
||||
@unknown default:
|
||||
NSLog("Unknown authorization status")
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests access to the camera
|
||||
private func requestVideoAccess() {
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
self?.authorizationStatus = granted ? .authorized : .denied
|
||||
if granted {
|
||||
self?.checkCameraAvailability() // Check availability if access granted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if any camera devices are available and sets up capture session if needed
|
||||
func checkCameraAvailability() {
|
||||
let availableDevices = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.external, .builtInWideAngleCamera],
|
||||
mediaType: .video,
|
||||
position: .unspecified
|
||||
).devices
|
||||
|
||||
let hasAvailableDevices = !availableDevices.isEmpty
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.cameraAvailable = hasAvailableDevices
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up the capture session with a completion handler
|
||||
private func setupCaptureSession(completion: @escaping (Bool) -> Void) {
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self = self else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up any existing session before creating a new one
|
||||
self.cleanupExistingSession()
|
||||
|
||||
let session = AVCaptureSession()
|
||||
|
||||
do {
|
||||
// Get available devices and prefer external camera if available
|
||||
let discoverySession = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.external, .builtInWideAngleCamera],
|
||||
mediaType: .video,
|
||||
position: .unspecified
|
||||
)
|
||||
|
||||
guard let videoDevice = discoverySession.devices.first else {
|
||||
NSLog("No video devices available")
|
||||
DispatchQueue.main.async {
|
||||
self.isSessionRunning = false
|
||||
self.cameraAvailable = false
|
||||
}
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
NSLog("Using camera: \(videoDevice.localizedName)")
|
||||
|
||||
// Lock device for configuration
|
||||
try videoDevice.lockForConfiguration()
|
||||
defer { videoDevice.unlockForConfiguration() }
|
||||
|
||||
let videoInput = try AVCaptureDeviceInput(device: videoDevice)
|
||||
guard session.canAddInput(videoInput) else {
|
||||
throw NSError(domain: "BoringNotch.WebcamManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Cannot add video input"])
|
||||
}
|
||||
|
||||
session.beginConfiguration()
|
||||
session.sessionPreset = .high
|
||||
session.addInput(videoInput)
|
||||
|
||||
let videoOutput = AVCaptureVideoDataOutput()
|
||||
videoOutput.setSampleBufferDelegate(nil, queue: nil)
|
||||
if session.canAddOutput(videoOutput) {
|
||||
session.addOutput(videoOutput)
|
||||
}
|
||||
session.commitConfiguration()
|
||||
|
||||
self.captureSession = session
|
||||
|
||||
// Create and set up preview layer on main thread
|
||||
DispatchQueue.main.async {
|
||||
self.cameraAvailable = true
|
||||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
self.previewLayer = previewLayer
|
||||
|
||||
// Setup is complete, let the caller know
|
||||
completion(true)
|
||||
}
|
||||
|
||||
NSLog("Capture session setup completed successfully")
|
||||
} catch {
|
||||
NSLog("Failed to setup capture session: \(error.localizedDescription)")
|
||||
DispatchQueue.main.async {
|
||||
self.isSessionRunning = false
|
||||
self.cameraAvailable = false
|
||||
self.previewLayer = nil
|
||||
}
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleans up an existing capture session, removing all inputs and outputs
|
||||
private func cleanupExistingSession() {
|
||||
if let existingSession = self.captureSession {
|
||||
// First stop the session if running
|
||||
if existingSession.isRunning {
|
||||
existingSession.stopRunning()
|
||||
}
|
||||
|
||||
// Then perform configuration cleanup
|
||||
existingSession.beginConfiguration()
|
||||
|
||||
// Remove all inputs and outputs
|
||||
for input in existingSession.inputs {
|
||||
existingSession.removeInput(input)
|
||||
}
|
||||
for output in existingSession.outputs {
|
||||
existingSession.removeOutput(output)
|
||||
}
|
||||
|
||||
existingSession.commitConfiguration()
|
||||
self.captureSession = nil
|
||||
|
||||
// Clear preview layer on main thread
|
||||
DispatchQueue.main.async {
|
||||
self.previewLayer = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func deviceWasDisconnected(notification: Notification) {
|
||||
NSLog("Camera device was disconnected")
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopSession()
|
||||
DispatchQueue.main.async {
|
||||
self.cameraAvailable = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func deviceWasConnected(notification: Notification) {
|
||||
NSLog("Camera device was connected")
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.checkCameraAvailability()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSessionState() {
|
||||
let isRunning = self.captureSession?.isRunning ?? false
|
||||
DispatchQueue.main.async {
|
||||
self.isSessionRunning = isRunning
|
||||
}
|
||||
}
|
||||
|
||||
func startSession() {
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// If no session exists, create new session
|
||||
if self.captureSession == nil {
|
||||
self.setupCaptureSession { success in
|
||||
if success {
|
||||
// Only start the session if setup was successful
|
||||
self.startRunningCaptureSession()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Session already exists, just start it
|
||||
self.startRunningCaptureSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startRunningCaptureSession() {
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self = self, let session = self.captureSession, !session.isRunning else {
|
||||
return
|
||||
}
|
||||
|
||||
session.startRunning()
|
||||
|
||||
// Update state on main thread
|
||||
self.updateSessionState()
|
||||
|
||||
NSLog("Capture session started successfully")
|
||||
}
|
||||
}
|
||||
|
||||
func stopSession() {
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Update state to indicate we're stopping
|
||||
DispatchQueue.main.async {
|
||||
self.isSessionRunning = false
|
||||
}
|
||||
|
||||
self.cleanupExistingSession()
|
||||
|
||||
NSLog("Capture session stopped and cleaned up")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue