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..9430f54
--- /dev/null
+++ b/Sources/CLI/main_cli.m
@@ -0,0 +1,219 @@
+//
+// 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 {
+ // 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..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;
@@ -40,7 +43,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 = 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
static const NSTimeInterval kFadeOutDuration = 0.45; // seconds
@@ -74,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;
@@ -113,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],
@@ -149,23 +154,39 @@ - (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];
+
+ // 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 positionPanelBelowStatusButton:button];
-
+
+ [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) {
@@ -202,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;
@@ -216,32 +237,73 @@ - (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;
+ NSRect full = screen.frame;
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 x, y;
+
+ // Horizontal component
+ switch (position) {
+ case HUDPositionTopLeft:
+ case HUDPositionCenterLeft:
+ case HUDPositionBottomLeft:
+ x = NSMinX(vis) + kSideMargin;
+ break;
+ case HUDPositionTopRight:
+ case HUDPositionCenterRight:
+ case HUDPositionBottomRight:
+ x = NSMaxX(vis) - size.width - kSideMargin;
+ 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:
+ // 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) + kSideMargin;
+ 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,12 +314,12 @@ - (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.
glass.hasVibrantRim = NO;
-
+
[host addSubview:glass];
glass.translatesAutoresizingMaskIntoConstraints = NO;
[NSLayoutConstraint activateConstraints:@[
@@ -266,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];
@@ -279,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];
}
@@ -350,7 +412,7 @@ - (NSView *)buildSliderStrip {
[slider.trailingAnchor constraintEqualToAnchor:iconRight.leadingAnchor constant:-8],
[slider.centerYAnchor constraintEqualToAnchor:strip.centerYAnchor],
]];
-
+
return strip;
}
@@ -380,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],
]];
@@ -407,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
@@ -426,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
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 */;