Added HUD for Tahoe
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -641,12 +646,14 @@ static NSTimeInterval updateSystemVolumeInterval=0.1f;
|
|||
if (_LockSystemAndPlayerVolume && runningPlayerPtr != systemAudio) {
|
||||
[systemAudio setCurrentVolume:[systemAudio oldVolume]];
|
||||
}
|
||||
|
||||
if(!_hideVolumeWindow)
|
||||
|
||||
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 16.0, *)) {
|
||||
iTunes = [[PlayerApplication alloc] initWithBundleIdentifier:@"com.apple.Music" andIcon:[NSImage imageNamed:@"AppleMusicTahoe"]];
|
||||
} else {
|
||||
iTunes = [[PlayerApplication alloc] initWithBundleIdentifier:@"com.apple.iTunes" andIcon:[NSImage imageNamed:@"AppleMusicSequoia"]];
|
||||
}
|
||||
|
||||
spotify = [[PlayerApplication alloc] initWithBundleIdentifier:@"com.spotify.client" andIcon:[NSImage imageNamed:@"spotify"]];
|
||||
|
||||
if (@available(macOS 10.15, *)) {
|
||||
iTunes = [[PlayerApplication alloc] initWithBundleIdentifier:@"com.apple.Music"];
|
||||
} else {
|
||||
iTunes = [[PlayerApplication alloc] initWithBundleIdentifier:@"com.apple.iTunes"];
|
||||
}
|
||||
|
||||
spotify = [[PlayerApplication alloc] initWithBundleIdentifier:@"com.spotify.client"];
|
||||
|
||||
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 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);
|
||||
|
|
@ -1181,10 +1181,11 @@ static NSTimeInterval updateSystemVolumeInterval=0.1f;
|
|||
|
||||
//NSLog(@"%d %d",(int)numFullBlks,(int)numQrtsBlks);
|
||||
|
||||
if(!_hideVolumeWindow)
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
// FILE: HUDPanel.h
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@interface HUDPanel : NSPanel
|
||||
@end
|
||||
|
|
@ -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 won’t steal app focus.
|
||||
- (BOOL)worksWhenModal { return YES; }
|
||||
|
||||
@end
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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.0–1.0).
|
||||
- (void)hud:(TahoeVolumeHUD *)hud didChangeVolume:(double)volume forPlayer:(PlayerApplication*)controlledPlayer;
|
||||
/// Called whenever the user changes the slider (0.0–1.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.0–1.0 (or 0–100; 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1002 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 905 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 888 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 911 B |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 518 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 855 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 477 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 822 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 901 B |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |