mirror of
https://github.com/morgan9e/VolumeControl
synced 2026-04-14 00:04:05 +09:00
236 lines
7.4 KiB
Plaintext
236 lines
7.4 KiB
Plaintext
// VolumeControl
|
|
//
|
|
// Provides software volume control for audio devices that lack hardware volume
|
|
// (e.g. HDMI outputs). Also works as a universal volume control for all devices.
|
|
|
|
#import <Cocoa/Cocoa.h>
|
|
#import <AVFoundation/AVCaptureDevice.h>
|
|
|
|
#import "VCAudioDeviceManager.h"
|
|
#import "VCTermination.h"
|
|
|
|
@protocol OSDUIHelperProtocol
|
|
- (void)showImage:(long long)image onDisplayID:(unsigned int)displayID
|
|
priority:(unsigned int)priority msecUntilFade:(unsigned int)fade
|
|
filledChiclets:(unsigned int)filled totalChiclets:(unsigned int)total
|
|
locked:(BOOL)locked;
|
|
@end
|
|
|
|
static const long long kOSDImageVolume = 3;
|
|
|
|
static void ShowVolumeOSD(float volume) {
|
|
static NSXPCConnection* conn = nil;
|
|
static dispatch_once_t once;
|
|
dispatch_once(&once, ^{
|
|
conn = [[NSXPCConnection alloc] initWithMachServiceName:@"com.apple.OSDUIHelper"
|
|
options:0];
|
|
conn.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(OSDUIHelperProtocol)];
|
|
[conn resume];
|
|
});
|
|
|
|
unsigned int filled = (unsigned int)(volume * 16 + 0.5f);
|
|
unsigned int total = 16;
|
|
|
|
id<OSDUIHelperProtocol> proxy = [conn remoteObjectProxy];
|
|
[proxy showImage:kOSDImageVolume
|
|
onDisplayID:CGMainDisplayID()
|
|
priority:0x1f4
|
|
msecUntilFade:1000
|
|
filledChiclets:filled
|
|
totalChiclets:total
|
|
locked:NO];
|
|
}
|
|
|
|
|
|
// Minimal app delegate — handles termination cleanup.
|
|
@interface VCAppDelegate : NSObject <NSApplicationDelegate>
|
|
@property (nonatomic) VCAudioDeviceManager* audioDevices;
|
|
@end
|
|
|
|
@implementation VCAppDelegate
|
|
- (void) applicationWillTerminate:(NSNotification*)note {
|
|
#pragma unused (note)
|
|
if ([self.audioDevices isVirtualDeviceActive]) {
|
|
[self.audioDevices unsetVCDeviceAsOSDefault];
|
|
}
|
|
}
|
|
@end
|
|
|
|
|
|
// Menu bar controller — speaker icon with scroll-to-adjust volume.
|
|
@interface VCMenuBar : NSObject <NSMenuDelegate>
|
|
@property (nonatomic) NSStatusItem* statusItem;
|
|
@property (nonatomic) VCAudioDeviceManager* audioDevices;
|
|
@property (nonatomic) id scrollMonitor;
|
|
@property (nonatomic) NSSlider* volumeSlider;
|
|
@end
|
|
|
|
@implementation VCMenuBar
|
|
|
|
- (void) sliderChanged:(NSSlider*)sender {
|
|
[self.audioDevices setVolume:sender.floatValue];
|
|
[self updateIcon];
|
|
}
|
|
|
|
- (void) menuWillOpen:(NSMenu*)menu {
|
|
self.volumeSlider.floatValue = [self.audioDevices volume];
|
|
}
|
|
|
|
- (void) setupWithAudioDevices:(VCAudioDeviceManager*)devices {
|
|
self.audioDevices = devices;
|
|
self.statusItem = [[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength];
|
|
|
|
[self updateIcon];
|
|
|
|
NSMenu* menu = [[NSMenu alloc] init];
|
|
menu.delegate = self;
|
|
|
|
NSMenuItem* label = [[NSMenuItem alloc] initWithTitle:@"VolumeControl" action:nil keyEquivalent:@""];
|
|
[label setEnabled:NO];
|
|
[menu addItem:label];
|
|
[menu addItem:[NSMenuItem separatorItem]];
|
|
|
|
// Slider menu item.
|
|
NSSlider* slider = [NSSlider sliderWithValue:[self.audioDevices volume]
|
|
minValue:0.0
|
|
maxValue:1.0
|
|
target:self
|
|
action:@selector(sliderChanged:)];
|
|
slider.controlSize = NSControlSizeSmall;
|
|
|
|
NSView* sliderView = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 200, 28)];
|
|
slider.frame = NSMakeRect(14, 4, 172, 20);
|
|
[sliderView addSubview:slider];
|
|
self.volumeSlider = slider;
|
|
|
|
NSMenuItem* sliderItem = [[NSMenuItem alloc] init];
|
|
sliderItem.view = sliderView;
|
|
[menu addItem:sliderItem];
|
|
|
|
[menu addItem:[NSMenuItem separatorItem]];
|
|
[menu addItemWithTitle:@"Quit" action:@selector(terminate:) keyEquivalent:@"q"];
|
|
|
|
self.statusItem.menu = menu;
|
|
|
|
// Scroll on status bar icon to adjust volume.
|
|
self.scrollMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskScrollWheel
|
|
handler:^NSEvent*(NSEvent* event) {
|
|
// Only respond if the mouse is over our status item.
|
|
NSRect frame = self.statusItem.button.window.frame;
|
|
NSPoint mouse = [NSEvent mouseLocation];
|
|
if (!NSPointInRect(mouse, frame)) {
|
|
return event;
|
|
}
|
|
|
|
float delta = event.scrollingDeltaY;
|
|
if (event.hasPreciseScrollingDeltas) {
|
|
delta *= 0.002f; // Trackpad: fine-grained
|
|
} else {
|
|
delta *= 0.05f; // Mouse wheel: coarser steps
|
|
}
|
|
|
|
float vol = [self.audioDevices volume] + delta;
|
|
[self.audioDevices setVolume:vol];
|
|
[self updateIcon];
|
|
ShowVolumeOSD([self.audioDevices volume]);
|
|
|
|
return nil; // Consume the event.
|
|
}];
|
|
}
|
|
|
|
- (void) dealloc {
|
|
if (self.scrollMonitor) {
|
|
[NSEvent removeMonitor:self.scrollMonitor];
|
|
}
|
|
}
|
|
|
|
- (void) updateIcon {
|
|
float vol = [self.audioDevices volume];
|
|
if (self.volumeSlider) {
|
|
self.volumeSlider.floatValue = vol;
|
|
}
|
|
BOOL muted = [self.audioDevices isMuted];
|
|
|
|
NSString* symbolName;
|
|
if (muted || vol < 0.01f) {
|
|
symbolName = @"speaker.fill";
|
|
} else if (vol < 0.33f) {
|
|
symbolName = @"speaker.wave.1.fill";
|
|
} else if (vol < 0.66f) {
|
|
symbolName = @"speaker.wave.2.fill";
|
|
} else {
|
|
symbolName = @"speaker.wave.3.fill";
|
|
}
|
|
|
|
NSImage* icon = [NSImage imageWithSystemSymbolName:symbolName
|
|
accessibilityDescription:@"VolumeControl"];
|
|
[icon setTemplate:YES];
|
|
self.statusItem.button.image = icon;
|
|
}
|
|
|
|
@end
|
|
|
|
|
|
int main(int argc, const char* argv[]) {
|
|
#pragma unused (argc, argv)
|
|
|
|
@autoreleasepool {
|
|
NSApplication* app = [NSApplication sharedApplication];
|
|
[app setActivationPolicy:NSApplicationActivationPolicyAccessory];
|
|
|
|
NSLog(@"VolumeControl: Starting...");
|
|
|
|
VCAudioDeviceManager* audioDevices = [VCAudioDeviceManager new];
|
|
|
|
if (!audioDevices) {
|
|
NSLog(@"VolumeControl: Could not find the virtual audio device driver.");
|
|
return 1;
|
|
}
|
|
|
|
VCTermination::SetUpTerminationCleanUp(audioDevices);
|
|
|
|
// App delegate for clean shutdown.
|
|
VCAppDelegate* delegate = [[VCAppDelegate alloc] init];
|
|
delegate.audioDevices = audioDevices;
|
|
[app setDelegate:delegate];
|
|
|
|
// Menu bar icon with scroll volume control.
|
|
VCMenuBar* menuBar = [[VCMenuBar alloc] init];
|
|
[menuBar setupWithAudioDevices:audioDevices];
|
|
|
|
// Update icon when volume changes externally (keyboard keys, system slider).
|
|
audioDevices.onVolumeChanged = ^{
|
|
[menuBar updateIcon];
|
|
};
|
|
|
|
// Request microphone permission. Block until granted.
|
|
if (@available(macOS 10.14, *)) {
|
|
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
|
|
__block BOOL granted = NO;
|
|
|
|
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio
|
|
completionHandler:^(BOOL g) {
|
|
granted = g;
|
|
dispatch_semaphore_signal(sem);
|
|
}];
|
|
|
|
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
|
|
|
|
if (!granted) {
|
|
NSLog(@"VolumeControl: Microphone permission denied.");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// Scan devices and activate.
|
|
[audioDevices evaluateAndActivate];
|
|
[menuBar updateIcon];
|
|
|
|
NSLog(@"VolumeControl: Running.");
|
|
|
|
[app run];
|
|
}
|
|
|
|
return 0;
|
|
}
|