vOOice/VoiceInk/MediaController.swift

220 lines
8.0 KiB
Swift

import Foundation
import AppKit
import SwiftUI
import os
import Combine
/// Controls media playback detection and management during recording
class MediaController: ObservableObject {
static let shared = MediaController()
private var mediaRemoteHandle: UnsafeMutableRawPointer?
private var mrNowPlayingIsPlaying: MRNowPlayingIsPlayingFunc?
private var didPauseMedia = false
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "MediaController")
@Published var isMediaPauseEnabled: Bool = UserDefaults.standard.bool(forKey: "isMediaPauseEnabled") {
didSet {
UserDefaults.standard.set(isMediaPauseEnabled, forKey: "isMediaPauseEnabled")
}
}
// Define function pointer types for MediaRemote functions
typealias MRNowPlayingIsPlayingFunc = @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void
typealias MRMediaRemoteCommandInfoFunc = @convention(c) () -> Void
// Additional function pointers for direct control
private var mrSendCommand: (@convention(c) (Int, [String: Any]?) -> Bool)?
// MediaRemote command constants
private let kMRPlay = 0
private let kMRPause = 1
private let kMRTogglePlayPause = 2
private init() {
// Set default if not already set
if !UserDefaults.standard.contains(key: "isMediaPauseEnabled") {
UserDefaults.standard.set(true, forKey: "isMediaPauseEnabled")
}
setupMediaRemote()
}
private func setupMediaRemote() {
// Open the private framework
guard let handle = dlopen("/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote", RTLD_NOW) else {
logger.error("Unable to open MediaRemote framework")
return
}
mediaRemoteHandle = handle
// Get pointer for the "is playing" function
guard let playingPtr = dlsym(handle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying") else {
logger.error("Unable to find MRMediaRemoteGetNowPlayingApplicationIsPlaying function")
dlclose(handle)
mediaRemoteHandle = nil
return
}
mrNowPlayingIsPlaying = unsafeBitCast(playingPtr, to: MRNowPlayingIsPlayingFunc.self)
// Get the send command function pointer
if let sendCommandPtr = dlsym(handle, "MRMediaRemoteSendCommand") {
mrSendCommand = unsafeBitCast(sendCommandPtr, to: (@convention(c) (Int, [String: Any]?) -> Bool).self)
logger.info("Successfully loaded MRMediaRemoteSendCommand function")
} else {
logger.warning("Could not find MRMediaRemoteSendCommand function, fallback to key simulation")
}
logger.info("MediaRemote framework initialized successfully")
}
deinit {
if let handle = mediaRemoteHandle {
dlclose(handle)
}
}
/// Checks if media is currently playing on the system
func isMediaPlaying() async -> Bool {
guard isMediaPauseEnabled, let mrNowPlayingIsPlaying = mrNowPlayingIsPlaying else {
return false
}
return await withCheckedContinuation { continuation in
mrNowPlayingIsPlaying(DispatchQueue.main) { isPlaying in
continuation.resume(returning: isPlaying)
}
}
}
/// Pauses media if it's currently playing
func pauseMediaIfPlaying() async -> Bool {
guard isMediaPauseEnabled else {
logger.info("Media pause feature is disabled")
return false
}
if await isMediaPlaying() {
logger.info("Media is playing, pausing it for recording")
await MainActor.run {
// Try direct command first, then fall back to key simulation
if !sendMediaCommand(command: kMRPause) {
sendMediaKey()
}
}
didPauseMedia = true
return true
}
logger.info("No media playing, no need to pause")
return false
}
/// Resumes media if it was paused by this controller
func resumeMediaIfPaused() async {
guard isMediaPauseEnabled, didPauseMedia else {
return
}
logger.info("Resuming previously paused media")
await MainActor.run {
// Try direct command first, then fall back to key simulation
if !sendMediaCommand(command: kMRPlay) {
sendMediaKey()
}
}
didPauseMedia = false
}
/// Sends a media command using the MediaRemote framework
private func sendMediaCommand(command: Int) -> Bool {
guard let sendCommand = mrSendCommand else {
logger.warning("MRMediaRemoteSendCommand not available")
return false
}
let result = sendCommand(command, nil)
logger.info("Sent media command \(command) with result: \(result)")
return result
}
/// Simulates a media key press (Play/Pause) by posting a system-defined NSEvent
private func sendMediaKey() {
let NX_KEYTYPE_PLAY: UInt32 = 16
let keys = [NX_KEYTYPE_PLAY]
logger.info("Simulating media key press using NSEvent")
for key in keys {
func postKeyEvent(down: Bool) {
let flags: NSEvent.ModifierFlags = down ? .init(rawValue: 0xA00) : .init(rawValue: 0xB00)
let data1 = Int((key << 16) | (down ? 0xA << 8 : 0xB << 8))
if let event = NSEvent.otherEvent(
with: .systemDefined,
location: .zero,
modifierFlags: flags,
timestamp: 0,
windowNumber: 0,
context: nil,
subtype: 8,
data1: data1,
data2: -1
) {
// Attempt to post directly to all applications
let didPost = event.cgEvent?.post(tap: .cghidEventTap) != nil
logger.info("Posted key event (down: \(down)) with result: \(didPost ? "success" : "failure")")
// Add a small delay to ensure the event is processed
usleep(10000) // 10ms delay
}
}
// Perform the key down/up sequence
postKeyEvent(down: true)
postKeyEvent(down: false)
// Allow some time for the system to process the key event
usleep(50000) // 50ms delay
}
// As a fallback, try to use CGEvent directly
createAndPostPlayPauseEvent()
}
/// Creates and posts a CGEvent for media control as a fallback method
private func createAndPostPlayPauseEvent() {
logger.info("Attempting fallback CGEvent for media control")
// Media keys as defined in IOKit
let NX_KEYTYPE_PLAY: Int64 = 16
// Create a CGEvent for the media key
guard let source = CGEventSource(stateID: .hidSystemState) else {
logger.error("Failed to create CGEventSource")
return
}
if let keyDownEvent = CGEvent(keyboardEventSource: source, virtualKey: UInt16(NX_KEYTYPE_PLAY), keyDown: true) {
keyDownEvent.flags = .init(rawValue: 0xA00)
keyDownEvent.post(tap: .cghidEventTap)
logger.info("Posted play/pause key down event")
// Small delay between down and up events
usleep(10000) // 10ms
if let keyUpEvent = CGEvent(keyboardEventSource: source, virtualKey: UInt16(NX_KEYTYPE_PLAY), keyDown: false) {
keyUpEvent.flags = .init(rawValue: 0xB00)
keyUpEvent.post(tap: .cghidEventTap)
logger.info("Posted play/pause key up event")
}
}
}
}
extension UserDefaults {
func contains(key: String) -> Bool {
return object(forKey: key) != nil
}
}