From 8d1b4f9b6edcaa14728e0c1469dbf61ddf90f9d8 Mon Sep 17 00:00:00 2001 From: Mario Kozjak Date: Wed, 25 Mar 2026 09:51:33 +0100 Subject: [PATCH 1/4] vibe-code cli tool for volume osd Signed-off-by: Mario Kozjak --- OSD/Info.plist | 30 +++ Sources/CLI/main_cli.m | 224 +++++++++++++++++++++++ Sources/Controllers/AppDelegate.m | 6 +- User interface/HUD/TahoeVolumeHUD.h | 17 +- User interface/HUD/TahoeVolumeHUD.m | 88 ++++++--- Volume Control.xcodeproj/project.pbxproj | 222 +++++++++++++++++++++- 6 files changed, 553 insertions(+), 34 deletions(-) create mode 100644 OSD/Info.plist create mode 100644 Sources/CLI/main_cli.m diff --git a/OSD/Info.plist b/OSD/Info.plist new file mode 100644 index 0000000..cb4be67 --- /dev/null +++ b/OSD/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + \ No newline at end of file diff --git a/Sources/CLI/main_cli.m b/Sources/CLI/main_cli.m new file mode 100644 index 0000000..d27fc5d --- /dev/null +++ b/Sources/CLI/main_cli.m @@ -0,0 +1,224 @@ +// +// main_cli.m +// volume-control-osd +// +// CLI entry point. Parses --volume <0-100>, --title , and --position, +// shows the TahoeVolumeHUD on screen, then exits once the HUD has finished +// its fade-out animation. +// +// Usage: +// volume-control-osd --volume 42 --title "Bluesound" +// volume-control-osd --volume 75 --position top-right +// volume-control-osd --help +// + +#import +#import "TahoeVolumeHUD.h" + +// --------------------------------------------------------------------------- +// Minimal PlayerApplication stand-in that carries only what TahoeVolumeHUD +// actually uses: icon (nil → no icon shown) and the numeric volume fields. +// --------------------------------------------------------------------------- +@interface CLIPlayerApplication : NSObject +@property (nonatomic, strong, nullable) NSImage *icon; +@property (nonatomic, assign) double currentVolume; +@property (nonatomic, assign) double oldVolume; +@property (nonatomic, assign) double doubleVolume; +- (BOOL)isRunning; +- (NSInteger)playerState; +@end + +@implementation CLIPlayerApplication +- (BOOL)isRunning { return NO; } +- (NSInteger)playerState { return 0; } +@end + +// --------------------------------------------------------------------------- +// Minimal app delegate – keeps NSApp alive just long enough for the HUD +// fade-in + hold + fade-out cycle, then quits cleanly. +// --------------------------------------------------------------------------- + +// Total lifetime = fade-in (0.25 s) + hold (1.5 s) + fade-out (0.45 s) + margin +static const NSTimeInterval kTotalLifetime = 0.25 + 1.5 + 0.45 + 0.15; + +@interface CLIAppDelegate : NSObject +@property (nonatomic, assign) double volume; // 0.0 – 1.0 +@property (nonatomic, copy) NSString *title; +@property (nonatomic, assign) HUDPosition position; +@end + +@implementation CLIAppDelegate + +- (void)applicationDidFinishLaunching:(NSNotification *)notification { + // Activate the app so the window server sets up a compositing surface for + // this process. Without this, NSGlassEffectView / NSVisualEffectView cannot + // see through the window to the content behind it and renders opaque/solid. + [NSApp activateIgnoringOtherApps:YES]; + + // Show the HUD. Passing nil for the status button → centers on screen. + CLIPlayerApplication *player = [[CLIPlayerApplication alloc] init]; + player.currentVolume = self.volume; + player.doubleVolume = self.volume; + + [[TahoeVolumeHUD sharedManager] showHUDWithVolume:self.volume + usingMusicPlayer:(PlayerApplication *)player + andLabel:self.title + anchoredToStatusButton:nil + position:self.position]; + + // Schedule app termination after the full HUD lifecycle has elapsed. + [NSTimer scheduledTimerWithTimeInterval:kTotalLifetime + target:self + selector:@selector(quit) + userInfo:nil + repeats:NO]; +} + +- (void)quit { + [NSApp terminate:nil]; +} + +// Prevent the app from dying immediately if it has no windows. +- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender { + return NO; +} + +@end + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static void printUsage(const char *progname) { + fprintf(stdout, + "Usage: %s --volume <0-100> [--title ] [--position ]\n" + "\n" + "Options:\n" + " --volume Volume level from 0 to 100 (required)\n" + " --title Label shown in the HUD (default: \"Volume\")\n" + " --position Where to show the HUD (default: top-center)\n" + " top-left | top-center | top-right\n" + " center-left | center | center-right\n" + " bottom-left | bottom-center | bottom-right\n" + " --help Show this help message\n" + "\n" + "Example:\n" + " %s --volume 42 --title Bluesound --position top-right\n", + progname, progname); +} + +static BOOL parsePosition(const char *str, HUDPosition *outPosition) { + NSString *s = [@(str) lowercaseString]; + NSDictionary *map = @{ + @"top-left" : @(HUDPositionTopLeft), + @"top-center" : @(HUDPositionTopCenter), + @"top-right" : @(HUDPositionTopRight), + @"center-left" : @(HUDPositionCenterLeft), + @"center" : @(HUDPositionCenter), + @"center-right" : @(HUDPositionCenterRight), + @"bottom-left" : @(HUDPositionBottomLeft), + @"bottom-center" : @(HUDPositionBottomCenter), + @"bottom-right" : @(HUDPositionBottomRight), + }; + NSNumber *val = map[s]; + if (!val) return NO; + *outPosition = (HUDPosition)val.integerValue; + return YES; +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +int main(int argc, char *argv[]) { + @autoreleasepool { + + // ---- Parse arguments ---- + double volume = -1.0; + NSString *title = @"Volume"; + HUDPosition position = HUDPositionTopCenter; + BOOL gotVolume = NO; + + for (int i = 1; i < argc; i++) { + NSString *arg = @(argv[i]); + + if ([arg isEqualToString:@"--help"] || [arg isEqualToString:@"-h"]) { + printUsage(argv[0]); + return 0; + } + + if ([arg isEqualToString:@"--volume"] || [arg isEqualToString:@"-v"]) { + if (i + 1 >= argc) { + fprintf(stderr, "error: --volume requires a numeric argument.\n"); + printUsage(argv[0]); + return 1; + } + i++; + char *end = NULL; + double val = strtod(argv[i], &end); + if (end == argv[i] || *end != '\0') { + fprintf(stderr, "error: --volume value '%s' is not a number.\n", argv[i]); + return 1; + } + if (val < 0.0 || val > 100.0) { + fprintf(stderr, "error: --volume must be between 0 and 100 (got %.4g).\n", val); + return 1; + } + volume = val / 100.0; // normalise to 0-1 + gotVolume = YES; + continue; + } + + if ([arg isEqualToString:@"--title"] || [arg isEqualToString:@"-t"]) { + if (i + 1 >= argc) { + fprintf(stderr, "error: --title requires a string argument.\n"); + printUsage(argv[0]); + return 1; + } + i++; + title = @(argv[i]); + continue; + } + + if ([arg isEqualToString:@"--position"] || [arg isEqualToString:@"-p"]) { + if (i + 1 >= argc) { + fprintf(stderr, "error: --position requires a position argument.\n"); + printUsage(argv[0]); + return 1; + } + i++; + if (!parsePosition(argv[i], &position)) { + fprintf(stderr, "error: unknown position '%s'.\n", argv[i]); + printUsage(argv[0]); + return 1; + } + continue; + } + + fprintf(stderr, "error: unknown argument '%s'.\n", argv[i]); + printUsage(argv[0]); + return 1; + } + + if (!gotVolume) { + fprintf(stderr, "error: --volume is required.\n"); + printUsage(argv[0]); + return 1; + } + + // ---- Bootstrap NSApplication ---- + // We need a proper NSApplication run loop for AppKit UI (windows, animations). + // NSApplicationActivationPolicyAccessory keeps us off the Dock/menu bar. + NSApplication *app = [NSApplication sharedApplication]; + [app setActivationPolicy:NSApplicationActivationPolicyAccessory]; + + CLIAppDelegate *delegate = [[CLIAppDelegate alloc] init]; + delegate.volume = volume; + delegate.title = title; + delegate.position = position; + app.delegate = delegate; + + [app run]; + } + return 0; +} \ No newline at end of file diff --git a/Sources/Controllers/AppDelegate.m b/Sources/Controllers/AppDelegate.m index f44c86c..e6e5559 100644 --- a/Sources/Controllers/AppDelegate.m +++ b/Sources/Controllers/AppDelegate.m @@ -629,7 +629,7 @@ - (void)MuteVol if(!_hideVolumeWindow){ if (@available(macOS 16.0, *)) { // On Tahoe, show the new popover HUD. - [[TahoeVolumeHUD sharedManager] showHUDWithVolume:0 usingMusicPlayer:runningPlayerPtr andLabel:[systemAudio getDefaultOutputDeviceName] anchoredToStatusButton:self.statusBar.button]; + [[TahoeVolumeHUD sharedManager] showHUDWithVolume:0 usingMusicPlayer:runningPlayerPtr andLabel:[systemAudio getDefaultOutputDeviceName] anchoredToStatusButton:self.statusBar.button position:HUDPositionTopCenter]; } else { // On older systems, use the classic OSD. id osdMgr = [self->OSDManager sharedManager]; @@ -651,7 +651,7 @@ - (void)MuteVol { if (@available(macOS 16.0, *)) { // On Tahoe, show the new popover HUD. - [[TahoeVolumeHUD sharedManager] showHUDWithVolume:[runningPlayerPtr oldVolume] usingMusicPlayer:runningPlayerPtr andLabel:[systemAudio getDefaultOutputDeviceName] anchoredToStatusButton:self.statusBar.button]; + [[TahoeVolumeHUD sharedManager] showHUDWithVolume:[runningPlayerPtr oldVolume] usingMusicPlayer:runningPlayerPtr andLabel:[systemAudio getDefaultOutputDeviceName] anchoredToStatusButton:self.statusBar.button position:HUDPositionTopCenter]; } else { // On older systems, use the classic OSD. id osdMgr = [self->OSDManager sharedManager]; @@ -1185,7 +1185,7 @@ - (void)setVolumeUp:(bool)increase { if (@available(macOS 16.0, *)) { // 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]; + [[TahoeVolumeHUD sharedManager] showHUDWithVolume:volume usingMusicPlayer:runningPlayerPtr andLabel:[systemAudio getDefaultOutputDeviceName] anchoredToStatusButton:self.statusBar.button position:HUDPositionTopCenter]; } else { if(image) { id osdMgr = [self->OSDManager sharedManager]; diff --git a/User interface/HUD/TahoeVolumeHUD.h b/User interface/HUD/TahoeVolumeHUD.h index 8d93bb2..626b470 100644 --- a/User interface/HUD/TahoeVolumeHUD.h +++ b/User interface/HUD/TahoeVolumeHUD.h @@ -12,6 +12,20 @@ @class TahoeVolumeHUD; @class PlayerApplication; +/// Where to place the HUD on screen when not anchored to a status-bar button. +/// The nine values form a 3×3 grid; the default is TopCenter (below the menu bar). +typedef NS_ENUM(NSInteger, HUDPosition) { + HUDPositionTopLeft = 0, + HUDPositionTopCenter = 1, + HUDPositionTopRight = 2, + HUDPositionCenterLeft = 3, + HUDPositionCenter = 4, + HUDPositionCenterRight = 5, + HUDPositionBottomLeft = 6, + HUDPositionBottomCenter = 7, + HUDPositionBottomRight = 8, +}; + NS_ASSUME_NONNULL_BEGIN @protocol TahoeVolumeHUDDelegate @@ -30,7 +44,8 @@ NS_ASSUME_NONNULL_BEGIN @property (weak, nonatomic, nullable) id 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; +/// When `button` is nil the HUD is placed on screen according to `position`. +- (void)showHUDWithVolume:(double)volume usingMusicPlayer:(nullable PlayerApplication*)controlledPlayer andLabel:(NSString*)label anchoredToStatusButton:(nullable NSStatusBarButton *)button position:(HUDPosition)position; /// Programmatically hide it immediately. - (void)hide; diff --git a/User interface/HUD/TahoeVolumeHUD.m b/User interface/HUD/TahoeVolumeHUD.m index df84580..15604d1 100644 --- a/User interface/HUD/TahoeVolumeHUD.m +++ b/User interface/HUD/TahoeVolumeHUD.m @@ -149,20 +149,20 @@ - (instancetype)initPrivate { #pragma mark - Public API -- (void)showHUDWithVolume:(double)volume usingMusicPlayer:(PlayerApplication*)player andLabel:(NSString*)label anchoredToStatusButton:(NSStatusBarButton *)button { +- (void)showHUDWithVolume:(double)volume usingMusicPlayer:(nullable PlayerApplication*)player andLabel:(NSString*)label anchoredToStatusButton:(nullable NSStatusBarButton *)button position:(HUDPosition)position { 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.appIconView.image = player ? [player icon] : nil; self.titleLabel.stringValue = label; // Size fence each time [_panel setContentSize:NSMakeSize(kHUDWidth, kHUDHeight)]; - [self positionPanelBelowStatusButton:button]; + [self positionPanel:button position:position]; // Animate the fade-in @@ -216,32 +216,72 @@ - (void)hide { } #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))]; - } +- (void)positionPanel:(nullable NSStatusBarButton *)button position:(HUDPosition)position { + // If anchored to a real status-bar button, keep the original drop-down behaviour. + if (button && button.window) { + 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]; return; } - NSRect buttonRectInWindow = [button convertRect:button.bounds toView:nil]; - NSRect buttonInScreen = [button.window convertRectToScreen:buttonRectInWindow]; + // No status button — place the HUD according to the requested position. + NSScreen *screen = NSScreen.mainScreen ?: NSScreen.screens.firstObject; + if (!screen) return; + NSRect vis = screen.visibleFrame; 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; + static const CGFloat kEdgeMargin = 24.0; + + CGFloat x, y; + + // Horizontal component + switch (position) { + case HUDPositionTopLeft: + case HUDPositionCenterLeft: + case HUDPositionBottomLeft: + x = NSMinX(vis) + kEdgeMargin; + break; + case HUDPositionTopRight: + case HUDPositionCenterRight: + case HUDPositionBottomRight: + x = NSMaxX(vis) - size.width - kEdgeMargin; + break; + default: // center column + x = NSMidX(vis) - size.width / 2.0; + break; + } - CGFloat margin = 8.0; - x = MAX(NSMinX(vis) + margin, MIN(x, NSMaxX(vis) - margin - size.width)); + // Vertical component + switch (position) { + case HUDPositionTopLeft: + case HUDPositionTopCenter: + case HUDPositionTopRight: + // Place just below the menu bar (top of visibleFrame is already below it) + y = NSMaxY(vis) - size.height - kEdgeMargin; + break; + case HUDPositionBottomLeft: + case HUDPositionBottomCenter: + case HUDPositionBottomRight: + y = NSMinY(vis) + kEdgeMargin; + break; + default: // middle row + y = NSMidY(vis) - size.height / 2.0; + break; + } [self.panel setFrame:NSMakeRect(round(x), round(y), size.width, size.height) display:NO]; } @@ -252,7 +292,7 @@ - (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]]; + tintColor:[NSColor colorWithCalibratedWhite:0 alpha:0]]; self.glass = glass; // Enable the new vibrant rim here. diff --git a/Volume Control.xcodeproj/project.pbxproj b/Volume Control.xcodeproj/project.pbxproj index 63f69d6..0a5b29e 100644 --- a/Volume Control.xcodeproj/project.pbxproj +++ b/Volume Control.xcodeproj/project.pbxproj @@ -7,6 +7,14 @@ objects = { /* Begin PBXBuildFile section */ + AA01000000000001 /* main_cli.m in Sources */ = {isa = PBXBuildFile; fileRef = AA01000100000001 /* main_cli.m */; }; + AA01000000000002 /* HUDPanel.m in Sources */ = {isa = PBXBuildFile; fileRef = AA01000100000002 /* HUDPanel.m */; }; + AA01000000000003 /* VolumeSlider.m in Sources */ = {isa = PBXBuildFile; fileRef = AA01000100000003 /* VolumeSlider.m */; }; + AA01000000000004 /* VolumeSliderCell.m in Sources */ = {isa = PBXBuildFile; fileRef = AA01000100000004 /* VolumeSliderCell.m */; }; + AA01000000000005 /* TahoeVolumeHUD.m in Sources */ = {isa = PBXBuildFile; fileRef = AA01000100000005 /* TahoeVolumeHUD.m */; }; + AA01000000000006 /* LiquidGlassView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01000100000006 /* LiquidGlassView.swift */; }; + AA01000000000007 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 65996D17267EAD470080A9A5 /* Cocoa.framework */; }; + AA01000000000008 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 65996D15267EAD410080A9A5 /* QuartzCore.framework */; }; 65533217267F5D86004231D6 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 65533216267F5D86004231D6 /* README.md */; }; 65996D01267EAB9E0080A9A5 /* AccessibilityDialog.m in Sources */ = {isa = PBXBuildFile; fileRef = 65996CFF267EAB9E0080A9A5 /* AccessibilityDialog.m */; }; 65996D10267EAD240080A9A5 /* CoreAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 65996D0F267EAD240080A9A5 /* CoreAudio.framework */; }; @@ -27,6 +35,40 @@ 65EF8F5B2E888C9500AAE7B7 /* Sparkle.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 65EF8F582E888C6B00AAE7B7 /* Sparkle.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ +/* Begin PBXGroup section (OSD) */ + AA01000300000001 /* volume-control-osd */ = { + isa = PBXGroup; + children = ( + AA01000300000002 /* Sources */, + AA01000300000003 /* OSD */, + ); + name = "volume-control-osd"; + sourceTree = ""; + }; + AA01000300000002 /* Sources */ = { + isa = PBXGroup; + children = ( + AA01000100000001 /* main_cli.m */, + AA01000100000002 /* HUDPanel.m */, + AA01000100000003 /* VolumeSlider.m */, + AA01000100000004 /* VolumeSliderCell.m */, + AA01000100000005 /* TahoeVolumeHUD.m */, + AA01000100000006 /* LiquidGlassView.swift */, + ); + name = Sources; + sourceTree = ""; + }; + AA01000300000003 /* OSD */ = { + isa = PBXGroup; + children = ( + AA01000100000008 /* Info.plist */, + ); + name = OSD; + path = OSD; + sourceTree = ""; + }; +/* End PBXGroup section (OSD) */ + /* Begin PBXCopyFilesBuildPhase section */ 65DBBEF12E89EB4D00752329 /* Embed Login Helper */ = { isa = PBXCopyFilesBuildPhase; @@ -52,7 +94,47 @@ }; /* End PBXCopyFilesBuildPhase section */ +/* Begin PBXBuildPhase section (OSD) */ + AA01000200000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA01000000000001 /* main_cli.m in Sources */, + AA01000000000002 /* HUDPanel.m in Sources */, + AA01000000000003 /* VolumeSlider.m in Sources */, + AA01000000000004 /* VolumeSliderCell.m in Sources */, + AA01000000000005 /* TahoeVolumeHUD.m in Sources */, + AA01000000000006 /* LiquidGlassView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AA01000200000002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AA01000000000007 /* Cocoa.framework in Frameworks */, + AA01000000000008 /* QuartzCore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AA01000200000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXBuildPhase section (OSD) */ + /* Begin PBXFileReference section */ + AA01000100000001 /* main_cli.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main_cli.m; path = "Sources/CLI/main_cli.m"; sourceTree = SOURCE_ROOT; }; + AA01000100000002 /* HUDPanel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = HUDPanel.m; path = "User interface/HUD/HUDPanel.m"; sourceTree = SOURCE_ROOT; }; + AA01000100000003 /* VolumeSlider.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = VolumeSlider.m; path = "User interface/HUD/VolumeSlider.m"; sourceTree = SOURCE_ROOT; }; + AA01000100000004 /* VolumeSliderCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = VolumeSliderCell.m; path = "User interface/HUD/VolumeSliderCell.m"; sourceTree = SOURCE_ROOT; }; + AA01000100000005 /* TahoeVolumeHUD.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = TahoeVolumeHUD.m; path = "User interface/HUD/TahoeVolumeHUD.m"; sourceTree = SOURCE_ROOT; }; + AA01000100000006 /* LiquidGlassView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LiquidGlassView.swift; path = "User interface/HUD/LiquidGlassView.swift"; sourceTree = SOURCE_ROOT; }; + AA01000100000007 /* volume-control-osd.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "volume-control-osd.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + AA01000100000008 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = "OSD/Info.plist"; sourceTree = SOURCE_ROOT; }; 65220C75267F49CB007BE316 /* generate_keys.rb */ = {isa = PBXFileReference; lastKnownFileType = text.script.ruby; path = generate_keys.rb; sourceTree = ""; }; 65220C76267F49D4007BE316 /* sign_update.rb */ = {isa = PBXFileReference; lastKnownFileType = text.script.ruby; path = sign_update.rb; sourceTree = ""; }; 6546E1892E8C66FB0087E95F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -146,6 +228,7 @@ 65996D1C267EADAA0080A9A5 /* Resources */, 6546E18B2E8C66FB0087E95F /* VolumeControl */, 65DBBEDB2E89DE1800752329 /* Volume Control Helper */, + AA01000300000001 /* volume-control-osd */, 65996D0C267EACE30080A9A5 /* Frameworks */, 65996C56267EA86A0080A9A5 /* Products */, ); @@ -156,6 +239,7 @@ children = ( 65996C55267EA86A0080A9A5 /* Volume Control.app */, 65DBBEDA2E89DE1800752329 /* Volume Control Helper.app */, + AA01000100000007 /* volume-control-osd */, ); name = Products; sourceTree = ""; @@ -302,6 +386,23 @@ productReference = 65996C55267EA86A0080A9A5 /* Volume Control.app */; productType = "com.apple.product-type.application"; }; + AA01000400000001 /* volume-control-osd */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA01000500000001 /* Build configuration list for PBXNativeTarget "volume-control-osd" */; + buildPhases = ( + AA01000200000001 /* Sources */, + AA01000200000002 /* Frameworks */, + AA01000200000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "volume-control-osd"; + productName = "volume-control-osd"; + productReference = AA01000100000007 /* volume-control-osd */; + productType = "com.apple.product-type.application"; + }; 65DBBED92E89DE1800752329 /* Volume Control Helper */ = { isa = PBXNativeTarget; buildConfigurationList = 65DBBEE22E89DE1A00752329 /* Build configuration list for PBXNativeTarget "Volume Control Helper" */; @@ -334,13 +435,16 @@ LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 2600; TargetAttributes = { - 65996C54267EA86A0080A9A5 = { - CreatedOnToolsVersion = 12.5; + 65996C54267EA86A0080A9A5 = { + CreatedOnToolsVersion = 12.5; + }; + 65DBBED92E89DE1800752329 = { + CreatedOnToolsVersion = 26.0; + }; + AA01000400000001 = { + CreatedOnToolsVersion = 26.0; + }; }; - 65DBBED92E89DE1800752329 = { - CreatedOnToolsVersion = 26.0; - }; - }; }; buildConfigurationList = 65996C50267EA86A0080A9A5 /* Build configuration list for PBXProject "Volume Control" */; developmentRegion = en; @@ -357,6 +461,7 @@ targets = ( 65996C54267EA86A0080A9A5 /* Volume Control */, 65DBBED92E89DE1800752329 /* Volume Control Helper */, + AA01000400000001 /* volume-control-osd */, ); }; /* End PBXProject section */ @@ -736,6 +841,102 @@ }; /* End XCBuildConfiguration section */ + AA01000600000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 0000000000; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Frameworks", + ); + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + INFOPLIST_FILE = OSD/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "volume-control-osd"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.your.company.VolumeControlOSD; + PRODUCT_NAME = "volume-control-osd"; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Sources/Controllers", + "$(PROJECT_DIR)/Sources/Supporting files", + "$(PROJECT_DIR)/User interface/HUD", + ); + SDKROOT = macosx; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "Volume_Control-Swift.h"; + SWIFT_VERSION = 6.0; + ONLY_ACTIVE_ARCH = YES; + }; + name = Debug; + }; + AA01000600000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 0000000000; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Frameworks", + ); + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = OSD/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "volume-control-osd"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.your.company.VolumeControlOSD; + PRODUCT_NAME = "volume-control-osd"; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Sources/Controllers", + "$(PROJECT_DIR)/Sources/Supporting files", + "$(PROJECT_DIR)/User interface/HUD", + ); + SDKROOT = macosx; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "Volume_Control-Swift.h"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; /* Begin XCConfigurationList section */ 65996C50267EA86A0080A9A5 /* Build configuration list for PBXProject "Volume Control" */ = { isa = XCConfigurationList; @@ -764,6 +965,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + AA01000500000001 /* Build configuration list for PBXNativeTarget "volume-control-osd" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA01000600000001 /* Debug */, + AA01000600000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 65996C4D267EA86A0080A9A5 /* Project object */; From 2c34452806a6641c3e9039393092ed64c77ae2f7 Mon Sep 17 00:00:00 2001 From: Mario Kozjak Date: Wed, 25 Mar 2026 11:10:48 +0100 Subject: [PATCH 2/4] fix margins --- User interface/HUD/TahoeVolumeHUD.m | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/User interface/HUD/TahoeVolumeHUD.m b/User interface/HUD/TahoeVolumeHUD.m index 15604d1..b028672 100644 --- a/User interface/HUD/TahoeVolumeHUD.m +++ b/User interface/HUD/TahoeVolumeHUD.m @@ -40,7 +40,9 @@ @interface TahoeVolumeHUD () 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 CGFloat kSideInset = 12.0; // left/right internal padding +static const CGFloat kTopMargin = 40.0; // gap from true screen top edge +static const CGFloat kSideMargin = 20.0; // gap from left/right screen edges static const NSTimeInterval kFadeInDuration = 0.25; // seconds static const NSTimeInterval kFadeOutDuration = 0.45; // seconds @@ -243,8 +245,9 @@ - (void)positionPanel:(nullable NSStatusBarButton *)button position:(HUDPosition if (!screen) return; NSRect vis = screen.visibleFrame; + NSRect full = screen.frame; NSSize size = NSMakeSize(kHUDWidth, kHUDHeight); - static const CGFloat kEdgeMargin = 24.0; + CGFloat x, y; @@ -253,12 +256,12 @@ - (void)positionPanel:(nullable NSStatusBarButton *)button position:(HUDPosition case HUDPositionTopLeft: case HUDPositionCenterLeft: case HUDPositionBottomLeft: - x = NSMinX(vis) + kEdgeMargin; + x = NSMinX(vis) + kSideMargin; break; case HUDPositionTopRight: case HUDPositionCenterRight: case HUDPositionBottomRight: - x = NSMaxX(vis) - size.width - kEdgeMargin; + x = NSMaxX(vis) - size.width - kSideMargin; break; default: // center column x = NSMidX(vis) - size.width / 2.0; @@ -270,13 +273,13 @@ - (void)positionPanel:(nullable NSStatusBarButton *)button position:(HUDPosition case HUDPositionTopLeft: case HUDPositionTopCenter: case HUDPositionTopRight: - // Place just below the menu bar (top of visibleFrame is already below it) - y = NSMaxY(vis) - size.height - kEdgeMargin; + // Measure from the true screen top so kTopMargin = exact px from top edge + y = NSMaxY(full) - size.height - kTopMargin; break; case HUDPositionBottomLeft: case HUDPositionBottomCenter: case HUDPositionBottomRight: - y = NSMinY(vis) + kEdgeMargin; + y = NSMinY(vis) + kSideMargin; break; default: // middle row y = NSMidY(vis) - size.height / 2.0; From 231f03a224714a4c1e4631936876407f7591aa86 Mon Sep 17 00:00:00 2001 From: Mario Kozjak Date: Wed, 25 Mar 2026 11:32:06 +0100 Subject: [PATCH 3/4] adapt left icon/title margin --- User interface/HUD/TahoeVolumeHUD.m | 85 +++++++++++++++++++---------- 1 file changed, 56 insertions(+), 29 deletions(-) diff --git a/User interface/HUD/TahoeVolumeHUD.m b/User interface/HUD/TahoeVolumeHUD.m index b028672..b04ef50 100644 --- a/User interface/HUD/TahoeVolumeHUD.m +++ b/User interface/HUD/TahoeVolumeHUD.m @@ -28,6 +28,9 @@ @interface TahoeVolumeHUD () // Constraints @property (strong) NSLayoutConstraint *contentFixedHeight; +@property (strong) NSLayoutConstraint *titleLeadingWithIcon; +@property (strong) NSLayoutConstraint *titleLeadingWithoutIcon; +@property (strong) NSLayoutConstraint *iconWidthConstraint; // Player @property (strong) PlayerApplication *controlledPlayer; @@ -41,7 +44,7 @@ @interface TahoeVolumeHUD () static const CGFloat kBelowGap = 14.0; static const NSTimeInterval kAutoHide = 1.5; static const CGFloat kSideInset = 12.0; // left/right internal padding -static const CGFloat kTopMargin = 40.0; // gap from true screen top edge +static const CGFloat kTopMargin = 38.0; // gap from true screen top edge static const CGFloat kSideMargin = 20.0; // gap from left/right screen edges static const NSTimeInterval kFadeInDuration = 0.25; // seconds @@ -76,7 +79,7 @@ - (instancetype)initPrivate { // Set to NO to prevent the panel from hiding when the app is not active. _panel.hidesOnDeactivate = NO; - + _panel.level = NSPopUpMenuWindowLevel; _panel.movableByWindowBackground = NO; @@ -115,22 +118,22 @@ - (instancetype)initPrivate { // 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], @@ -153,21 +156,37 @@ - (instancetype)initPrivate { - (void)showHUDWithVolume:(double)volume usingMusicPlayer:(nullable PlayerApplication*)player andLabel:(NSString*)label anchoredToStatusButton:(nullable NSStatusBarButton *)button position:(HUDPosition)position { 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 ? [player icon] : nil; + + // Update header: show icon and adjust title leading constraint accordingly. + NSImage *icon = player ? [player icon] : nil; + self.appIconView.image = icon; + + if (icon) { + // Icon is present — give it its full width and anchor title to its trailing edge. + self.appIconView.hidden = NO; + self.iconWidthConstraint.constant = 18; + self.titleLeadingWithoutIcon.active = NO; + self.titleLeadingWithIcon.active = YES; + } else { + // No icon — hide and collapse the icon view, anchor title directly to the left edge. + self.appIconView.hidden = YES; + self.iconWidthConstraint.constant = 0; + self.titleLeadingWithIcon.active = NO; + self.titleLeadingWithoutIcon.active = YES; + } + self.titleLabel.stringValue = label; - + // Size fence each time [_panel setContentSize:NSMakeSize(kHUDWidth, kHUDHeight)]; - + [self positionPanel:button position:position]; - + // 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) { @@ -204,7 +223,7 @@ - (void)hide { 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; @@ -297,10 +316,10 @@ - (void)installGlassInto:(NSView *)host cornerRadius:(CGFloat)radius { cornerRadius:radius tintColor:[NSColor colorWithCalibratedWhite:0 alpha:0]]; self.glass = glass; - + // Enable the new vibrant rim here. glass.hasVibrantRim = NO; - + [host addSubview:glass]; glass.translatesAutoresizingMaskIntoConstraints = NO; [NSLayoutConstraint activateConstraints:@[ @@ -309,12 +328,12 @@ - (void)installGlassInto:(NSView *)host cornerRadius:(CGFloat)radius { [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]; @@ -322,7 +341,7 @@ - (void)installGlassInto:(NSView *)host cornerRadius:(CGFloat)radius { } else { // Fallback on earlier versions } - + // Optional SwiftUI-like post-filters: //[glass applyVisualAdjustmentsWithSaturation:1.5 brightness:0.2 blur:0.25]; } @@ -393,7 +412,7 @@ - (NSView *)buildSliderStrip { [slider.trailingAnchor constraintEqualToAnchor:iconRight.leadingAnchor constant:-8], [slider.centerYAnchor constraintEqualToAnchor:strip.centerYAnchor], ]]; - + return strip; } @@ -423,18 +442,26 @@ - (NSView *)buildHeaderRow { CGFloat topPadding = 12.0; // Increased to provide more space at the top. CGFloat bottomPadding = 4.0; // Defines space between header and slider strip. + // Build the two alternative leading constraints for the title label. + // Only one will be active at a time, toggled in showHUDWithVolume:... + self.titleLeadingWithIcon = [self.titleLabel.leadingAnchor constraintEqualToAnchor:self.appIconView.trailingAnchor constant:8]; + self.titleLeadingWithoutIcon = [self.titleLabel.leadingAnchor constraintEqualToAnchor:row.leadingAnchor constant:kSideInset]; + + // Width constraint for the icon — set to 0 and deactivated when no icon is present. + self.iconWidthConstraint = [self.appIconView.widthAnchor constraintEqualToConstant:18]; + [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.iconWidthConstraint, [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], + // Start with no-icon layout; showHUDWithVolume:... will switch as needed. + self.titleLeadingWithoutIcon, [self.titleLabel.centerYAnchor constraintEqualToAnchor:self.appIconView.centerYAnchor], [self.titleLabel.trailingAnchor constraintLessThanOrEqualToAnchor:row.trailingAnchor constant:-kSideInset], ]]; @@ -450,12 +477,12 @@ - (NSView *)buildHeaderRow { // 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 @@ -469,7 +496,7 @@ - (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 From ef70cc3b6e4d325c5db6e4a36c41ddd48e4ba3be Mon Sep 17 00:00:00 2001 From: Mario Kozjak Date: Fri, 27 Mar 2026 15:34:23 +0100 Subject: [PATCH 4/4] don't activate the app in order to not steal focus --- Sources/CLI/main_cli.m | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Sources/CLI/main_cli.m b/Sources/CLI/main_cli.m index d27fc5d..9430f54 100644 --- a/Sources/CLI/main_cli.m +++ b/Sources/CLI/main_cli.m @@ -50,11 +50,6 @@ @interface CLIAppDelegate : NSObject @implementation CLIAppDelegate - (void)applicationDidFinishLaunching:(NSNotification *)notification { - // Activate the app so the window server sets up a compositing surface for - // this process. Without this, NSGlassEffectView / NSVisualEffectView cannot - // see through the window to the content behind it and renders opaque/solid. - [NSApp activateIgnoringOtherApps:YES]; - // Show the HUD. Passing nil for the status button → centers on screen. CLIPlayerApplication *player = [[CLIPlayerApplication alloc] init]; player.currentVolume = self.volume;