This commit is contained in:
Andrea Alberti 2025-10-25 21:33:01 +02:00
parent 0ba1b0f9f9
commit a7dc3f216f
6 changed files with 289 additions and 70 deletions

View File

@ -0,0 +1,9 @@
//
// CustomVolumeSlider.h
// Volume Control
//
// Created by Andrea Alberti on 25.10.25.
//
@interface CustomVolumeSlider : NSSliderCell
@end

View File

@ -0,0 +1,29 @@
//
// CustomVolumeSlider.m
// Volume Control
//
// Created by Andrea Alberti on 25.10.25.
//
@implementation CustomVolumeSlider
- (void)drawBarInside:(NSRect)rect flipped:(BOOL)flipped {
rect = NSInsetRect(rect, 0, (NSHeight(rect)-4)/2.0);
[[NSColor colorWithWhite:1.0 alpha:0.25] setFill]; // background track
NSBezierPath *bg = [NSBezierPath bezierPathWithRoundedRect:rect xRadius:2 yRadius:2];
[bg fill];
CGFloat pct = (self.doubleValue - self.minValue) / (self.maxValue - self.minValue);
NSRect fillRect = rect; fillRect.size.width = round(NSWidth(rect)*pct);
[[NSColor colorWithWhite:1.0 alpha:0.85] setFill]; // active fill
NSBezierPath *fg = [NSBezierPath bezierPathWithRoundedRect:fillRect xRadius:2 yRadius:2];
[fg fill];
}
- (void)drawKnob:(NSRect)knobRect {
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];
}
@end

View File

