Added HUD for Tahoe

This commit is contained in:
Andrea Alberti 2025-10-24 23:23:32 +02:00
parent 62fe10458e
commit 89871334e1
145 changed files with 1349 additions and 43000 deletions

View File

@ -11,6 +11,7 @@
#import <Sparkle/Sparkle.h>
#import "TahoeVolumeHUD.h"
#import "iTunes.h"
// #import "Music.h"
#import "Spotify.h"
@ -18,14 +19,11 @@
@class IntroWindowController, AccessibilityDialog, StatusBarItem, PlayerApplication, SystemApplication;
@interface AppDelegate : NSObject <NSApplicationDelegate, NSMenuItemValidation, SPUUpdaterDelegate, SPUStandardUserDriverDelegate> {
CALayer *mainLayer;
@interface AppDelegate : NSObject <NSApplicationDelegate, NSMenuItemValidation, SPUUpdaterDelegate, SPUStandardUserDriverDelegate, TahoeVolumeHUDDelegate> {
CALayer *volumeImageLayer;
CALayer *iconLayer;
CALayer *volumeBar[16];
NSImage *imgVolOn,*imgVolOff;
NSImage *iTunesIcon,*spotifyIcon;
NSUserDefaults *preferences;
@ -122,6 +120,6 @@
@property (assign, nonatomic) double currentVolume;
@property (assign, nonatomic) double oldVolume;
@property (assign, nonatomic) double doubleVolume;
@property (assign, nonatomic) NSImage* icon;
@end

View File

@ -1,4 +1,5 @@
//
//
// AppDelegate.m
// iTunes Volume Control
//
@ -9,6 +10,7 @@
#import "AppDelegate.h"
#import "SystemVolume.h"
#import "AccessibilityDialog.h"
#import "TahoeVolumeHUD.h"
#import <IOKit/hidsystem/ev_keymap.h>
#import <ServiceManagement/ServiceManagement.h>
@ -97,7 +99,7 @@ CGEventRef event_tap_callback(CGEventTapProxy proxy, CGEventType type, CGEventRe
keyCode == NX_KEYTYPE_SOUND_UP ||
keyCode == NX_KEYTYPE_SOUND_DOWN);
if(isMediaKey) {
if(isMediaKey /*&& keyModifier==1114111*/) {
// Hand off all actual logic to main thread
dispatch_async(dispatch_get_main_queue(), ^{
AppDelegate *app = (__bridge AppDelegate *)refcon;
@ -160,6 +162,7 @@ CGEventRef event_tap_callback(CGEventTapProxy proxy, CGEventType type, CGEventRe
@implementation PlayerApplication
@synthesize currentVolume = _currentVolume;
@synthesize icon = _icon;
- (void) setCurrentVolume:(double)currentVolume
{
@ -205,12 +208,12 @@ CGEventRef event_tap_callback(CGEventTapProxy proxy, CGEventType type, CGEventRe
return [musicPlayer playerState];
}
-(id)initWithBundleIdentifier:(NSString*) bundleIdentifier {
-(id)initWithBundleIdentifier:(NSString*) bundleIdentifier andIcon:(NSImage*)icon {
if (self = [super init]) {
[self setCurrentVolume: -100];
[self setOldVolume: -1];
musicPlayer = [SBApplication applicationWithBundleIdentifier:bundleIdentifier];
[self setIcon:icon];
}
return self;
}
@ -625,8 +628,10 @@ static NSTimeInterval updateSystemVolumeInterval=0.1f;
if(!_hideVolumeWindow){
if (@available(macOS 16.0, *)) {
// Running on Tahoe (2026) or newer
// On Tahoe, show the new popover HUD.
[[TahoeVolumeHUD sharedManager] showHUDWithVolume:0 usingMusicPlayer:runningPlayerPtr andLabel:[systemAudio getDefaultOutputDeviceName] anchoredToStatusButton:self.statusBar.button];
} else {
// On older systems, use the classic OSD.
id osdMgr = [self->OSDManager sharedManager];
if (osdMgr) {
[osdMgr showImage:OSDGraphicSpeakerMute onDisplayID:CGSMainDisplayID() priority:OSDPriorityDefault msecUntilFade:1000 filledChiclets:0 totalChiclets:(unsigned int)100 locked:NO];
@ -645,8 +650,10 @@ static NSTimeInterval updateSystemVolumeInterval=0.1f;
if(!_hideVolumeWindow)
{
if (@available(macOS 16.0, *)) {
// Running on Tahoe (2026) or newer
// On Tahoe, show the new popover HUD.
[[TahoeVolumeHUD sharedManager] showHUDWithVolume:[runningPlayerPtr oldVolume] usingMusicPlayer:runningPlayerPtr andLabel:[systemAudio getDefaultOutputDeviceName] anchoredToStatusButton:self.statusBar.button];
} else {
// On older systems, use the classic OSD.
id osdMgr = [self->OSDManager sharedManager];
if (osdMgr) {
[osdMgr showImage:OSDGraphicSpeaker onDisplayID:CGSMainDisplayID() priority:OSDPriorityDefault msecUntilFade:1000 filledChiclets:(unsigned int)[runningPlayerPtr oldVolume] totalChiclets:(unsigned int)100 locked:NO];
@ -733,18 +740,15 @@ static NSTimeInterval updateSystemVolumeInterval=0.1f;
self->OSDManager = NSClassFromString(@"OSDManager");
}
//[self checkSIPforAppIdentifier:@"com.apple.iTunes" promptIfNeeded:YES];
//[self checkSIPforAppIdentifier:@"com.spotify.client" promptIfNeeded:YES];
if (@available(macOS 10.15, *)) {
iTunes = [[PlayerApplication alloc] initWithBundleIdentifier:@"com.apple.Music"];
if (@available(macOS 16.0, *)) {
iTunes = [[PlayerApplication alloc] initWithBundleIdentifier:@"com.apple.Music" andIcon:[NSImage imageNamed:@"AppleMusicTahoe"]];
} else {
iTunes = [[PlayerApplication alloc] initWithBundleIdentifier:@"com.apple.iTunes"];
iTunes = [[PlayerApplication alloc] initWithBundleIdentifier:@"com.apple.iTunes" andIcon:[NSImage imageNamed:@"AppleMusicSequoia"]];
}
spotify = [[PlayerApplication alloc] initWithBundleIdentifier:@"com.spotify.client"];
spotify = [[PlayerApplication alloc] initWithBundleIdentifier:@"com.spotify.client" andIcon:[NSImage imageNamed:@"spotify"]];
doppler = [[PlayerApplication alloc] initWithBundleIdentifier:@"co.brushedtype.doppler-macos"];
doppler = [[PlayerApplication alloc] initWithBundleIdentifier:@"co.brushedtype.doppler-macos" andIcon:[NSImage imageNamed:@"doppler"]];
// Force MacOS to ask for authorization to AppleEvents if this was not already given
if([iTunes isRunning])
@ -807,6 +811,8 @@ static NSTimeInterval updateSystemVolumeInterval=0.1f;
accessibilityDialog = [[AccessibilityDialog alloc] initWithWindowNibName:@"AccessibilityDialog"];
[accessibilityDialog showWindow:self];
}
[TahoeVolumeHUD sharedManager].delegate = self;
}
- (BOOL)applicationShouldHandleReopen:(NSApplication *)sender hasVisibleWindows:(BOOL)flag
@ -892,13 +898,7 @@ static NSTimeInterval updateSystemVolumeInterval=0.1f;
[self setLockSystemAndPlayerVolume:[preferences boolForKey: @"LockSystemAndPlayerVolume"]];
[self setAutomaticUpdates:[preferences boolForKey: @"AutomaticUpdates"]];
[self setHideFromStatusBar:[preferences boolForKey: @"hideFromStatusBarPreference"]];
if (@available(macOS 16.0, *)) {
// Running on Tahoe (2026) or newer
NSMenuItem *item = [self.statusMenu itemWithTag:HIDE_VOLUME_WINDOW_ID];
[item setHidden:YES];
} else {
[self setHideVolumeWindow:[preferences boolForKey: @"hideVolumeWindowPreference"]];
}
[[self iTunesBtn] setState:[preferences boolForKey: @"iTunesControl"]];
if (@available(macOS 10.15, *)) {
[[self iTunesBtn] setTitle:@"Music"];
@ -1172,7 +1172,7 @@ static NSTimeInterval updateSystemVolumeInterval=0.1f;
NSInteger numQrtsBlks = 0;
if (@available(macOS 16.0, *)) {
// Running on Tahoe (2026) or newer
// On Tahoe, show the new popover HUD anchored to the status item.
} else {
image = (volume > 0)? OSDGraphicSpeaker : OSDGraphicSpeakerMute;
numFullBlks = floor(volume/6.25);
@ -1184,7 +1184,8 @@ static NSTimeInterval updateSystemVolumeInterval=0.1f;
if(!_hideVolumeWindow)
{
if (@available(macOS 16.0, *)) {
// Running on Tahoe (2026) or newer
// On Tahoe, show the new popover HUD anchored to the status item.
[[TahoeVolumeHUD sharedManager] showHUDWithVolume:volume usingMusicPlayer:runningPlayerPtr andLabel:[systemAudio getDefaultOutputDeviceName] anchoredToStatusButton:self.statusBar.button];
} else {
if(image) {
id osdMgr = [self->OSDManager sharedManager];
@ -1526,6 +1527,48 @@ static NSTimeInterval updateSystemVolumeInterval=0.1f;
}
}
#pragma mark - TahoeVolumeHUDDelegate
- (void)hud:(TahoeVolumeHUD *)hud didChangeVolume:(double)volume forPlayer:(PlayerApplication*)controlledPlayer{
// This method is called every time the user drags the slider in the HUD.
// The received 'volume' is a value between 0.0 and 1.0.
// 1. Convert the 0.0-1.0 scale to the 0-100 scale our app uses.
double volumePercent = volume * 100.0;
// 2. Get the currently active player, just like we do for the volume keys.
id runningPlayerPtr = controlledPlayer;
if (runningPlayerPtr != nil) {
// 3. Set the volume for the active player.
[runningPlayerPtr setCurrentVolume:volumePercent];
// 4. If volume is locked, also set the system volume.
if (_LockSystemAndPlayerVolume && runningPlayerPtr != systemAudio) {
[systemAudio setCurrentVolume:volumePercent];
}
// 5. Update the percentage labels in the status menu to reflect the change in real-time.
if (runningPlayerPtr == iTunes) {
[self setItunesVolume:volumePercent];
} else if (runningPlayerPtr == spotify) {
[self setSpotifyVolume:volumePercent];
} else if (runningPlayerPtr == doppler) {
[self setDopplerVolume:volumePercent];
}
if (_LockSystemAndPlayerVolume || runningPlayerPtr == systemAudio) {
[self setSystemVolume:volumePercent];
}
}
}
- (void)didChangeVolumeFinal:(TahoeVolumeHUD *)hud {
// This is called when the HUD fades out. We can play the feedback sound here
[self emitAcousticFeedback];
}
#pragma mark - Sparkle Delegates
// This is the Objective-C equivalent of the Swift property 'supportsGentleScheduledUpdateReminders'

View File

@ -1,73 +0,0 @@
//
// ISSoundAdditions.m (ver 1.2 - 2012.10.27)
//
// Created by Massimo Moiso (2012-09) InerziaSoft
// based on an idea of Antonio Nunes, SintraWorks
//
// Permission is granted free of charge to use this code without restriction
// and without limitation, with the only condition that the copyright
// notice and this permission shall be included in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
/*
GOAL
This is a category of NSSound build using CoreAudio to get and set the volume of the
system sound and some other utility.
It was implemented using the Apple documentation and various unattributed code fragments
found on the net. For this reason, its use is free for all.
USE
To maintain the Cocoa conventions, a property-like syntax was used; the following
methods ("properties") are available:
(float)systemVolume - return the volume of the default sound device
setSystemVolume(float) - set the volume of the default sound device
(AudioDeviceID)defaultOutputDevice - return the default output device
applyMute(boolean) - enable or disable muting, if supported
REQUIREMENTS
At least MacOS X 10.6
Core Audio Framework
*/
#import <Cocoa/Cocoa.h>
#import <CoreAudio/CoreAudio.h>
#import <AudioToolbox/AudioServices.h>
@interface NSSound (ISSoundAdditions)
+ (AudioDeviceID)defaultOutputDevice;
+ (float)systemVolume;
+ (void)setSystemVolume:(float)inVolume;
+ (void)increaseSystemVolumeBy:(float)amount;
+ (void)decreaseSystemVolumeBy:(float)amount;
+ (void)applyMute:(Boolean)m;
+ (Boolean)isMuted;
#define ZERO_THRESHOLD 0.000 //if the volume should be set under this value, the device will be muted
@end
@interface SystemApplication : NSObject{
NSAppleScript *ASSystemVolume;
NSAppleEventDescriptor* AEsetVolume;
NSAppleEventDescriptor* AEgetVolume;
}
@property (assign, nonatomic) double currentVolume; // The sound output volume (0 = minimum, 100 = maximum)
@property (assign, nonatomic) double oldVolume;
@property (assign, nonatomic) double doubleVolume;
@end

View File

@ -1,381 +0,0 @@
//
// ISSoundAdditions.m (ver 1.2 - 2012.10.27)
//
// Created by Massimo Moiso (2012-09) InerziaSoft
// based on an idea of Antonio Nunes, SintraWorks
//
// Permission is granted free of charge to use this code without restriction
// and without limitation, with the only condition that the copyright
// notice and this permission shall be included in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#import <Carbon/Carbon.h>
#import "ISSoundAdditions.h"
AudioDeviceID obtainDefaultOutputDevice();
@implementation NSSound (ISSoundAdditions)
//
// Return the ID of the default audio device; this is a C routine
//
// IN: none
// OUT: the ID of the default device or AudioObjectUnknown
//
AudioDeviceID obtainDefaultOutputDevice()
{
AudioDeviceID theAnswer = kAudioObjectUnknown;
UInt32 theSize = sizeof(AudioDeviceID);
AudioObjectPropertyAddress theAddress;
theAddress.mSelector = kAudioHardwarePropertyDefaultOutputDevice;
theAddress.mScope = kAudioObjectPropertyScopeGlobal;
theAddress.mElement = kAudioObjectPropertyElementMaster;
//first be sure that a default device exists
if (! AudioObjectHasProperty(kAudioObjectSystemObject, &theAddress) ) {
NSLog(@"Unable to get default audio device");
return theAnswer;
}
//get the property 'default output device'
OSStatus theError = AudioObjectGetPropertyData(kAudioObjectSystemObject, &theAddress, 0, NULL, &theSize, &theAnswer);
if (theError != noErr) {
NSLog(@"Unable to get output audio device");
return theAnswer;
}
return theAnswer;
}
//
// Return the ID of the default audio device; this is a category class method
// that can be called from outside
//
// IN: none
// OUT: the ID of the default device or AudioObjectUnknown
//
+ (AudioDeviceID)defaultOutputDevice
{
return obtainDefaultOutputDevice();
}
//
// Return the system sound volume as a float in the range [0...1]
//
// IN: none
// OUT: (float) the volume of the default device
//
+ (float)systemVolume
{
AudioDeviceID defaultDevID = kAudioObjectUnknown;
UInt32 theSize = sizeof(Float32);
OSStatus theError;
Float32 theVolume = 0;
AudioObjectPropertyAddress theAddress;
defaultDevID = obtainDefaultOutputDevice();
if (defaultDevID == kAudioObjectUnknown) return 0.0; //device not found: return 0
theAddress.mSelector = kAudioHardwareServiceDeviceProperty_VirtualMasterVolume;
theAddress.mScope = kAudioDevicePropertyScopeOutput;
theAddress.mElement = kAudioObjectPropertyElementMaster;
//be sure that the default device has the volume property
if (! AudioObjectHasProperty(defaultDevID, &theAddress) ) {
NSLog(@"No volume control for device 0x%0x",defaultDevID);
return 0.0;
}
//now read the property and correct it, if outside [0...1]
theError = AudioObjectGetPropertyData(defaultDevID, &theAddress, 0, NULL, &theSize, &theVolume);
if ( theError != noErr ) {
NSLog(@"Unable to read volume for device 0x%0x", defaultDevID);
return 0.0;
}
theVolume = theVolume > 1.0 ? 1.0 : (theVolume < 0.0 ? 0.0 : theVolume);
return theVolume;
}
//
// Set the volume of the default device
//
// IN: (float)the new volume
// OUT: none
//
+ (void)setSystemVolume:(float)theVolume
{
float newValue = theVolume;
AudioObjectPropertyAddress theAddress;
AudioDeviceID defaultDevID;
OSStatus theError = noErr;
UInt32 muted;
Boolean canSetVol = YES, muteValue;
Boolean hasMute = YES, canMute = YES;
defaultDevID = obtainDefaultOutputDevice();
if (defaultDevID == kAudioObjectUnknown) { //device not found: return without trying to set
NSLog(@"Device unknown");
return;
}
//check if the new value is in the correct range - normalize it if not
newValue = theVolume > 1.0 ? 1.0 : (theVolume <= THRESHOLD ? ZERO_THRESHOLD : theVolume);
if (newValue != theVolume) {
NSLog(@"Tentative volume (%5.2f) was out of range; reset to %5.2f", theVolume, newValue);
}
theAddress.mElement = kAudioObjectPropertyElementMaster;
theAddress.mScope = kAudioDevicePropertyScopeOutput;
//set the selector to mute or not by checking if under threshold and check if a mute command is available
if ( (muteValue = (newValue <= ZERO_THRESHOLD)) )
{
theAddress.mSelector = kAudioDevicePropertyMute;
hasMute = AudioObjectHasProperty(defaultDevID, &theAddress);
if (hasMute)
{
theError = AudioObjectIsPropertySettable(defaultDevID, &theAddress, &canMute);
if (theError != noErr || !canMute)
{
canMute = NO;
NSLog(@"Should mute device 0x%0x but did not success",defaultDevID);
}
}
else canMute = NO;
}
else
{
theAddress.mSelector = kAudioHardwareServiceDeviceProperty_VirtualMasterVolume;
}
// **** now manage the volume following the what we found ****
//be sure the device has a volume command
if (! AudioObjectHasProperty(defaultDevID, &theAddress))
{
NSLog(@"The device 0x%0x does not have a volume to set", defaultDevID);
return;
}
//be sure the device can set the volume
theError = AudioObjectIsPropertySettable(defaultDevID, &theAddress, &canSetVol);
if ( theError!=noErr || !canSetVol )
{
NSLog(@"The volume of device 0x%0x cannot be set", defaultDevID);
return;
}
//if under the threshold then mute it, only if possible - done/exit
if (muteValue && hasMute && canMute)
{
muted = 1;
theError = AudioObjectSetPropertyData(defaultDevID, &theAddress, 0, NULL, sizeof(muted), &muted);
if (theError != noErr)
{
NSLog(@"The device 0x%0x was not muted",defaultDevID);
return;
}
}
else //else set it
{
theError = AudioObjectSetPropertyData(defaultDevID, &theAddress, 0, NULL, sizeof(newValue), &newValue);
if (theError != noErr)
{
NSLog(@"The device 0x%0x was unable to set volume", defaultDevID);
}
//if device is able to handle muting, maybe it was muted, so unlock it
if (hasMute && canMute)
{
theAddress.mSelector = kAudioDevicePropertyMute;
muted = 0;
theError = AudioObjectSetPropertyData(defaultDevID, &theAddress, 0, NULL, sizeof(muted), &muted);
}
}
if (theError != noErr) {
NSLog(@"Unable to set volume for device 0x%0x", defaultDevID);
}
}
//
// Increase the volume of the system device by a certain value
//
// IN: (float) amount of volume to increase
// OUT: none
//
+ (void)increaseSystemVolumeBy:(float)amount {
[self setSystemVolume:self.systemVolume+amount];
}
//
// Decrease the volume of the system device by a certain value
//
// IN: (float) amount of volume to decrease
// OUT: none
//
+ (void)decreaseSystemVolumeBy:(float)amount {
[self setSystemVolume:self.systemVolume-amount];
}
//
// IN: (Boolean) if true the device is muted, false it is unmated
// OUT: none
//
+ (void)applyMute:(Boolean)m
{
AudioDeviceID defaultDevID = kAudioObjectUnknown;
AudioObjectPropertyAddress theAddress;
Boolean hasMute, canMute = YES;
OSStatus theError = noErr;
UInt32 muted = 0;
defaultDevID = obtainDefaultOutputDevice();
if (defaultDevID == kAudioObjectUnknown) { //device not found
NSLog(@"Device unknown");
return;
}
theAddress.mElement = kAudioObjectPropertyElementMaster;
theAddress.mScope = kAudioDevicePropertyScopeOutput;
theAddress.mSelector = kAudioDevicePropertyMute;
muted = m ? 1 : 0;
hasMute = AudioObjectHasProperty(defaultDevID, &theAddress);
if (hasMute)
{
theError = AudioObjectIsPropertySettable(defaultDevID, &theAddress, &canMute);
if (theError == noErr && canMute)
{
theError = AudioObjectSetPropertyData(defaultDevID, &theAddress, 0, NULL, sizeof(muted), &muted);
if (theError != noErr) NSLog(@"Cannot change mute status of device 0x%0x", defaultDevID);
}
}
}
+ (Boolean)isMuted
{
AudioDeviceID defaultDevID = kAudioObjectUnknown;
AudioObjectPropertyAddress theAddress;
Boolean hasMute, canMute = YES;
OSStatus theError = noErr;
UInt32 muted = 0;
UInt32 mutedSize = 4;
defaultDevID = obtainDefaultOutputDevice();
if (defaultDevID == kAudioObjectUnknown) { //device not found
NSLog(@"Device unknown");
return false; // works, but not the best return code for this
}
theAddress.mElement = kAudioObjectPropertyElementMaster;
theAddress.mScope = kAudioDevicePropertyScopeOutput;
theAddress.mSelector = kAudioDevicePropertyMute;
hasMute = AudioObjectHasProperty(defaultDevID, &theAddress);
if (hasMute)
{
theError = AudioObjectIsPropertySettable(defaultDevID, &theAddress, &canMute);
if (theError == noErr && canMute)
{
theError = AudioObjectGetPropertyData(defaultDevID, &theAddress, 0, NULL, &mutedSize, &muted);
if (muted) {
return true;
}
}
}
return false;
}
@end
@implementation SystemApplication
@synthesize currentVolume = _currentVolume;
@synthesize doubleVolume = _doubleVolume;
- (void) setCurrentVolume:(double)currentVolume
{
[self setDoubleVolume:currentVolume];
NSAppleEventDescriptor* AEsetVolumeParams = [NSAppleEventDescriptor listDescriptor];
[AEsetVolumeParams insertDescriptor:[NSAppleEventDescriptor descriptorWithInt32:(int)currentVolume] atIndex:1];
[AEsetVolume setParamDescriptor:AEsetVolumeParams forKeyword:keyDirectObject];
NSDictionary *error = nil;
[ASSystemVolume executeAppleEvent:AEsetVolume error:&error];
// [NSSound setSystemVolume:currentVolume/100.];
}
- (double) currentVolume
{
double vol = 0;
NSAppleEventDescriptor* AEsetVolumeParams = [NSAppleEventDescriptor listDescriptor];
[AEsetVolume setParamDescriptor:AEsetVolumeParams forKeyword:keyDirectObject];
NSDictionary *error = nil;
NSAppleEventDescriptor *resultEventDescriptor = [ASSystemVolume executeAppleEvent:AEgetVolume error:&error];
if (! resultEventDescriptor) {
NSLog(@"%s AppleScript getVolume error = %@", __PRETTY_FUNCTION__, error);
}
else {
if ([resultEventDescriptor descriptorType] == cLongInteger) {
vol = (double)[resultEventDescriptor int32Value];
}
else
{
NSLog(@"%s AppleScript getVolume error = Return argument has wrong type", __PRETTY_FUNCTION__);
}
}
// double vol = (double)[NSSound systemVolume]*100;
if (fabs(vol-[self doubleVolume])<1)
{
vol = [self doubleVolume];
}
return vol;
}
-(id)init {
if (self = [super init]) {
[self setOldVolume: -1];
NSURL *URL = [[NSBundle mainBundle] URLForResource:@"SystemVolume" withExtension:@"scpt"];
if (URL) {
ASSystemVolume = [[NSAppleScript alloc] initWithContentsOfURL:URL error:NULL];
// target
ProcessSerialNumber psn = {0, kCurrentProcess};
NSAppleEventDescriptor *target = [NSAppleEventDescriptor descriptorWithDescriptorType:typeProcessSerialNumber bytes:&psn length:sizeof(ProcessSerialNumber)];
// functions
NSAppleEventDescriptor *setVolumeHandler = [NSAppleEventDescriptor descriptorWithString:@"setVolume"];
NSAppleEventDescriptor *getVolumeHandler = [NSAppleEventDescriptor descriptorWithString:@"getVolume"];
// events
AEsetVolume = [NSAppleEventDescriptor appleEventWithEventClass:kASAppleScriptSuite eventID:kASSubroutineEvent targetDescriptor:target returnID:kAutoGenerateReturnID transactionID:kAnyTransactionID];
[AEsetVolume setParamDescriptor:setVolumeHandler forKeyword:keyASSubroutineName];
AEgetVolume = [NSAppleEventDescriptor appleEventWithEventClass:kASAppleScriptSuite eventID:kASSubroutineEvent targetDescriptor:target returnID:kAutoGenerateReturnID transactionID:kAnyTransactionID];
[AEgetVolume setParamDescriptor:getVolumeHandler forKeyword:keyASSubroutineName];
}
}
return self;
}
@end

View File

@ -1,42 +0,0 @@
//
// IntroWindowController.h
// iTunes Volume Control
//
// Created by Andrea Alberti on 15.12.13.
// Copyright (c) 2013 Andrea Alberti. All rights reserved.
//
#import <Cocoa/Cocoa.h>
@class AppDelegate;
@interface IntroWindowController : NSWindowController
{
AppDelegate *appDelegate;
CALayer* iTunesScreenshotIntroLayer;
CALayer* statusbarScreenshotIntroLayer;
CALayer* arrow_1_Layer;
CALayer* arrow_2_Layer;
IBOutlet NSTextField *iTune_label_1;
IBOutlet NSTextField *iTune_label_2;
IBOutlet NSTextField *iTune_label_3;
int step_number;
@public
}
@property (nonatomic,assign) IBOutlet NSButton *closeButton;
@property (nonatomic,assign) IBOutlet NSButton *nextButton;
@property (nonatomic,assign) IBOutlet NSButton *previousButton;
@property (nonatomic,assign) IBOutlet NSButton *loadIntroAtStartButton;
- (IBAction)nextButtonClicked:(id)sender;
- (IBAction)prevButtonClicked:(id)sender;
- (IBAction)closeButtonClicked:(id)sender;
- (IBAction)loadIntroAtStartChanged:(id)sender;
@end

View File

@ -1,249 +0,0 @@
//
// IntroWindowController.m
// iTunes Volume Control
//
// Created by Andrea Alberti on 15.12.13.
// Copyright (c) 2013 Andrea Alberti. All rights reserved.
//
#import "IntroWindowController.h"
#import <QuartzCore/CoreAnimation.h>
#import "AppDelegate.h"
@interface IntroWindowController ()
@end
@implementation IntroWindowController
@synthesize nextButton;
@synthesize previousButton;
@synthesize closeButton;
@synthesize loadIntroAtStartButton;
- (id)initWithWindow:(NSWindow *)window
{
self = [super initWithWindow:window];
if (self) {
// Initialization code here.
appDelegate = [[NSApplication sharedApplication] delegate];
[[NSNotificationCenter defaultCenter] addObserver:appDelegate selector:@selector(introWindowWillClose:) name:NSWindowWillCloseNotification object:window];
step_number = 0;
}
return self;
}
- (IBAction)loadIntroAtStartChanged:(id)sender
{
[appDelegate setLoadIntroAtStart:[loadIntroAtStartButton state]];
}
- (IBAction)closeButtonClicked:(id)sender
{
[NSTimer scheduledTimerWithTimeInterval:0.2 target:[self window] selector:@selector(close) userInfo:nil repeats:NO];
}
- (IBAction)nextButtonClicked:(id)sender
{
// CABasicAnimation* fadeOutAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
// [fadeOutAnimation setDuration:1.f];
// [fadeOutAnimation setRemovedOnCompletion:NO];
// [fadeOutAnimation setFillMode:kCAFillModeForwards];
// [fadeOutAnimation setFromValue:[NSNumber numberWithFloat:1.0f]];
// [fadeOutAnimation setToValue:[NSNumber numberWithFloat:0.0f]];
switch (step_number)
{
case 0:
[previousButton setEnabled:YES];
[[NSAnimationContext currentContext] setDuration:1.0f];
[NSAnimationContext beginGrouping];
// [introLayer addAnimation:fadeOutAnimation forKey:@"decreaseOpacity"];
[[iTune_label_2 animator] setAlphaValue:1.f];
[arrow_2_Layer setOpacity:1.f];
[NSAnimationContext endGrouping];
break;
case 1:
[nextButton setEnabled:NO];
[closeButton setEnabled:YES];
[[NSAnimationContext currentContext] setDuration:0.8f];
[NSAnimationContext beginGrouping];
[[iTune_label_1 animator] setAlphaValue:0.f];
[[iTune_label_2 animator] setAlphaValue:0.f];
[arrow_1_Layer setOpacity:0.f];
[arrow_2_Layer setOpacity:0.f];
[statusbarScreenshotIntroLayer setOpacity:1.0f];
[iTunesScreenshotIntroLayer setOpacity:0.0f];
[NSAnimationContext endGrouping];
break;
}
step_number++;
}
- (IBAction)prevButtonClicked:(id)sender
{
switch (step_number)
{
case 1:
[previousButton setEnabled:NO];
[[NSAnimationContext currentContext] setDuration:0.4f];
[NSAnimationContext beginGrouping];
// [introLayer addAnimation:fadeOutAnimation forKey:@"decreaseOpacity"];
[[iTune_label_2 animator] setAlphaValue:0.f];
[arrow_2_Layer setOpacity:0.f];
[NSAnimationContext endGrouping];
break;
case 2:
[nextButton setEnabled:YES];
[closeButton setEnabled:NO];
[[NSAnimationContext currentContext] setDuration:0.4f];
[NSAnimationContext beginGrouping];
[[iTune_label_1 animator] setAlphaValue:1.f];
[[iTune_label_2 animator] setAlphaValue:1.f];
[arrow_1_Layer setOpacity:1.f];
[arrow_2_Layer setOpacity:1.f];
[statusbarScreenshotIntroLayer setOpacity:0.0f];
[iTunesScreenshotIntroLayer setOpacity:1.0f];
[NSAnimationContext endGrouping];
break;
}
step_number--;
}
-(void)showFirstMessage:(NSTimer*)theTimer
{
[[NSAnimationContext currentContext] setDuration:1.3f];
[NSAnimationContext beginGrouping];
[[iTune_label_1 animator] setAlphaValue:1.f];
[arrow_1_Layer setOpacity:1.f];
[NSAnimationContext endGrouping];
}
-(void)awakeFromNib
{
NSWindow* introWindow = [self window];
NSView* introContentView = [introWindow contentView];
CGSize windowViewSize = [introContentView frame].size;
NSRect imageRect = NSZeroRect;
NSImage *iTunesScreenshot=[NSImage imageNamed:@"iTunes-screenshot"];
NSImage *iTunes_arrow_1=[NSImage imageNamed:@"iTunes-arrow-1"];
NSImage *iTunes_arrow_2=[NSImage imageNamed:@"iTunes-arrow-2"];
// NSImage *statusbarScreenshot=[NSImage imageNamed:@"statusbar-screenshot"];
NSImage *statusbarScreenshot=[NSImage imageNamed:@"keyboard"];
//[introWindow setContentBorderThickness:36 forEdge:NSMinYEdge];
CALayer* toplayer = [CALayer layer];
//CGColorRef backgroundColor=CGColorCreateGenericRGB(1.f, 1.f, 1.f, 1.f);
//[toplayer setBackgroundColor:backgroundColor];
//CFRelease(backgroundColor);
// iTunes screenshot
iTunesScreenshotIntroLayer = [CALayer layer];
imageRect.size = [iTunesScreenshot size];
[iTunesScreenshotIntroLayer setFrame:NSRectToCGRect(imageRect)];
[iTunesScreenshotIntroLayer setPosition:CGPointMake(windowViewSize.width/2-16,windowViewSize.height-imageRect.size.height/2-60)];
[iTunesScreenshotIntroLayer setContents:iTunesScreenshot];
//[introLayer setBorderColor:CGColorCreateGenericRGB(1.f, 0.f, 0.f, 1.f)];
//[introLayer setBorderWidth:1];
[toplayer addSublayer:iTunesScreenshotIntroLayer];
// Statusbar screenshot
statusbarScreenshotIntroLayer = [CALayer layer];
// [statusbarScreenshotIntroLayer setCompositingFilter:[CIFilter filterWithName:@"CIAdditionCompositing"]];
imageRect.size = [statusbarScreenshot size];
[statusbarScreenshotIntroLayer setFrame:NSRectToCGRect(imageRect)];
[statusbarScreenshotIntroLayer setPosition:CGPointMake(350,windowViewSize.height-250)];
[statusbarScreenshotIntroLayer setContents:statusbarScreenshot];
[statusbarScreenshotIntroLayer setOpacity:0.f];
[toplayer addSublayer:statusbarScreenshotIntroLayer];
arrow_1_Layer = [CALayer layer];
imageRect.size = [iTunes_arrow_1 size];
[arrow_1_Layer setFrame:NSRectToCGRect(imageRect)];
[arrow_1_Layer setPosition:CGPointMake(313,windowViewSize.height-94)];
[arrow_1_Layer setContents:iTunes_arrow_1];
[arrow_1_Layer setOpacity:0.f];
[toplayer addSublayer:arrow_1_Layer];
[iTune_label_1 setAlphaValue:0.f];
arrow_2_Layer = [CALayer layer];
imageRect.size = [iTunes_arrow_2 size];
[arrow_2_Layer setFrame:NSRectToCGRect(imageRect)];
[arrow_2_Layer setPosition:CGPointMake(240,windowViewSize.height-95)];
[arrow_2_Layer setContents:iTunes_arrow_2];
[arrow_2_Layer setOpacity:0.f];
[toplayer addSublayer:arrow_2_Layer];
[iTune_label_2 setAlphaValue:0.f];
NSRect frameRect = NSMakeRect(20,20,40,40); // This will change based on the size you need
iTune_label_3 = [[NSTextField alloc] initWithFrame:frameRect];
[iTune_label_3 setStringValue:@"My Label"];
[iTune_label_3 setBezeled:NO];
[iTune_label_3 setDrawsBackground:NO];
[iTune_label_3 setEditable:NO];
[iTune_label_3 setSelectable:NO];
[introContentView addSubview:iTune_label_3];
[introContentView setLayer:toplayer];
[introContentView setWantsLayer:YES];
NSImage *closeButtonImage=[NSImage imageNamed:@"introButtons-close"];
NSImage *closeButtonImageHL=[NSImage imageNamed:@"introButtons-close-HL"];
[closeButton setImage: closeButtonImage];
[closeButton setAlternateImage: closeButtonImageHL];
[closeButton setBordered:NO];
[closeButton setEnabled:NO];
[[closeButton cell] setHighlightsBy:1];
NSImage *nextButtonImage=[NSImage imageNamed:@"introButtons-next"];
NSImage *nextButtonImageHL=[NSImage imageNamed:@"introButtons-next-HL"];
[nextButton setImage: nextButtonImage];
[nextButton setAlternateImage: nextButtonImageHL];
[nextButton setBordered:NO];
[[nextButton cell] setHighlightsBy:1];
NSImage *prevButtonImage=[NSImage imageNamed:@"introButtons-prev"];
NSImage *prevButtonImageHL=[NSImage imageNamed:@"introButtons-prev-HL"];
[previousButton setImage: prevButtonImage];
[previousButton setAlternateImage: prevButtonImageHL];
[previousButton setBordered:NO];
[previousButton setEnabled:NO];
[[previousButton cell] setHighlightsBy:1];
[NSTimer scheduledTimerWithTimeInterval:0.3 target:self selector:@selector(showFirstMessage:) userInfo:nil repeats:NO];
[loadIntroAtStartButton setState: [appDelegate loadIntroAtStart]];
}
- (void)windowDidLoad
{
[super windowDidLoad];
// Implement this method to handle any initialization after your window controller's window has been loaded from its nib file.
}
@end

View File

@ -18,8 +18,10 @@
-(id)init;
-(bool)isMuted;
-(NSString *)getDefaultOutputDeviceName;
@property (assign, nonatomic) double currentVolume; // The sound output volume (0 = minimum, 100 = maximum)
@property (assign, nonatomic) double oldVolume;
@property (assign, nonatomic) NSImage* icon;
@end

View File

@ -14,6 +14,7 @@
@implementation SystemApplication
@synthesize currentVolume = _currentVolume;
@synthesize icon = _icon;
-(AudioDeviceID) getDefaultOutputDevice
{
@ -156,6 +157,40 @@
return ((double)volume) * 100.0;
}
- (NSString *)getDefaultOutputDeviceName
{
AudioDeviceID defaultOutputDeviceID = [self getDefaultOutputDevice];
if (defaultOutputDeviceID == kAudioObjectUnknown) {
return @"Unknown Device";
}
CFStringRef deviceName = NULL;
UInt32 dataSize = sizeof(deviceName);
AudioObjectPropertyAddress propertyAddress = {
kAudioObjectPropertyName,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain
};
OSStatus result = AudioObjectGetPropertyData(defaultOutputDeviceID,
&propertyAddress,
0,
NULL,
&dataSize,
&deviceName);
if (result != kAudioHardwareNoError || deviceName == NULL) {
NSLog(@"Could not get device name for device 0x%0x", defaultOutputDeviceID);
return @"Unknown Device";
}
NSString *name = [NSString stringWithString:(__bridge NSString *)deviceName];
CFRelease(deviceName);
return name;
}
-(void)dealloc
{
@ -164,6 +199,11 @@
-(id)init{
if (self = [super init]) {
[self setOldVolume:[self currentVolume]];
if (@available(macOS 16.0, *)) {
[self setIcon:[NSImage imageNamed:@"FinderTahoe"]];
} else {
[self setIcon:[NSImage imageNamed:@"FinderSequoia"]];
}
}
return self;
}

View File

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24127" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24128" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24127"/>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24128"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@ -19,7 +20,7 @@
<window title="Authorization required" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="F0z-JX-Cv5">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<rect key="contentRect" x="1003" y="186" width="771" height="635"/>
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1415"/>
<rect key="screenRect" x="0.0" y="0.0" width="3072" height="1697"/>
<view key="contentView" id="se5-gp-TjO">
<rect key="frame" x="0.0" y="0.0" width="771" height="635"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>

View File

@ -0,0 +1,6 @@
// FILE: HUDPanel.h
#import <Cocoa/Cocoa.h>
@interface HUDPanel : NSPanel
@end

View File

@ -0,0 +1,19 @@
// FILE: HUDPanel.m
#import "HUDPanel.h"
@implementation HUDPanel
// Allow this nonactivating panel to be key so controls (slider) behave nicely.
- (BOOL)canBecomeKeyWindow { return YES; }
// Explicitly state that the panel can become the first responder.
// This allows controls within the panel (like the slider) to receive and process mouse events.
- (BOOL)acceptsFirstResponder { return YES; }
// Do not pretend to be the main app window.
- (BOOL)canBecomeMainWindow { return NO; }
// Keep it nonactivating: clicks wont steal app focus.
- (BOOL)worksWhenModal { return YES; }
@end

View File

@ -0,0 +1,374 @@
//
// LiquidGlassView.swift
//
// Created by Andrea Alberti on 25.10.25.
//
// A native glass host view (macOS 26+) with graceful fallback,
// usable from Objective-C and SwiftUI.
//
import AppKit
import QuartzCore
import SwiftUI
@objc(LiquidGlassView)
public final class LiquidGlassView: NSView {
// MARK: - Public API (Objective-C visible)
/// Where your content goes. Replaces any previous content.
@objc public var contentView: NSView? {
get { contentHost.subviews.first }
set {
contentHost.subviews.forEach { $0.removeFromSuperview() }
guard let v = newValue else { return }
v.translatesAutoresizingMaskIntoConstraints = false
contentHost.addSubview(v)
NSLayoutConstraint.activate([
v.leadingAnchor.constraint(equalTo: contentHost.leadingAnchor),
v.trailingAnchor.constraint(equalTo: contentHost.trailingAnchor),
v.topAnchor.constraint(equalTo: contentHost.topAnchor),
v.bottomAnchor.constraint(equalTo: contentHost.bottomAnchor)
])
}
}
/// Corner radius applied to the glass.
@objc public var cornerRadius: CGFloat = 14 {
didSet {
applyCornerRadius()
updateRimPath() // Update rim to match new radius
}
}
/// Tint color for the glass (native on 26+, fallback paints a faint layer color).
@objc public var tintColor: NSColor? {
didSet { applyTint() }
}
/// NSGlassEffectView.Style (0 = Regular, 1 = Clear). Ignored on fallback.
@objc public var style: Int = 1 { // default Clear
didSet { applyStyle() }
}
/// Draws a vibrant, glowing white rim around the view.
@objc public var hasVibrantRim: Bool = false {
didSet {
updateRim()
}
}
/// Convenience builder
@objc public class func glass(withStyle style: Int, cornerRadius: CGFloat, tintColor: NSColor?) -> LiquidGlassView {
let v = LiquidGlassView(frame: .zero)
v.translatesAutoresizingMaskIntoConstraints = false
v.style = style
v.cornerRadius = cornerRadius
v.tintColor = tintColor
return v
}
// MARK: Optional private Tahoe knobs (no-ops if unavailable)
/// 0..N variants
@objc public func setVariantIfAvailable(_ variant: Int) {
setPrivate("_variant", value: variant)
}
/// 0/1
@objc public func setScrimStateIfAvailable(_ state: Int) {
setPrivate("_scrimState", value: state)
}
/// 0/1
@objc public func setSubduedStateIfAvailable(_ state: Int) {
setPrivate("_subduedState", value: state)
}
/// Controls whether the glass adapts its appearance to light/dark mode.
/// Set to 0 to force a consistent (darker) look.
@objc public func setAdaptiveAppearanceIfAvailable(_ state: Int) {
setPrivate("_adaptiveAppearance", value: state)
}
/// Accepts a boolean (YES/NO from ObjC) to use a smaller, sharper shadow rim.
@objc public func setUseReducedShadowRadiusIfAvailable(_ state: Bool) {
setPrivate("_useReducedShadowRadius", value: state)
}
/// Controls a depth-of-field/focus simulation. Usually set to 0 to disable.
@objc public func setContentLensingIfAvailable(_ state: Int) {
setPrivate("_contentLensing", value: state)
}
/// SwiftUI-like post-processing (applies CIFilters to the layer).
/// Use sparingly; native glass already blurs/tints appropriately.
@objc public func applyVisualAdjustments(saturation: CGFloat = 1.0,
brightness: CGFloat = 0.0,
blur: CGFloat = 0.0) {
ensureLayerBacked()
var filters: [CIFilter] = []
if blur > 0 {
if let f = CIFilter(name: "CIGaussianBlur") {
f.setValue(blur, forKey: kCIInputRadiusKey)
filters.append(f)
}
}
if brightness != 0 || saturation != 1.0 {
if let f = CIFilter(name: "CIColorControls") {
f.setValue(saturation, forKey: "inputSaturation")
f.setValue(brightness, forKey: "inputBrightness")
filters.append(f)
}
}
backingGlass.layer?.filters = filters.isEmpty ? nil : filters
}
// MARK: - Internals
private let contentHost = NSView(frame: .zero)
private var backingGlass: NSView!
private var rimLayer: CAShapeLayer?
public override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
// The root view must be layer-backed to host the rim layer.
wantsLayer = true
buildBacking()
updateRim()
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
// The root view must be layer-backed to host the rim layer.
wantsLayer = true
buildBacking()
updateRim()
}
public override func layout() {
super.layout()
// Update the rim's path whenever the view's size changes.
updateRimPath()
}
public override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
guard let s = superview, translatesAutoresizingMaskIntoConstraints == false else { return }
NSLayoutConstraint.activate([
leadingAnchor.constraint(equalTo: s.leadingAnchor),
trailingAnchor.constraint(equalTo: s.trailingAnchor),
topAnchor.constraint(equalTo: s.topAnchor),
bottomAnchor.constraint(equalTo: s.bottomAnchor)
])
}
// Build NSGlassEffectView if available, else NSVisualEffectView
private func buildBacking() {
let glass: NSView
if #available(macOS 26.0, *), let GlassType = NSClassFromString("NSGlassEffectView") as? NSView.Type {
let g = GlassType.init(frame: bounds)
g.translatesAutoresizingMaskIntoConstraints = false
glass = g
} else {
let v = NSVisualEffectView(frame: bounds)
v.translatesAutoresizingMaskIntoConstraints = false
v.material = .underWindowBackground
v.blendingMode = .behindWindow
v.state = .active
v.wantsLayer = true
v.layer?.masksToBounds = true
glass = v
}
backingGlass = glass
addSubview(backingGlass)
NSLayoutConstraint.activate([
backingGlass.leadingAnchor.constraint(equalTo: leadingAnchor),
backingGlass.trailingAnchor.constraint(equalTo: trailingAnchor),
backingGlass.topAnchor.constraint(equalTo: topAnchor),
backingGlass.bottomAnchor.constraint(equalTo: bottomAnchor)
])
// content host
contentHost.translatesAutoresizingMaskIntoConstraints = false
contentHost.wantsLayer = false
if backingGlass.responds(to: NSSelectorFromString("setContentView:")) {
backingGlass.setValue(contentHost, forKey: "contentView") // NSGlassEffectView path
} else {
backingGlass.addSubview(contentHost) // fallback path
NSLayoutConstraint.activate([
contentHost.leadingAnchor.constraint(equalTo: backingGlass.leadingAnchor),
contentHost.trailingAnchor.constraint(equalTo: backingGlass.trailingAnchor),
contentHost.topAnchor.constraint(equalTo: backingGlass.topAnchor),
contentHost.bottomAnchor.constraint(equalTo: backingGlass.bottomAnchor)
])
}
// Apply defaults
style = 1
cornerRadius = 14
tintColor = nil
}
private func applyStyle() {
if backingGlass.responds(to: NSSelectorFromString("setStyle:")) {
backingGlass.setValue(style, forKey: "style")
}
}
private func applyCornerRadius() {
if backingGlass.responds(to: NSSelectorFromString("setCornerRadius:")) {
backingGlass.setValue(cornerRadius, forKey: "cornerRadius")
} else {
ensureLayerBacked()
backingGlass.layer?.cornerRadius = cornerRadius
backingGlass.layer?.masksToBounds = true
}
}
private func applyTint() {
if backingGlass.responds(to: NSSelectorFromString("setTintColor:")) {
backingGlass.setValue(tintColor, forKey: "tintColor")
} else if let tint = tintColor {
ensureLayerBacked()
backingGlass.layer?.backgroundColor = tint.cgColor // very subtle on fallback
} else {
backingGlass.layer?.backgroundColor = nil
}
}
private func setPrivate(_ key: String, value: Any) {
guard backingGlass.responds(to: NSSelectorFromString("setContentView:")) else { return }
// Best-effort, swallow future changes safely
guard let glass = backingGlass else { return }
(try? ObjcKVC.setValue(value, forKey: key, on: glass)) ?? ()
}
private func ensureLayerBacked() {
if backingGlass.layer == nil {
backingGlass.wantsLayer = true
backingGlass.layer = CALayer()
}
}
/// Creates, removes, or configures the rim layer based on the `hasVibrantRim` property.
private func updateRim() {
if hasVibrantRim {
guard rimLayer == nil else { return } // Already created
let newRim = CAShapeLayer()
newRim.lineWidth = 1
newRim.strokeColor = NSColor(white: 1.0, alpha: 0.8).cgColor
newRim.fillColor = NSColor.clear.cgColor
// Configure the "vibrant" glow using the shadow property
newRim.shadowColor = NSColor.white.cgColor
newRim.shadowRadius = 1.0
newRim.shadowOpacity = 0.6
newRim.shadowOffset = .zero
self.layer?.addSublayer(newRim)
self.rimLayer = newRim
updateRimPath() // Set its initial path
} else {
// Remove the rim if it exists
rimLayer?.removeFromSuperlayer()
rimLayer = nil
}
}
/// Recalculates the rim's path to match the view's current bounds and corner radius.
private func updateRimPath() {
if #available(macOS 26.0, *) {
guard let rimLayer = rimLayer else { return }
// Inset the drawing rect by half the line width to keep the stroke centered on the edge
let insetRect = bounds.insetBy(dx: rimLayer.lineWidth / 2, dy: rimLayer.lineWidth / 2)
let path = NSBezierPath(roundedRect: insetRect, xRadius: cornerRadius, yRadius: cornerRadius)
rimLayer.path = path.cgPath
}
}
}
/// Tiny helper to avoid bridging crashes if KVC changes in future macOS versions
private enum ObjcKVC {
static func setValue(_ value: Any, forKey key: String, on object: Any) throws {
(object as AnyObject).setValue(value, forKey: key)
}
}
// MARK: - SwiftUI wrapper
public enum GUI { }
@available(macOS 26.0, *)
public struct CustomGlassEffectView<Content: View>: NSViewRepresentable {
private let variant: Int?
private let scrimState: Int?
private let subduedState: Int?
private let style: NSGlassEffectView.Style
private let tint: NSColor?
private let cornerRadius: CGFloat
private let hasVibrantRim: Bool
private let content: Content
public init(variant: Int? = nil,
scrimState: Int? = nil,
subduedState: Int? = nil,
style: NSGlassEffectView.Style = .clear,
tint: NSColor? = nil,
cornerRadius: CGFloat = 14,
hasVibrantRim: Bool = false,
@ViewBuilder content: () -> Content)
{
self.variant = variant
self.scrimState = scrimState
self.subduedState = subduedState
self.style = style
self.tint = tint
self.cornerRadius = cornerRadius
self.hasVibrantRim = hasVibrantRim
self.content = content()
}
public func makeNSView(context: Context) -> LiquidGlassView {
let v = LiquidGlassView.glass(withStyle: Int(style.rawValue),
cornerRadius: cornerRadius,
tintColor: tint)
v.hasVibrantRim = hasVibrantRim
let hosting = NSHostingView(rootView: content)
hosting.translatesAutoresizingMaskIntoConstraints = false
v.contentView = hosting
if let variant { v.setVariantIfAvailable(variant) }
if let scrimState { v.setScrimStateIfAvailable(scrimState) }
if let subduedState { v.setSubduedStateIfAvailable(subduedState) }
return v
}
public func updateNSView(_ v: LiquidGlassView, context: Context) {
if let hosting = v.contentView as? NSHostingView<Content> {
hosting.rootView = content
}
v.style = Int(style.rawValue)
v.cornerRadius = cornerRadius
v.tintColor = tint
v.hasVibrantRim = hasVibrantRim
if let variant { v.setVariantIfAvailable(variant) }
if let scrimState { v.setScrimStateIfAvailable(scrimState) }
if let subduedState { v.setSubduedStateIfAvailable(subduedState) }
}
}

View File

@ -0,0 +1,43 @@
//
// TahoeVolumeHUD.h
//
// Created by Andrea Alberti on 25.10.25.
//
#import <Cocoa/Cocoa.h>
// Do not import AppDelegate.h here to avoid circular imports.
// Forward-declare the types we only need by pointer.
@class TahoeVolumeHUD;
@class PlayerApplication;
NS_ASSUME_NONNULL_BEGIN
@protocol TahoeVolumeHUDDelegate <NSObject>
@optional
/// Called whenever the user changes the slider (0.01.0).
- (void)hud:(TahoeVolumeHUD *)hud didChangeVolume:(double)volume forPlayer:(PlayerApplication*)controlledPlayer;
/// Called whenever the user changes the slider (0.01.0) for the last time releasing focus
- (void)didChangeVolumeFinal:(TahoeVolumeHUD *)hud;
@end
/// A singleton, popover-like Tahoe glass HUD anchored to a status bar button.
@interface TahoeVolumeHUD : NSObject
@property (class, readonly, strong) TahoeVolumeHUD *sharedManager;
@property (weak, nonatomic, nullable) id<TahoeVolumeHUDDelegate> delegate;
/// Show/update the HUD under a status bar button. `volume` is 0.01.0 (or 0100; both accepted).
- (void)showHUDWithVolume:(double)volume usingMusicPlayer:(PlayerApplication*)controlledPlayer andLabel:(NSString*)label anchoredToStatusButton:(NSStatusBarButton *)button;
/// Programmatically hide it immediately.
- (void)hide;
/// Optional: update the slider programmatically without showing/hiding.
- (void)setVolume:(double)volume;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,439 @@
// FILE: TahoeVolumeHUD.m
#import "TahoeVolumeHUD.h"
#import "VolumeSliderCell.h"
#import <AppKit/NSGlassEffectView.h>
#import "HUDPanel.h"
#import "VolumeSlider.h"
#import "AppDelegate.h"
#import <QuartzCore/QuartzCore.h>
// Product Module Name: Volume_Control
#import "Volume_Control-Swift.h" // exposes LiquidGlassView to ObjC
// **IMPROVEMENT 1: Add VolumeSliderDelegate protocol conformance**
@interface TahoeVolumeHUD () <VolumeSliderDelegate>
// Window + layout
@property (strong) HUDPanel *panel;
@property (strong) NSView *root;
@property (strong) LiquidGlassView *glass;
// UI
// **IMPROVEMENT 2: Change property type to be more specific**
@property (strong) VolumeSlider *slider;
@property (strong) NSImageView *appIconView;
@property (strong) NSTimer *hideTimer;
@property (strong) NSTextField *titleLabel;
// Constraints
@property (strong) NSLayoutConstraint *contentFixedHeight;
// Player
@property (strong) PlayerApplication *controlledPlayer;
@end
// Tunables
static const CGFloat kHUDHeight = 64.0;
static const CGFloat kHUDWidth = 290.0;
static const CGFloat kCornerRadius = 24.0;
static const CGFloat kBelowGap = 14.0;
static const NSTimeInterval kAutoHide = 1.5;
static const CGFloat kSideInset = 12.0; // left/right margin
static const NSTimeInterval kFadeInDuration = 0.25; // seconds
static const NSTimeInterval kFadeOutDuration = 0.45; // seconds
@implementation TahoeVolumeHUD
+ (TahoeVolumeHUD *)sharedManager {
static TahoeVolumeHUD *instance;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[TahoeVolumeHUD alloc] initPrivate];
});
return instance;
}
- (instancetype)initPrivate {
self = [super init];
if (!self) return nil;
// ... (this part is correct) ...
// Panel
NSRect frame = NSMakeRect(0, 0, kHUDWidth, kHUDHeight);
_panel = [[HUDPanel alloc] initWithContentRect:frame
styleMask:(NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel)
backing:NSBackingStoreBuffered
defer:NO];
_panel.opaque = NO;
_panel.backgroundColor = NSColor.clearColor;
_panel.hasShadow = NO;
_panel.appearance = [NSAppearance appearanceNamed:NSAppearanceNameVibrantDark];
// Set to NO to prevent the panel from hiding when the app is not active.
_panel.hidesOnDeactivate = NO;
_panel.level = NSPopUpMenuWindowLevel;
_panel.movableByWindowBackground = NO;
// Ensure the panel can appear on any space.
// This makes it a true system-wide HUD.
_panel.collectionBehavior = NSWindowCollectionBehaviorTransient
| NSWindowCollectionBehaviorFullScreenAuxiliary
| NSWindowCollectionBehaviorCanJoinAllSpaces;
_panel.floatingPanel = YES;
_panel.becomesKeyOnlyIfNeeded = YES;
// Size fences
_panel.contentMinSize = NSMakeSize(kHUDWidth, kHUDHeight);
_panel.contentMaxSize = NSMakeSize(FLT_MAX, kHUDHeight);
[_panel setContentSize:NSMakeSize(kHUDWidth, kHUDHeight)];
// Root host
_root = [[NSView alloc] initWithFrame:_panel.contentView.bounds];
_root.translatesAutoresizingMaskIntoConstraints = NO;
_root.wantsLayer = YES;
_root.layer.backgroundColor = NSColor.clearColor.CGColor;
_panel.contentView = _root;
// Hard height on the window contentView guarantees kHUDHeight regardless of fitting sizes
self.contentFixedHeight = [_panel.contentView.heightAnchor constraintEqualToConstant:kHUDHeight];
self.contentFixedHeight.priority = 1000;
self.contentFixedHeight.active = YES;
[NSLayoutConstraint activateConstraints:@[
[_root.leadingAnchor constraintEqualToAnchor:_panel.contentView.leadingAnchor],
[_root.trailingAnchor constraintEqualToAnchor:_panel.contentView.trailingAnchor],
[_root.topAnchor constraintEqualToAnchor:_panel.contentView.topAnchor],
[_root.bottomAnchor constraintEqualToAnchor:_panel.contentView.bottomAnchor],
]];
// Glass (Swift class)
[self installGlassInto:_root cornerRadius:kCornerRadius];
// Content wrapper (fills the glass)
NSView *wrapper = [NSView new];
wrapper.translatesAutoresizingMaskIntoConstraints = NO;
// 1 of 3: Set the appearance for the entire content view.
// This forces all subviews (labels, icons) to use their dark variants.
wrapper.appearance = [NSAppearance appearanceNamed:NSAppearanceNameVibrantDark];
// Build header row (icon + label) and slider strip
NSView *header = [self buildHeaderRow];
NSView *strip = [self buildSliderStrip];
[wrapper addSubview:header];
[wrapper addSubview:strip];
// Anchor header to top, strip to bottom. This is more robust.
[NSLayoutConstraint activateConstraints:@[
[header.topAnchor constraintEqualToAnchor:wrapper.topAnchor],
[header.leadingAnchor constraintEqualToAnchor:wrapper.leadingAnchor],
[header.trailingAnchor constraintEqualToAnchor:wrapper.trailingAnchor],
[strip.bottomAnchor constraintEqualToAnchor:wrapper.bottomAnchor],
[strip.leadingAnchor constraintEqualToAnchor:wrapper.leadingAnchor],
[strip.trailingAnchor constraintEqualToAnchor:wrapper.trailingAnchor],
]];
// Install in glass (wrapper stretches to the glass edges via LiquidGlassView)
self.glass.contentView = wrapper;
return self;
}
#pragma mark - Public API
- (void)showHUDWithVolume:(double)volume usingMusicPlayer:(PlayerApplication*)player andLabel:(NSString*)label anchoredToStatusButton:(NSStatusBarButton *)button {
self.controlledPlayer = player;
if (volume > 1.0) volume = MAX(0.0, MIN(1.0, volume / 100.0));
self.slider.doubleValue = volume;
// Update header
self.appIconView.image = [player icon];
self.titleLabel.stringValue = label;
// Size fence each time
[_panel setContentSize:NSMakeSize(kHUDWidth, kHUDHeight)];
[self positionPanelBelowStatusButton:button];
// Animate the fade-in
// 1. If the panel is already visible, just update it.
// Otherwise, prepare for a fade-in animation.
if (!self.panel.isVisible) {
// Set the panel to be fully transparent before showing it
self.panel.alphaValue = 0.0;
[self.panel orderFront:nil];
}
// 2. Animate the alpha value to 1.0 (fully opaque)
[NSAnimationContext runAnimationGroup:^(NSAnimationContext * _Nonnull context) {
context.duration = kFadeInDuration;
context.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut];
[[self.panel animator] setAlphaValue:1.0];
}];
// Reset the auto-hide timer
[self.hideTimer invalidate];
self.hideTimer = [NSTimer scheduledTimerWithTimeInterval:kAutoHide
target:self
selector:@selector(hide)
userInfo:nil
repeats:NO];
}
- (void)setVolume:(double)volume {
if (volume > 1.0) volume = MAX(0.0, MIN(1.0, volume / 100.0));
self.slider.doubleValue = volume;
}
- (void)hide {
// Invalidate the timer to prevent this method from being called again
[self.hideTimer invalidate];
self.hideTimer = nil;
// Animate the fade-out
// 1. Animate the alpha value down to 0.0 (fully transparent)
[NSAnimationContext runAnimationGroup:^(NSAnimationContext * _Nonnull context) {
context.duration = kFadeOutDuration;
context.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
[[self.panel animator] setAlphaValue:0.0];
} completionHandler:^{
// 2. AFTER the animation is complete, properly order the window out.
// This is crucial for performance and correctness.
[self.panel orderOut:nil];
}];
}
#pragma mark - Layout / Anchoring
- (void)positionPanelBelowStatusButton:(NSStatusBarButton *)button {
if (!button || !button.window) {
NSScreen *screen = NSScreen.mainScreen ?: NSScreen.screens.firstObject;
if (screen) {
NSRect vis = screen.visibleFrame;
CGFloat x = NSMidX(vis) - self.panel.frame.size.width/2.0;
CGFloat y = NSMidY(vis) - self.panel.frame.size.height/2.0;
[self.panel setFrameOrigin:NSMakePoint(round(x), round(y))];
}
return;
}
NSRect buttonRectInWindow = [button convertRect:button.bounds toView:nil];
NSRect buttonInScreen = [button.window convertRectToScreen:buttonRectInWindow];
NSSize size = NSMakeSize(kHUDWidth, kHUDHeight);
CGFloat x = NSMidX(buttonInScreen) - size.width / 2.0;
CGFloat y = NSMinY(buttonInScreen) - size.height - kBelowGap;
NSScreen *target = button.window.screen ?: NSScreen.mainScreen;
NSRect vis = target.visibleFrame;
if (y < NSMinY(vis)) y = NSMinY(vis) + 2.0;
CGFloat margin = 8.0;
x = MAX(NSMinX(vis) + margin, MIN(x, NSMaxX(vis) - margin - size.width));
[self.panel setFrame:NSMakeRect(round(x), round(y), size.width, size.height) display:NO];
}
#pragma mark - Glass
- (void)installGlassInto:(NSView *)host cornerRadius:(CGFloat)radius {
if (@available(macOS 26.0, *)) {
LiquidGlassView *glass = [LiquidGlassView glassWithStyle:NSGlassEffectViewStyleClear // Clear
cornerRadius:radius
tintColor:[NSColor colorWithCalibratedWhite:0 alpha:1]];
self.glass = glass;
// Enable the new vibrant rim here.
glass.hasVibrantRim = NO;
[host addSubview:glass];
glass.translatesAutoresizingMaskIntoConstraints = NO;
[NSLayoutConstraint activateConstraints:@[
[glass.leadingAnchor constraintEqualToAnchor:host.leadingAnchor],
[glass.trailingAnchor constraintEqualToAnchor:host.trailingAnchor],
[glass.topAnchor constraintEqualToAnchor:host.topAnchor],
[glass.bottomAnchor constraintEqualToAnchor:host.bottomAnchor],
]];
// Optional Tahoe tuning:
[glass setVariantIfAvailable:5];
[glass setScrimStateIfAvailable:0];
[glass setSubduedStateIfAvailable:0];
// Setting adaptive appearance to 0 is the key to keeping it dark.
[glass setAdaptiveAppearanceIfAvailable:0];
[glass setUseReducedShadowRadiusIfAvailable:YES];
[glass setContentLensingIfAvailable:0];
} else {
// Fallback on earlier versions
}
// Optional SwiftUI-like post-filters:
//[glass applyVisualAdjustmentsWithSaturation:1.5 brightness:0.2 blur:0.25];
}
#pragma mark - Content
// In TahoeVolumeHUD.m
- (NSView *)buildSliderStrip {
NSView *strip = [NSView new];
strip.translatesAutoresizingMaskIntoConstraints = NO;
// Left and Right speaker glyphs are correct
NSImageView *iconLeft = [NSImageView new];
iconLeft.translatesAutoresizingMaskIntoConstraints = NO;
iconLeft.symbolConfiguration = [NSImageSymbolConfiguration configurationWithPointSize:14 weight:NSFontWeightSemibold];
iconLeft.image = [NSImage imageWithSystemSymbolName:@"speaker.fill" accessibilityDescription:nil];
iconLeft.contentTintColor = NSColor.labelColor;
[iconLeft setContentHuggingPriority:251 forOrientation:NSLayoutConstraintOrientationHorizontal];
[iconLeft setContentCompressionResistancePriority:751 forOrientation:NSLayoutConstraintOrientationHorizontal];
NSImageView *iconRight = [NSImageView new];
iconRight.translatesAutoresizingMaskIntoConstraints = NO;
iconRight.symbolConfiguration = [NSImageSymbolConfiguration configurationWithPointSize:14 weight:NSFontWeightSemibold];
iconRight.image = [NSImage imageWithSystemSymbolName:@"speaker.wave.2.fill" accessibilityDescription:nil];
iconRight.contentTintColor = NSColor.labelColor;
[iconRight setContentHuggingPriority:251 forOrientation:NSLayoutConstraintOrientationHorizontal];
[iconRight setContentCompressionResistancePriority:751 forOrientation:NSLayoutConstraintOrientationHorizontal];
// Create the slider
VolumeSlider *slider = [VolumeSlider new]; // Changed from HoverSlider
slider.minValue = 0.0;
slider.maxValue = 1.0;
slider.doubleValue = 0.6;
slider.trackingDelegate = self;
slider.translatesAutoresizingMaskIntoConstraints = NO;
slider.controlSize = NSControlSizeSmall;
// Assign our custom cell that handles all the drawing
VolumeSliderCell *cell = [VolumeSliderCell new]; // Changed from CustomVolumeSlider
cell.minValue = 0.0;
cell.maxValue = 1.0;
cell.controlSize = NSControlSizeSmall;
slider.cell = cell;
[slider setContentHuggingPriority:1 forOrientation:NSLayoutConstraintOrientationHorizontal];
[slider setContentCompressionResistancePriority:1 forOrientation:NSLayoutConstraintOrientationHorizontal];
self.slider = slider;
// Add views to the strip - NO MORE trackBackgroundView
[strip addSubview:iconLeft];
[strip addSubview:slider];
[strip addSubview:iconRight];
// Set up constraints
[NSLayoutConstraint activateConstraints:@[
[strip.heightAnchor constraintEqualToConstant:36],
[iconLeft.leadingAnchor constraintEqualToAnchor:strip.leadingAnchor constant:12],
[iconLeft.centerYAnchor constraintEqualToAnchor:strip.centerYAnchor],
[iconRight.trailingAnchor constraintEqualToAnchor:strip.trailingAnchor constant:-12],
[iconRight.centerYAnchor constraintEqualToAnchor:strip.centerYAnchor],
// Slider is now constrained directly between the icons
[slider.leadingAnchor constraintEqualToAnchor:iconLeft.trailingAnchor constant:8],
[slider.trailingAnchor constraintEqualToAnchor:iconRight.leadingAnchor constant:-8],
[slider.centerYAnchor constraintEqualToAnchor:strip.centerYAnchor],
]];
return strip;
}
- (NSView *)buildHeaderRow {
NSView *row = [NSView new];
row.translatesAutoresizingMaskIntoConstraints = NO;
// Icon (uses the same appIconView instance so other code can update it)
self.appIconView = [NSImageView new];
self.appIconView.translatesAutoresizingMaskIntoConstraints = NO;
self.appIconView.imageScaling = NSImageScaleProportionallyUpOrDown;
self.appIconView.wantsLayer = YES;
self.appIconView.layer.cornerRadius = 6.0;
self.appIconView.layer.masksToBounds = YES;
// Title label
self.titleLabel = [NSTextField labelWithString:@"Place holder"];
self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
self.titleLabel.font = [NSFont systemFontOfSize:12 weight:NSFontWeightSemibold];
self.titleLabel.textColor = [NSColor labelColor];
[row addSubview:self.appIconView];
[row addSubview:self.titleLabel];
// New constraints for better padding and alignment.
CGFloat topPadding = 12.0; // Increased to provide more space at the top.
CGFloat bottomPadding = 4.0; // Defines space between header and slider strip.
[NSLayoutConstraint activateConstraints:@[
// Icon constraints define the layout and padding for the header row.
[self.appIconView.leadingAnchor constraintEqualToAnchor:row.leadingAnchor constant:kSideInset],
[self.appIconView.topAnchor constraintEqualToAnchor:row.topAnchor constant:topPadding],
[self.appIconView.widthAnchor constraintEqualToConstant:18],
[self.appIconView.heightAnchor constraintEqualToConstant:18],
// The header row's height is determined by the icon's position and its own padding.
[row.bottomAnchor constraintEqualToAnchor:self.appIconView.bottomAnchor constant:bottomPadding],
// Title label is positioned relative to the icon.
[self.titleLabel.leadingAnchor constraintEqualToAnchor:self.appIconView.trailingAnchor constant:8],
[self.titleLabel.centerYAnchor constraintEqualToAnchor:self.appIconView.centerYAnchor],
[self.titleLabel.trailingAnchor constraintLessThanOrEqualToAnchor:row.trailingAnchor constant:-kSideInset],
]];
// Good contrast on glass
// row.appearance = [NSAppearance appearanceNamed:NSAppearanceNameVibrantDark];
return row;
}
#pragma mark - VolumeSliderDelegate
// 5. IMPLEMENT the new delegate methods.
- (void)volumeSlider:(VolumeSlider *)slider didChangeValue:(double)value {
// This is now the method that gets called during a drag.
// Selector to match the protocol definition.
if ([self.delegate respondsToSelector:@selector(hud:didChangeVolume:forPlayer:)]) {
[self.delegate hud:self didChangeVolume:value forPlayer:self.controlledPlayer];
}
// Reset the auto-hide timer on every value change.
[self.hideTimer invalidate];
self.hideTimer = [NSTimer scheduledTimerWithTimeInterval:1.2
target:self
selector:@selector(hide)
userInfo:nil
repeats:NO];
}
- (void)volumeSliderDidEndDragging:(VolumeSlider *)slider {
if ([self.delegate respondsToSelector:@selector(didChangeVolumeFinal:)]) {
[self.delegate didChangeVolumeFinal:self];
}
// You might also want to reset the hide timer here with a standard delay.
[self.hideTimer invalidate];
self.hideTimer = [NSTimer scheduledTimerWithTimeInterval:kAutoHide
target:self
selector:@selector(hide)
userInfo:nil
repeats:NO];
}
@end

View File

@ -0,0 +1,31 @@
//
// VolumeSlider.h
// Volume Control
//
// Created by Andrea Alberti on 26.10.25.
//
#import <Cocoa/Cocoa.h>
@class VolumeSlider;
NS_ASSUME_NONNULL_BEGIN
// 1. Define a delegate protocol to communicate value changes.
@protocol VolumeSliderDelegate <NSObject>
- (void)volumeSlider:(VolumeSlider *)slider didChangeValue:(double)value;
- (void)volumeSliderDidEndDragging:(VolumeSlider *)slider;
@end
/**
An NSSlider subclass that detects mouse hover events and manually handles dragging
to ensure it works within a non-activating panel.
*/
@interface VolumeSlider : NSSlider
// 2. Add a delegate property.
@property (nonatomic, weak) id<VolumeSliderDelegate> trackingDelegate;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,84 @@
//
// HoverSlider.m
// Volume Control
//
// Created by Andrea Alberti on 26.10.25.
//
#import "VolumeSlider.h"
#import "VolumeSliderCell.h" // We need this to access the 'isHovered' property
@implementation VolumeSlider
// This method sets up the tracking area that allows us to receive mouse events.
- (void)updateTrackingAreas {
[super updateTrackingAreas];
// Remove any old tracking areas to prevent duplicates
for (NSTrackingArea *area in self.trackingAreas) {
[self removeTrackingArea:area];
}
// Tracking always active
NSTrackingAreaOptions options = NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways;
NSTrackingArea *trackingArea = [[NSTrackingArea alloc] initWithRect:self.bounds
options:options
owner:self
userInfo:nil];
[self addTrackingArea:trackingArea];
}
// Called when the mouse cursor enters the slider's bounds.
- (void)mouseEntered:(NSEvent *)event {
[super mouseEntered:event];
if ([self.cell isKindOfClass:[VolumeSliderCell class]]) {
// Tell our custom cell that it's being hovered
((VolumeSliderCell *)self.cell).isHovered = YES;
// Trigger a redraw to show the knob
[self setNeedsDisplay:YES];
}
}
// Called when the mouse cursor leaves the slider's bounds.
- (void)mouseExited:(NSEvent *)event {
[super mouseExited:event];
if ([self.cell isKindOfClass:[VolumeSliderCell class]]) {
// Tell our custom cell that the hover is over
((VolumeSliderCell *)self.cell).isHovered = NO;
// Trigger a redraw to hide the knob
[self setNeedsDisplay:YES];
}
}
- (void)updateValueWithEvent:(NSEvent *)event {
// Convert the mouse click location to a point within the slider's bounds.
NSPoint point = [self convertPoint:[event locationInWindow] fromView:nil];
// Calculate the percentage value based on the horizontal position.
CGFloat percentage = point.x / self.bounds.size.width;
// Clamp the value between 0.0 and 1.0.
percentage = MAX(0.0, MIN(1.0, percentage));
// Calculate the actual slider value based on its min/max range.
double newValue = self.minValue + (percentage * (self.maxValue - self.minValue));
// Update the slider's own value and notify the delegate.
self.doubleValue = newValue;
[self.trackingDelegate volumeSlider:self didChangeValue:self.doubleValue];
}
- (void)mouseDown:(NSEvent *)event {
[self updateValueWithEvent:event];
}
- (void)mouseDragged:(NSEvent *)event {
[self updateValueWithEvent:event];
}
- (void)mouseUp:(NSEvent *)event {
[self.trackingDelegate volumeSliderDidEndDragging:self];
}
@end

View File

@ -0,0 +1,15 @@
//
// CustomVolumeSlider.h
// Volume Control
//
// Created by Andrea Alberti on 25.10.25.
//
#import <AppKit/NSSliderCell.h>
@interface VolumeSliderCell : NSSliderCell
// This property will control whether the knob is visible.
@property (nonatomic, assign) BOOL isHovered;
@end

View File

@ -0,0 +1,45 @@
//
// CustomVolumeSlider.m
// Volume Control
//
// Created by Andrea Alberti on 25.10.25.
//
#import "VolumeSliderCell.h"
#import <AppKit/NSColor.h>
#import <AppKit/NSBezierPath.h>
@implementation VolumeSliderCell
- (void)drawBarInside:(NSRect)rect flipped:(BOOL)flipped {
// This rect is the area for the bar. We'll make it 4pt tall and centered.
rect = NSInsetRect(rect, 0, (NSHeight(rect) - 4.0) / 2.0);
// 1. Draw the background "track"
[[NSColor colorWithWhite:1.0 alpha:0.25] setFill];
NSBezierPath *backgroundPath = [NSBezierPath bezierPathWithRoundedRect:rect xRadius:2 yRadius:2];
[backgroundPath fill];
// 2. Calculate the width of the "filled" portion
CGFloat percentage = (self.doubleValue - self.minValue) / (self.maxValue - self.minValue);
NSRect fillRect = rect;
fillRect.size.width = round(NSWidth(rect) * percentage);
// 3. Draw the active "fill" portion
[[NSColor colorWithWhite:1.0 alpha:0.85] setFill];
NSBezierPath *fillPath = [NSBezierPath bezierPathWithRoundedRect:fillRect xRadius:2 yRadius:2];
[fillPath fill];
}
- (void)drawKnob:(NSRect)knobRect {
if (self.isHovered) {
/*
CGFloat d = 12;
knobRect = NSMakeRect(NSMidX(knobRect)-d/2.0, NSMidY(knobRect)-d/2.0, d, d);
[[NSColor whiteColor] setFill];
NSBezierPath *circle = [NSBezierPath bezierPathWithRoundedRect:knobRect xRadius:d/2 yRadius:d/2];
[circle fill];
*/
[super drawKnob:knobRect]; // Draws Apple's standard system knob
}
}
@end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1002 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 905 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 911 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Some files were not shown because too many files have changed in this diff Show More