diff --git a/Quality.xcodeproj/project.pbxproj b/Quality.xcodeproj/project.pbxproj index c2e0858..cdb18ad 100644 --- a/Quality.xcodeproj/project.pbxproj +++ b/Quality.xcodeproj/project.pbxproj @@ -25,6 +25,8 @@ 1293436B28131591002E19A8 /* CurrentUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1293436A28131591002E19A8 /* CurrentUser.swift */; }; 12AFF5C12811AD40001CC6ED /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12AFF5C02811AD40001CC6ED /* AppDelegate.swift */; }; 12F1AA572868639A006C1AD8 /* DeviceMenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F1AA562868639A006C1AD8 /* DeviceMenuItem.swift */; }; + BF0C90C82B20BFD6002F99C9 /* LosslessSwitcher.sdef in Resources */ = {isa = PBXBuildFile; fileRef = BF0C90C72B20BFD6002F99C9 /* LosslessSwitcher.sdef */; }; + BF0C90CA2B20C163002F99C9 /* ScriptableApplicationCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C90C92B20C163002F99C9 /* ScriptableApplicationCommand.swift */; }; BF7E0D09296336DA009FFEEC /* AudioStreamBasicDescription+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7E0D08296336DA009FFEEC /* AudioStreamBasicDescription+Equatable.swift */; }; /* End PBXBuildFile section */ @@ -47,6 +49,8 @@ 1293436A28131591002E19A8 /* CurrentUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentUser.swift; sourceTree = ""; }; 12AFF5C02811AD40001CC6ED /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 12F1AA562868639A006C1AD8 /* DeviceMenuItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMenuItem.swift; sourceTree = ""; }; + BF0C90C72B20BFD6002F99C9 /* LosslessSwitcher.sdef */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = LosslessSwitcher.sdef; sourceTree = ""; }; + BF0C90C92B20C163002F99C9 /* ScriptableApplicationCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptableApplicationCommand.swift; sourceTree = ""; }; BF7E0D08296336DA009FFEEC /* AudioStreamBasicDescription+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AudioStreamBasicDescription+Equatable.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -110,6 +114,8 @@ 127C972C281FCF000087313B /* AppVersion.swift */, 12F1AA562868639A006C1AD8 /* DeviceMenuItem.swift */, BF7E0D08296336DA009FFEEC /* AudioStreamBasicDescription+Equatable.swift */, + BF0C90C92B20C163002F99C9 /* ScriptableApplicationCommand.swift */, + BF0C90C72B20BFD6002F99C9 /* LosslessSwitcher.sdef */, ); path = Quality; sourceTree = ""; @@ -191,6 +197,7 @@ buildActionMask = 2147483647; files = ( 1272AA9F280DBB4B00FD72BA /* Preview Assets.xcassets in Resources */, + BF0C90C82B20BFD6002F99C9 /* LosslessSwitcher.sdef in Resources */, 1272AA9C280DBB4B00FD72BA /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -203,6 +210,7 @@ buildActionMask = 2147483647; files = ( 12AFF5C12811AD40001CC6ED /* AppDelegate.swift in Sources */, + BF0C90CA2B20C163002F99C9 /* ScriptableApplicationCommand.swift in Sources */, 1254A79C2813FB9400241107 /* Defaults.swift in Sources */, BF7E0D09296336DA009FFEEC /* AudioStreamBasicDescription+Equatable.swift in Sources */, 1234F50E281E8F07007EC9F5 /* MediaTrack.swift in Sources */, @@ -342,7 +350,7 @@ CODE_SIGN_ENTITLEMENTS = Quality/Quality.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_ASSET_PATHS = "\"Quality/Preview Content\""; DEVELOPMENT_TEAM = 3X69W4AQD6; ENABLE_HARDENED_RUNTIME = YES; @@ -376,7 +384,7 @@ CODE_SIGN_ENTITLEMENTS = Quality/Quality.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_ASSET_PATHS = "\"Quality/Preview Content\""; DEVELOPMENT_TEAM = 3X69W4AQD6; ENABLE_HARDENED_RUNTIME = YES; diff --git a/Quality/AppDelegate.swift b/Quality/AppDelegate.swift index 6be0a8b..7abf513 100644 --- a/Quality/AppDelegate.swift +++ b/Quality/AppDelegate.swift @@ -15,13 +15,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { // https://stackoverflow.com/a/66160164 static private(set) var instance: AppDelegate! = nil - private var outputDevices: OutputDevices! + var outputDevices: OutputDevices! private let defaults = Defaults.shared private var mrController: MediaRemoteController! private var devicesMenu: NSMenu! var statusItem: NSStatusItem? var cancellable: AnyCancellable? + + var currentScriptSelectionMenuItem: NSMenuItem? private var _statusItemTitle = "Loading..." var statusItemTitle: String { @@ -62,6 +64,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { checkPermissions() let menu = NSMenu() + + menu.delegate = self let sampleRateView = ContentView().environmentObject(outputDevices) let view = NSHostingView(rootView: sampleRateView) @@ -96,6 +100,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { aboutItem.submenu?.addItem(buildItem) menu.addItem(aboutItem) + let scriptMenu = NSMenuItem(title: "Scripting", action: nil, keyEquivalent: "") + let selectScript = NSMenuItem(title: "Select Script...", action: #selector(selectScript(_:)), keyEquivalent: "") + let resetScript = NSMenuItem(title: "Clear selection", action: #selector(resetScript(_:)), keyEquivalent: "") + let currentScriptSelectionMenuItem = NSMenuItem(title: "No selection", action: nil, keyEquivalent: "") + self.currentScriptSelectionMenuItem = currentScriptSelectionMenuItem + scriptMenu.submenu = NSMenu() + scriptMenu.submenu?.addItem(selectScript) + scriptMenu.submenu?.addItem(resetScript) + scriptMenu.submenu?.addItem(currentScriptSelectionMenuItem) + menu.addItem(scriptMenu) + let quitItem = NSMenuItem(title: "Quit", action: #selector(NSApp.terminate(_:)), keyEquivalent: "") menu.addItem(quitItem) @@ -175,4 +190,26 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + @objc func selectScript(_ item: NSMenuItem) { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.message = "Select a script that should be invoked when sample rate changes." + + panel.begin { response in + Defaults.shared.shellScriptPath = panel.url?.path + } + } + + @objc func resetScript(_ item: NSMenuItem) { + Defaults.shared.shellScriptPath = nil + } + +} + +extension AppDelegate: NSMenuDelegate { + func menuWillOpen(_ menu: NSMenu) { + currentScriptSelectionMenuItem?.title = Defaults.shared.shellScriptPath ?? "No selection" + } } diff --git a/Quality/Defaults.swift b/Quality/Defaults.swift index fc0d5d4..a31ad76 100644 --- a/Quality/Defaults.swift +++ b/Quality/Defaults.swift @@ -12,6 +12,7 @@ class Defaults: ObservableObject { private let kUserPreferIconStatusBarItem = "com.vincent-neo.LosslessSwitcher-Key-UserPreferIconStatusBarItem" private let kSelectedDeviceUID = "com.vincent-neo.LosslessSwitcher-Key-SelectedDeviceUID" private let kUserPreferBitDepthDetection = "com.vincent-neo.LosslessSwitcher-Key-BitDepthDetection" + private let kShellScriptPath = "KeyShellScriptPath" private init() { UserDefaults.standard.register(defaults: [ @@ -40,6 +41,15 @@ class Defaults: ObservableObject { } } + var shellScriptPath: String? { + get { + return UserDefaults.standard.string(forKey: kShellScriptPath) + } + set { + UserDefaults.standard.setValue(newValue, forKey: kShellScriptPath) + } + } + @Published var userPreferBitDepthDetection: Bool diff --git a/Quality/Info.plist b/Quality/Info.plist index deaca6f..12822ad 100644 --- a/Quality/Info.plist +++ b/Quality/Info.plist @@ -6,5 +6,9 @@ NSAppleEventsUsageDescription This permission is required for local file sample rate detection. + NSAppleScriptEnabled + + OSAScriptingDefinition + LosslessSwitcher.sdef diff --git a/Quality/LosslessSwitcher.sdef b/Quality/LosslessSwitcher.sdef new file mode 100644 index 0000000..766ce16 --- /dev/null +++ b/Quality/LosslessSwitcher.sdef @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Quality/OutputDevices.swift b/Quality/OutputDevices.swift index 1773cd1..78357ff 100644 --- a/Quality/OutputDevices.swift +++ b/Quality/OutputDevices.swift @@ -252,5 +252,24 @@ class OutputDevices: ObservableObject { let delegate = AppDelegate.instance delegate?.statusItemTitle = String(format: "%.1f kHz", readableSampleRate) } + self.runUserScript(sampleRate) + } + + func runUserScript(_ sampleRate: Float64) { + guard let scriptPath = Defaults.shared.shellScriptPath else { return } + let argumentSampleRate = String(Int(sampleRate)) + Task.detached { + let scriptURL = URL(fileURLWithPath: scriptPath) + do { + let task = try NSUserUnixTask(url: scriptURL) + let arguments = [ + argumentSampleRate + ] + try await task.execute(withArguments: arguments) + } + catch { + print("TASK ERR \(error)") + } + } } } diff --git a/Quality/ScriptableApplicationCommand.swift b/Quality/ScriptableApplicationCommand.swift new file mode 100644 index 0000000..a0d8d00 --- /dev/null +++ b/Quality/ScriptableApplicationCommand.swift @@ -0,0 +1,22 @@ +// +// ScriptableApplicationCommand.swift +// LosslessSwitcher +// +// Created by Vincent Neo on 6/12/23. +// + +import Cocoa + +class ScriptableApplicationCommand: NSScriptCommand { + + override func performDefaultImplementation() -> Any? { + guard let delegate = AppDelegate.instance else { + return -1000 + } + let od = delegate.outputDevices + guard let sampleRate = od?.currentSampleRate else { + return -1 + } + return Int(sampleRate * 1000) + } +}