@ -1,5 +1,7 @@
#import "TahoeVolumeHUD.h"
#import "CustomVolumeSlider.h"
#import <AppKit/NSGlassEffectView.h> // macOS 26 SDK
#import "VCLiquidGlassView.h"
@interface TahoeVolumeHUD ()
// Properties
@ -123,8 +125,10 @@
_panel.level = NSPopUpMenuWindowLevel;
_panel.movableByWindowBackground = NO;
_panel.collectionBehavior = NSWindowCollectionBehaviorTransient |
NSWindowCollectionBehaviorIgnoresCycle |
NSWindowCollectionBehaviorFullScreenAuxiliary;
NSWindowCollectionBehaviorIgnoresCycle |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorCanJoinAllSpaces;
_panel.floatingPanel = YES;
_panel.becomesKeyOnlyIfNeeded = YES;
@ -190,21 +194,18 @@
#pragma mark - Public API
- (void)showHUDWithVolume:(double)volume anchoredToStatusButton:(NSStatusBarButton *)button {
// Normalize volume (accept 0100 or 01)
if (volume > 1.0) volume = MAX(0.0, MIN(1.0, volume / 100.0));
self.slider.doubleValue = volume;
// Size (kept at HEIGHT_POPOVER height)
NSRect f = self.panel.frame;
f.size = NSMakeSize(MAX(WIDTH_POPOVER, f.size.width), HEIGHT_POPOVER);
[self.panel setFrame:f display:NO];
// Position directly beneath the status bar button
// Position + show
[self positionPanelBelowStatusButton:button];
[self.panel makeKeyAndOrderFront:nil];
[self.panel orderFront:nil];
// Restart autohide timer (2s)
// Restart autohide
[self.hideTimer invalidate];
self.hideTimer = [NSTimer scheduledTimerWithTimeInterval:2.0
target:self
@ -270,70 +271,28 @@
}
#pragma mark - Glass
- (void)installGlassInto:(NSView *)host cornerRadius:(CGFloat)radius {
NSView *glass = nil;
if (@available(macOS 26.0, *)) {
Class GlassCls = NSClassFromString(@"NSGlassEffectView");
if (GlassCls) {
NSGlassEffectView *g = [[NSGlassEffectView alloc] initWithFrame:host.bounds];
g.translatesAutoresizingMaskIntoConstraints = NO;
// Public properties
g.style = NSGlassEffectViewStyleClear;
g.cornerRadius = radius;
g.tintColor = [NSColor colorWithCalibratedWhite:1 alpha:0.06];
// Optional: private knobs
[g setValue:@(8) forKey:@"_variant"]; // cartouchePopover
//[g setValue:@(1) forKey:@"_scrimState"];
[g setValue:@(0) forKey:@"_subduedState"];
[g setValue:@(YES) forKey:@"_useReducedShadowRadius"]; // smaller or sharper rim
[g setValue:@(0) forKey:@"_adaptiveAppearance"]; // adapts rim contrast to dark/light mode
[g setValue:@(0) forKey:@"_contentLensing"]; // if 1, simulates focus depth
// Optional rim accent (very subtle)
g.wantsLayer = YES;
CALayer *rim = [CALayer layer];
rim.frame = g.bounds;
rim.cornerRadius = radius;
rim.borderWidth = 1.0; // thin
rim.borderColor = [[NSColor colorWithWhite:1.0 alpha:0.20] CGColor];
rim.autoresizingMask = kCALayerWidthSizable | kCALayerHeightSizable;
rim.masksToBounds = YES;
rim.shadowColor = [NSColor.whiteColor CGColor];
rim.shadowRadius = 4.0;
rim.shadowOpacity = 0.12;
rim.shadowOffset = CGSizeZero;
[g.layer addSublayer:rim];
glass = g;
}
}
if (!glass) {
// Fallback
NSVisualEffectView *vev = [[NSVisualEffectView alloc] initWithFrame:host.bounds];
vev.translatesAutoresizingMaskIntoConstraints = NO;
vev.material = NSVisualEffectMaterialUnderWindowBackground;
vev.blendingMode = NSVisualEffectBlendingModeBehindWindow;
vev.state = NSVisualEffectStateActive;
vev.wantsLayer = YES;
vev.layer.masksToBounds = YES;
vev.layer.cornerRadius = radius;
glass = vev;
}
self.glass = glass;
VCLiquidGlassView *glass = [VCLiquidGlassView glassWithStyle:1 /* Clear */
cornerRadius:radius
tintColor:[NSColor colorWithCalibratedWhite:1 alpha:0.06]];
self.glass = glass; // keep your ivar as NSView*; its a subclass of NSView
[host addSubview:glass];
[NSLayoutConstraint activateConstraints:@[
[glass.leadingAnchor constraintEqualToAnchor:host.leadingAnchor],
[glass.trailingAnchor constraintEqualToAnchor:host.trailingAnchor],
[glass.topAnchor constraintEqualToAnchor:host.topAnchor],
[glass.bottomAnchor constraintEqualToAnchor:host.bottomAnchor],
]];
// The wrapper autoconstraints itself to fill the host in -viewDidMoveToSuperview
// but if host isnt using Auto Layout yet, pin manually:
if (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: private Tahoe look variant
[glass setVariantIfAvailable:8]; // cartouchePopover (as in your code)
// [glass setScrimStateIfAvailable:1];
[glass setSubduedStateIfAvailable:0];
}
#pragma mark - Content

View File

@ -0,0 +1,41 @@
//
// LiquidGlassView.h
// Volume Control
//
// Created by Andrea Alberti on 25.10.25.
//
#import <Cocoa/Cocoa.h>
NS_ASSUME_NONNULL_BEGIN
/// A view that hosts content inside native glass (macOS 26+) with fallback to NSVisualEffectView.
/// Add subviews to `contentView` (or set it), not to `VCLiquidGlassView` itself.
@interface VCLiquidGlassView : NSView
/// The embedded content. You can set either a single content view, or just add your subviews to it.
@property (nullable, strong) __kindof NSView *contentView;
/// Public glass properties (no private API required)
@property CGFloat cornerRadius; // default 14
@property (nullable, copy) NSColor *tintColor; // default nil
@property NSInteger style; // NSGlassEffectViewStyle (0=Regular,1=Clear). Defaults to Clear on 26+, ignored on fallback.
/// Convenience: set all at once.
- (void)configureWithStyle:(NSInteger)style
cornerRadius:(CGFloat)cornerRadius
tintColor:(nullable NSColor *)tint;
/// Private Tahoe knobs (no-ops on fallback or if Apple removes them)
- (void)setVariantIfAvailable:(NSInteger)variant; // 0..N
- (void)setScrimStateIfAvailable:(NSInteger)onOff; // 0/1
- (void)setSubduedStateIfAvailable:(NSInteger)onOff; // 0/1
/// Build and return a fully configured instance.
+ (instancetype)glassWithStyle:(NSInteger)style
cornerRadius:(CGFloat)cornerRadius
tintColor:(nullable NSColor *)tint;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,171 @@
//
// LiquidGlassView.m
// Volume Control
//
// Created by Andrea Alberti on 25.10.25.
//
#import "VCLiquidGlassView.h"
@interface VCLiquidGlassView ()
@property (strong) NSView *backingGlass; // NSGlassEffectView or NSVisualEffectView
@property (strong) NSView *contentHost; // Where clients put their content
@end
@implementation VCLiquidGlassView
+ (instancetype)glassWithStyle:(NSInteger)style cornerRadius:(CGFloat)cornerRadius tintColor:(NSColor *)tint {
VCLiquidGlassView *v = [[self alloc] initWithFrame:NSZeroRect];
v.translatesAutoresizingMaskIntoConstraints = NO;
v.style = style;
v.cornerRadius = cornerRadius;
v.tintColor = tint;
return v;
}
- (instancetype)initWithFrame:(NSRect)frame {
if ((self = [super initWithFrame:frame])) {
self.wantsLayer = NO; // root stays pass-through
[self buildBacking];
}
return self;
}
- (void)viewDidMoveToSuperview {
[super viewDidMoveToSuperview];
if (self.superview && self.translatesAutoresizingMaskIntoConstraints == NO) {
// auto-pin to fill parent if you added it with Auto Layout
[NSLayoutConstraint activateConstraints:@[
[self.leadingAnchor constraintEqualToAnchor:self.superview.leadingAnchor],
[self.trailingAnchor constraintEqualToAnchor:self.superview.trailingAnchor],
[self.topAnchor constraintEqualToAnchor:self.superview.topAnchor],
[self.bottomAnchor constraintEqualToAnchor:self.superview.bottomAnchor]
]];
}
}
- (void)buildBacking {
// Build glass (26+) or fallback
NSView *glass = nil;
if (@available(macOS 26.0, *)) {
Class GlassCls = NSClassFromString(@"NSGlassEffectView");
if (GlassCls) {
glass = [[GlassCls alloc] initWithFrame:self.bounds];
glass.translatesAutoresizingMaskIntoConstraints = NO;
// Defaults
[glass setValue:@(1) forKey:@"style"]; // Clear by default
[glass setValue:@(14.0) forKey:@"cornerRadius"];
}
}
if (!glass) {
NSVisualEffectView *vev = [[NSVisualEffectView alloc] initWithFrame:self.bounds];
vev.translatesAutoresizingMaskIntoConstraints = NO;
vev.material = NSVisualEffectMaterialUnderWindowBackground;
vev.blendingMode = NSVisualEffectBlendingModeBehindWindow;
vev.state = NSVisualEffectStateActive;
vev.wantsLayer = YES;
vev.layer.masksToBounds = YES;
vev.layer.cornerRadius = 14.0;
glass = vev;
}
self.backingGlass = glass;
// A host view that you can treat like NSGlassEffectView.contentView
NSView *host = [[NSView alloc] initWithFrame:self.bounds];
host.translatesAutoresizingMaskIntoConstraints = NO;
host.wantsLayer = NO;
self.contentHost = host;
[self addSubview:self.backingGlass];
[NSLayoutConstraint activateConstraints:@[
[self.backingGlass.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
[self.backingGlass.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
[self.backingGlass.topAnchor constraintEqualToAnchor:self.topAnchor],
[self.backingGlass.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
]];
if ([self.backingGlass respondsToSelector:NSSelectorFromString(@"setContentView:")]) {
// NSGlassEffectView path
[self.backingGlass setValue:self.contentHost forKey:@"contentView"];
} else {
// Fallback: add host as a subview inside VEV
[self.backingGlass addSubview:self.contentHost];
[NSLayoutConstraint activateConstraints:@[
[self.contentHost.leadingAnchor constraintEqualToAnchor:self.backingGlass.leadingAnchor],
[self.contentHost.trailingAnchor constraintEqualToAnchor:self.backingGlass.trailingAnchor],
[self.contentHost.topAnchor constraintEqualToAnchor:self.backingGlass.topAnchor],
[self.contentHost.bottomAnchor constraintEqualToAnchor:self.backingGlass.bottomAnchor],
]];
}
}
#pragma mark - API
- (void)setContentView:(NSView *)contentView {
// Replace existing content
for (NSView *v in self.contentHost.subviews) { [v removeFromSuperview]; }
if (!contentView) return;
contentView.translatesAutoresizingMaskIntoConstraints = NO;
[self.contentHost addSubview:contentView];
[NSLayoutConstraint activateConstraints:@[
[contentView.leadingAnchor constraintEqualToAnchor:self.contentHost.leadingAnchor],
[contentView.trailingAnchor constraintEqualToAnchor:self.contentHost.trailingAnchor],
[contentView.topAnchor constraintEqualToAnchor:self.contentHost.topAnchor],
[contentView.bottomAnchor constraintEqualToAnchor:self.contentHost.bottomAnchor]
]];
}
- (NSView *)contentView {
return self.contentHost.subviews.firstObject;
}
- (void)configureWithStyle:(NSInteger)style cornerRadius:(CGFloat)cornerRadius tintColor:(NSColor *)tint {
self.style = style;
self.cornerRadius = cornerRadius;
self.tintColor = tint;
}
- (void)setStyle:(NSInteger)style {
_style = style;
if ([self.backingGlass respondsToSelector:@selector(setStyle:)]) {
// NSGlassEffectView
[self.backingGlass setValue:@(style) forKey:@"style"];
} else {
// Fallback has no style concept
}
}
- (void)setCornerRadius:(CGFloat)cornerRadius {
_cornerRadius = cornerRadius;
if ([self.backingGlass respondsToSelector:@selector(setCornerRadius:)]) {
[self.backingGlass setValue:@(cornerRadius) forKey:@"cornerRadius"];
} else if (self.backingGlass.wantsLayer) {
self.backingGlass.layer.cornerRadius = cornerRadius;
self.backingGlass.layer.masksToBounds = YES;
}
}
- (void)setTintColor:(NSColor *)tintColor {
_tintColor = [tintColor copy];
if ([self.backingGlass respondsToSelector:NSSelectorFromString(@"setTintColor:")]) {
[self.backingGlass setValue:_tintColor forKey:@"tintColor"];
} else if (self.backingGlass.wantsLayer && _tintColor) {
// VERY subtle fallback tint
self.backingGlass.layer.backgroundColor = _tintColor.CGColor;
}
}
#pragma mark - Private Tahoe knobs
- (void)setVariantIfAvailable:(NSInteger)variant {
@try { [self.backingGlass setValue:@(variant) forKey:@"_variant"]; } @catch (...) {}
}
- (void)setScrimStateIfAvailable:(NSInteger)onOff {
@try { [self.backingGlass setValue:@(onOff) forKey:@"_scrimState"]; } @catch (...) {}
}
- (void)setSubduedStateIfAvailable:(NSInteger)onOff {
@try { [self.backingGlass setValue:@(onOff) forKey:@"_subduedState"]; } @catch (...) {}
}
@end

View File

@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
654FF5D32EAD5D5900B9C699 /* VCLiquidGlassView.m in Sources */ = {isa = PBXBuildFile; fileRef = 654FF5D22EAD5C1E00B9C699 /* VCLiquidGlassView.m */; };
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 */; };
@ -60,6 +61,10 @@
6546E18A2E8C66FB0087E95F /* VolumeControl.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VolumeControl.entitlements; sourceTree = "<group>"; };
654FF5C82EAD0BEC00B9C699 /* GlassDemoWindowController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GlassDemoWindowController.h; sourceTree = "<group>"; };
654FF5CA2EAD0C1B00B9C699 /* GlassDemoWindowController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = GlassDemoWindowController.m; sourceTree = "<group>"; };
654FF5CF2EAD483B00B9C699 /* CustomVolumeSlider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CustomVolumeSlider.h; sourceTree = "<group>"; };
654FF5D02EAD484F00B9C699 /* CustomVolumeSlider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CustomVolumeSlider.m; sourceTree = "<group>"; };
654FF5D12EAD5C0800B9C699 /* VCLiquidGlassView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = VCLiquidGlassView.h; sourceTree = "<group>"; };
654FF5D22EAD5C1E00B9C699 /* VCLiquidGlassView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = VCLiquidGlassView.m; sourceTree = "<group>"; };
65533216267F5D86004231D6 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = SOURCE_ROOT; };
65996C55267EA86A0080A9A5 /* Volume Control.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Volume Control.app"; sourceTree = BUILT_PRODUCTS_DIR; };
65996CDF267EAB660080A9A5 /* keyboard.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = keyboard.png; sourceTree = "<group>"; };
@ -295,6 +300,10 @@
65996D09267EACB50080A9A5 /* Controllers */ = {
isa = PBXGroup;
children = (
654FF5D22EAD5C1E00B9C699 /* VCLiquidGlassView.m */,
654FF5D12EAD5C0800B9C699 /* VCLiquidGlassView.h */,
654FF5D02EAD484F00B9C699 /* CustomVolumeSlider.m */,
654FF5CF2EAD483B00B9C699 /* CustomVolumeSlider.h */,
654FF5CA2EAD0C1B00B9C699 /* GlassDemoWindowController.m */,
654FF5C82EAD0BEC00B9C699 /* GlassDemoWindowController.h */,
65A0E3022EAC2B11009B6CC0 /* CustomGlassEffectView.m */,
@ -503,6 +512,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
654FF5D32EAD5D5900B9C699 /* VCLiquidGlassView.m in Sources */,
65996E1A267EB14F0080A9A5 /* AppDelegate.m in Sources */,
65996D01267EAB9E0080A9A5 /* AccessibilityDialog.m in Sources */,
65996E1B267EB14F0080A9A5 /* SystemVolume.m in Sources */,