From 9184ba91644be94076eda71bd81170d66d241c6e Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Mon, 11 May 2026 09:40:55 -0500 Subject: [PATCH 01/18] chore: streamline vscode run task --- .vscode/launch.json | 8 +------- .vscode/tasks.json | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index cdcf442ea..51bc70a08 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,15 +7,9 @@ "request": "launch", "program": "${workspaceFolder}/build/OpenNOW", "args": [], - "stopAtEntry": false, "cwd": "${workspaceFolder}", - "environment": [], - "externalConsole": false, - "MIMode": "lldb", "preLaunchTask": "build", - "logging": { - "engineLogging": false - } + } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 401b78f08..6050ee6af 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "build", "type": "shell", - "command": "make -B all", + "command": "make run", "args": [], "group": { "kind": "build", From 2a8a9393c6cd419cf3d2762ffa147bf44b6ebcc8 Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Mon, 11 May 2026 10:55:15 -0500 Subject: [PATCH 02/18] chore: use make for vscode build task --- .vscode/tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6050ee6af..ab6ae6c0a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "build", "type": "shell", - "command": "make run", + "command": "make", "args": [], "group": { "kind": "build", From 330e908f0b8bfbeb2aa24c896c55b2d6dbe0105a Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Mon, 11 May 2026 12:51:15 -0500 Subject: [PATCH 03/18] Add controller mode interface --- src/common/OPNUIHelpers.h | 2 + src/common/OPNUIHelpers.mm | 12 + src/views/OPNBackdropView.mm | 187 +++++++++++---- src/views/OPNGameCardView.h | 1 + src/views/OPNGameCardView.mm | 30 ++- src/views/OPNGameCatalogView.mm | 410 ++++++++++++++++++++++++++++++-- src/views/OPNSettingsView.mm | 44 +++- 7 files changed, 613 insertions(+), 73 deletions(-) diff --git a/src/common/OPNUIHelpers.h b/src/common/OPNUIHelpers.h index 793d9287e..408eeedcc 100644 --- a/src/common/OPNUIHelpers.h +++ b/src/common/OPNUIHelpers.h @@ -12,6 +12,8 @@ CGFloat OpnPosterSizeScale(void); void OpnSetPosterSizeScale(CGFloat scale); BOOL OpnAutoFullScreenEnabled(void); void OpnSetAutoFullScreenEnabled(BOOL enabled); +BOOL OpnControllerModeEnabled(void); +void OpnSetControllerModeEnabled(BOOL enabled); NSDictionary *OpnTextStyle(CGFloat size, NSColor *color, NSFontWeight weight = NSFontWeightRegular); diff --git a/src/common/OPNUIHelpers.mm b/src/common/OPNUIHelpers.mm index 6a879bdb0..e38751451 100644 --- a/src/common/OPNUIHelpers.mm +++ b/src/common/OPNUIHelpers.mm @@ -9,6 +9,7 @@ static NSString *const OPNAccentBlueDefaultsKey = @"OpenNOW.Interface.AccentBlue"; static NSString *const OPNPosterSizeScaleDefaultsKey = @"OpenNOW.Interface.PosterSizeScale"; static NSString *const OPNAutoFullScreenDefaultsKey = @"OpenNOW.Interface.AutoFullScreen"; +static NSString *const OPNControllerModeDefaultsKey = @"OpenNOW.Interface.ControllerMode"; static const CGFloat OPNMinimumPosterSizeScale = 0.80; static const CGFloat OPNMaximumPosterSizeScale = 1.30; static const unsigned OPNDefaultAccentRGB = 0x7CF1B1; @@ -76,6 +77,17 @@ void OpnSetAutoFullScreenEnabled(BOOL enabled) { [NSNotificationCenter.defaultCenter postNotificationName:OPNInterfacePreferencesDidChangeNotification object:nil]; } +BOOL OpnControllerModeEnabled(void) { + return [NSUserDefaults.standardUserDefaults boolForKey:OPNControllerModeDefaultsKey]; +} + +void OpnSetControllerModeEnabled(BOOL enabled) { + if (enabled == OpnControllerModeEnabled()) return; + [NSUserDefaults.standardUserDefaults setBool:enabled forKey:OPNControllerModeDefaultsKey]; + [NSUserDefaults.standardUserDefaults synchronize]; + [NSNotificationCenter.defaultCenter postNotificationName:OPNInterfacePreferencesDidChangeNotification object:nil]; +} + static unsigned OpnResolvedInterfaceColor(unsigned rgb) { unsigned accent = OpnCurrentAccentRGB(); switch (rgb) { diff --git a/src/views/OPNBackdropView.mm b/src/views/OPNBackdropView.mm index ce6bf73bc..5b750a3ee 100644 --- a/src/views/OPNBackdropView.mm +++ b/src/views/OPNBackdropView.mm @@ -2,6 +2,7 @@ #import "../common/OPNColorTokens.h" #import "../common/OPNUIHelpers.h" #import "../common/OPNAuthTypes.h" +#import @implementation OPNBackdropView { NSRect _storeNavFrame; @@ -12,6 +13,8 @@ @implementation OPNBackdropView { NSButton *_libraryButton; NSButton *_settingsButton; NSButton *_accountButton; + NSTimer *_controllerNavigationTimer; + uint16_t _previousControllerButtons; } static NSAttributedString *OPNMenuTitle(NSString *title, NSColor *color, NSFontWeight weight) { @@ -39,10 +42,88 @@ - (instancetype)initWithFrame:(NSRect)frame { [self addSubview:_libraryButton]; [self addSubview:_settingsButton]; [self addSubview:_accountButton]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(interfacePreferencesChanged:) + name:OPNInterfacePreferencesDidChangeNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(controllerDidConnect:) name:GCControllerDidConnectNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(controllerDidDisconnect:) name:GCControllerDidDisconnectNotification object:nil]; + [self startControllerNavigationIfNeeded]; } return self; } +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [_controllerNavigationTimer invalidate]; +} + +- (void)interfacePreferencesChanged:(NSNotification *)notification { + (void)notification; + [self setNeedsDisplay:YES]; + [self startControllerNavigationIfNeeded]; +} + +- (void)startControllerNavigationIfNeeded { + if (!OpnControllerModeEnabled() || _controllerNavigationTimer) return; + _controllerNavigationTimer = [NSTimer scheduledTimerWithTimeInterval:(1.0 / 30.0) + target:self + selector:@selector(pollControllerNavigation) + userInfo:nil + repeats:YES]; +} + +- (void)controllerDidConnect:(NSNotification *)notification { + (void)notification; + [self startControllerNavigationIfNeeded]; +} + +- (void)controllerDidDisconnect:(NSNotification *)notification { + (void)notification; + _previousControllerButtons = 0; +} + +- (uint16_t)currentControllerNavigationButtons { + NSArray *controllers = [GCController controllers]; + if (controllers.count == 0) return 0; + GCExtendedGamepad *pad = controllers.firstObject.extendedGamepad; + if (!pad) return 0; + uint16_t buttons = 0; + if (pad.leftShoulder.value > 0.5) buttons |= 1u << 0; + if (pad.rightShoulder.value > 0.5) buttons |= 1u << 1; + if (@available(macOS 10.15, *)) { + if (pad.buttonMenu.value > 0.5 || pad.buttonOptions.value > 0.5) buttons |= 1u << 2; + } + return buttons; +} + +- (void)selectPreviousControllerTab { + if (self.mode == OPNBackdropModeSettings) { + if (self.onLibrarySelected) self.onLibrarySelected(); + } +} + +- (void)selectNextControllerTab { + if (self.mode == OPNBackdropModeLibrary) { + if (self.onSettingsSelected) self.onSettingsSelected(); + } +} + +- (void)pollControllerNavigation { + if (!OpnControllerModeEnabled()) { + [_controllerNavigationTimer invalidate]; + _controllerNavigationTimer = nil; + _previousControllerButtons = 0; + return; + } + uint16_t buttons = [self currentControllerNavigationButtons]; + uint16_t pressed = buttons & (uint16_t)~_previousControllerButtons; + if (pressed & (1u << 0)) [self selectPreviousControllerTab]; + if (pressed & (1u << 1)) [self selectNextControllerTab]; + if (pressed & (1u << 2)) [self accountButtonPressed:self]; + _previousControllerButtons = buttons; +} + - (NSButton *)navigationHitButtonWithAction:(SEL)action { NSButton *button = [[NSButton alloc] initWithFrame:NSZeroRect]; button.title = @""; @@ -97,11 +178,19 @@ - (void)setCurrentAccountIdentifier:(NSString *)currentAccountIdentifier { - (void)layout { [super layout]; BOOL showNavigation = self.mode != OPNBackdropModeAuth; - _storeButton.frame = showNavigation && !NSEqualRects(_storeNavFrame, NSZeroRect) ? _storeNavFrame : NSZeroRect; + BOOL controllerMode = OpnControllerModeEnabled(); + if (controllerMode && showNavigation) { + _storeNavFrame = NSZeroRect; + _libraryNavFrame = NSMakeRect(28.0, 90.0, 86.0, 34.0); + _settingsNavFrame = NSMakeRect(124.0, 90.0, 90.0, 34.0); + _accountFrame = NSMakeRect(NSWidth(self.bounds) - 264.0, 10.0, 244.0, 44.0); + } + BOOL showStore = showNavigation && !controllerMode; + _storeButton.frame = showStore && !NSEqualRects(_storeNavFrame, NSZeroRect) ? _storeNavFrame : NSZeroRect; _libraryButton.frame = showNavigation && !NSEqualRects(_libraryNavFrame, NSZeroRect) ? _libraryNavFrame : NSZeroRect; _settingsButton.frame = showNavigation && !NSEqualRects(_settingsNavFrame, NSZeroRect) ? _settingsNavFrame : NSZeroRect; _accountButton.frame = showNavigation && !NSEqualRects(_accountFrame, NSZeroRect) ? _accountFrame : NSZeroRect; - _storeButton.hidden = !showNavigation; + _storeButton.hidden = !showStore; _libraryButton.hidden = !showNavigation; _settingsButton.hidden = !showNavigation; _accountButton.hidden = !showNavigation; @@ -113,72 +202,86 @@ - (void)drawRect:(NSRect)dirtyRect { using namespace OPN; NSRect bounds = self.bounds; + BOOL controllerMode = OpnControllerModeEnabled(); [OpnColor(kBackground) setFill]; NSRectFill(bounds); - NSGradient *edgeWash = [[NSGradient alloc] initWithColors:@[ - OpnColor(kBackgroundB, 0.94), - OpnColor(kBackground, 1.0), - OpnColor(0x0C0D10, 1.0), - ]]; + NSGradient *edgeWash = controllerMode + ? [[NSGradient alloc] initWithColors:@[ + OpnColor(kBackground, 1.0), + OpnColor(kBrandGreen, 0.20), + OpnColor(0x06080E, 1.0), + ]] + : [[NSGradient alloc] initWithColors:@[ + OpnColor(kBackgroundB, 0.94), + OpnColor(kBackground, 1.0), + OpnColor(0x0C0D10, 1.0), + ]]; [edgeWash drawInRect:bounds angle:270.0]; - NSGradient *spotlight = [[NSGradient alloc] initWithStartingColor:OpnColor(0xFFFFFF, 0.045) + NSGradient *spotlight = [[NSGradient alloc] initWithStartingColor:controllerMode ? OpnColor(kBrandGreen, 0.18) : OpnColor(0xFFFFFF, 0.045) endingColor:OpnColor(0xFFFFFF, 0.0)]; - NSRect spotlightRect = NSMakeRect(NSWidth(bounds) * 0.5 - 360.0, -300.0, 720.0, 720.0); + NSRect spotlightRect = controllerMode + ? NSMakeRect(NSWidth(bounds) * 0.5 - 520.0, -360.0, 1040.0, 760.0) + : NSMakeRect(NSWidth(bounds) * 0.5 - 360.0, -300.0, 720.0, 720.0); [spotlight drawInBezierPath:[NSBezierPath bezierPathWithOvalInRect:spotlightRect] angle:90.0]; NSBezierPath *lowerGlow = [NSBezierPath bezierPathWithOvalInRect: NSMakeRect(NSWidth(bounds) - 500.0, NSHeight(bounds) - 360.0, 520.0, 520.0)]; - [OpnColor(kLinkBlue, 0.045) setFill]; + [controllerMode ? OpnColor(kBrandGreen, 0.10) : OpnColor(kLinkBlue, 0.045) setFill]; [lowerGlow fill]; if (self.mode == OPNBackdropModeAuth) { return; } - CGFloat navHeight = 64.0; + CGFloat navHeight = controllerMode ? 136.0 : 64.0; NSRect navRect = NSMakeRect(0, 0, NSWidth(bounds), navHeight); - [OpnColor(0x1C1D21, 0.82) setFill]; + [controllerMode ? OpnColor(0x06080D, 0.42) : OpnColor(0x1C1D21, 0.82) setFill]; NSRectFill(navRect); [OpnColor(0xFFFFFF, 0.08) setFill]; NSRectFill(NSMakeRect(0, navHeight - 1.0, NSWidth(bounds), 1)); - [@"OpenNOW" drawInRect:NSMakeRect(24.0, 21.0, 132, 22) - withAttributes:OpnTextStyle(16.0, OpnColor(kTextPrimary), NSFontWeightSemibold)]; + if (!controllerMode) { + [@"OpenNOW" drawInRect:NSMakeRect(32.0, 21.0, 132, 22) + withAttributes:OpnTextStyle(16.0, OpnColor(kTextPrimary), NSFontWeightSemibold)]; + } - NSArray *items = @[@"Store", @"Library", @"Settings"]; - CGFloat widths[] = {78.0, 86.0, 90.0}; - CGFloat navWidth = widths[0] + widths[1] + widths[2] + 8.0; - CGFloat x = floor((NSWidth(bounds) - navWidth) / 2.0); - NSRect segmentedRect = NSMakeRect(x - 4.0, 15.0, navWidth + 8.0, 34.0); - NSBezierPath *segmented = [NSBezierPath bezierPathWithRoundedRect:segmentedRect xRadius:10.0 yRadius:10.0]; - [OpnColor(0xFFFFFF, 0.055) setFill]; + NSArray *items = controllerMode ? @[@"Library", @"Settings"] : @[@"Store", @"Library", @"Settings"]; + CGFloat widths[] = {86.0, 90.0, 78.0}; + CGFloat navWidth = controllerMode ? widths[0] + widths[1] + 10.0 : widths[2] + widths[0] + widths[1] + 8.0; + CGFloat x = controllerMode ? 28.0 : floor((NSWidth(bounds) - navWidth) / 2.0); + _storeNavFrame = controllerMode ? NSZeroRect : _storeNavFrame; + NSRect segmentedRect = NSMakeRect(x - 8.0, controllerMode ? 86.0 : 15.0, navWidth + 16.0, controllerMode ? 42.0 : 34.0); + NSBezierPath *segmented = [NSBezierPath bezierPathWithRoundedRect:segmentedRect xRadius:controllerMode ? 21.0 : 10.0 yRadius:controllerMode ? 21.0 : 10.0]; + [controllerMode ? OpnColor(0xFFFFFF, 0.035) : OpnColor(0xFFFFFF, 0.055) setFill]; [segmented fill]; for (NSUInteger i = 0; i < items.count; i++) { - BOOL active = (self.mode == OPNBackdropModeStore && i == 0) || - (self.mode == OPNBackdropModeLibrary && i == 1) || - (self.mode == OPNBackdropModeSettings && i == 2); - NSRect itemRect = NSMakeRect(x, 18.0, widths[i], 28.0); - if (i == 0) _storeNavFrame = itemRect; - if (i == 1) _libraryNavFrame = itemRect; - if (i == 2) _settingsNavFrame = itemRect; + NSString *item = items[i]; + CGFloat itemWidth = [item isEqualToString:@"Store"] ? widths[2] : ([item isEqualToString:@"Library"] ? widths[0] : widths[1]); + BOOL active = ([item isEqualToString:@"Store"] && self.mode == OPNBackdropModeStore) || + ([item isEqualToString:@"Library"] && self.mode == OPNBackdropModeLibrary) || + ([item isEqualToString:@"Settings"] && self.mode == OPNBackdropModeSettings); + NSRect itemRect = NSMakeRect(x, controllerMode ? 90.0 : 18.0, itemWidth, controllerMode ? 34.0 : 28.0); + if ([item isEqualToString:@"Store"]) _storeNavFrame = itemRect; + if ([item isEqualToString:@"Library"]) _libraryNavFrame = itemRect; + if ([item isEqualToString:@"Settings"]) _settingsNavFrame = itemRect; if (active) { - NSBezierPath *pill = [NSBezierPath bezierPathWithRoundedRect:itemRect xRadius:8.0 yRadius:8.0]; - [OpnColor(0xFFFFFF, 0.14) setFill]; + NSBezierPath *pill = [NSBezierPath bezierPathWithRoundedRect:itemRect xRadius:controllerMode ? 17.0 : 8.0 yRadius:controllerMode ? 17.0 : 8.0]; + [controllerMode ? OpnColor(kBrandGreen, 0.26) : OpnColor(0xFFFFFF, 0.14) setFill]; [pill fill]; } NSColor *textColor = active ? OpnColor(kTextPrimary) : OpnColor(kTextMuted); NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; style.alignment = NSTextAlignmentCenter; - NSMutableDictionary *attrs = [OpnTextStyle(13, textColor, active ? NSFontWeightSemibold : NSFontWeightRegular) mutableCopy]; + NSMutableDictionary *attrs = [OpnTextStyle(controllerMode ? 18 : 13, textColor, active ? NSFontWeightSemibold : NSFontWeightRegular) mutableCopy]; attrs[NSParagraphStyleAttributeName] = style; - [items[i] drawInRect:NSInsetRect(itemRect, 0, 6.0) withAttributes:attrs]; - x += widths[i] + 4.0; + [item drawInRect:NSInsetRect(itemRect, 0, controllerMode ? 8.0 : 6.0) withAttributes:attrs]; + x += itemWidth + (controllerMode ? 10.0 : 4.0); } NSString *remaining = self.remainingPlayTime.length > 0 ? self.remainingPlayTime : @"--"; - NSRect planRect = NSMakeRect(NSWidth(bounds) - 294, 11.0, 108, 26); + NSRect planRect = controllerMode ? NSMakeRect(28.0, 52.0, 108.0, 26.0) : NSMakeRect(NSWidth(bounds) - 294, 11.0, 108, 26); NSBezierPath *planPill = [NSBezierPath bezierPathWithRoundedRect:planRect xRadius:14 yRadius:14]; [OpnColor(0xFFFFFF, 0.075) setFill]; [planPill fill]; @@ -194,10 +297,10 @@ - (void)drawRect:(NSRect)dirtyRect { gameCountStyle.alignment = NSTextAlignmentCenter; NSMutableDictionary *gameCountAttrs = [OpnTextStyle(10, OpnColor(kTextMuted), NSFontWeightMedium) mutableCopy]; gameCountAttrs[NSParagraphStyleAttributeName] = gameCountStyle; - [gameCount drawInRect:NSMakeRect(NSMinX(planRect), 40.0, NSWidth(planRect), 14) + [gameCount drawInRect:controllerMode ? NSMakeRect(NSMaxX(planRect) + 12.0, 58.0, 120.0, 14.0) : NSMakeRect(NSMinX(planRect), 40.0, NSWidth(planRect), 14) withAttributes:gameCountAttrs]; - NSRect avatarRect = NSMakeRect(NSWidth(bounds) - 164, 17.0, 30, 30); + NSRect avatarRect = controllerMode ? NSMakeRect(NSWidth(bounds) - 252.0, 18.0, 30.0, 30.0) : NSMakeRect(NSWidth(bounds) - 164, 17.0, 30, 30); NSBezierPath *avatar = [NSBezierPath bezierPathWithOvalInRect:avatarRect]; NSString *name = self.accountName.length > 0 ? self.accountName : @"User"; @@ -220,16 +323,16 @@ - (void)drawRect:(NSRect)dirtyRect { [initial drawInRect:NSMakeRect(NSMinX(avatarRect), NSMinY(avatarRect) + 7, 30, 16) withAttributes:avatarAttrs]; } - [name drawInRect:NSMakeRect(NSWidth(bounds) - 124, 16.0, 72, 17) + [name drawInRect:controllerMode ? NSMakeRect(NSWidth(bounds) - 212.0, 17.0, 160.0, 17.0) : NSMakeRect(NSWidth(bounds) - 124, 16.0, 72, 17) withAttributes:OpnTextStyle(12, OpnColor(kTextPrimary), NSFontWeightSemibold)]; NSString *status = self.accountStatus.length > 0 ? self.accountStatus : @"Signed in"; - [status drawInRect:NSMakeRect(NSWidth(bounds) - 124, 32.0, 72, 14) + [status drawInRect:controllerMode ? NSMakeRect(NSWidth(bounds) - 212.0, 33.0, 160.0, 14.0) : NSMakeRect(NSWidth(bounds) - 124, 32.0, 72, 14) withAttributes:OpnTextStyle(10, OpnColor(kTextMuted), NSFontWeightRegular)]; - _accountFrame = NSMakeRect(NSWidth(bounds) - 174, 9.0, 154, 48); + _accountFrame = controllerMode ? NSMakeRect(NSWidth(bounds) - 264.0, 10.0, 244.0, 44.0) : NSMakeRect(NSWidth(bounds) - 174, 9.0, 154, 48); NSBezierPath *chevron = [NSBezierPath bezierPath]; - CGFloat chevronX = NSWidth(bounds) - 36.0; - CGFloat chevronY = 28.0; + CGFloat chevronX = controllerMode ? NSWidth(bounds) - 36.0 : NSWidth(bounds) - 36.0; + CGFloat chevronY = controllerMode ? 31.0 : 28.0; [chevron moveToPoint:NSMakePoint(chevronX - 4.0, chevronY - 2.0)]; [chevron lineToPoint:NSMakePoint(chevronX, chevronY + 2.0)]; [chevron lineToPoint:NSMakePoint(chevronX + 4.0, chevronY - 2.0)]; @@ -325,7 +428,7 @@ - (void)exitMenuItemPressed:(id)sender { - (void)mouseDown:(NSEvent *)event { NSPoint point = [self convertPoint:event.locationInWindow fromView:nil]; - if (NSPointInRect(point, _storeNavFrame)) { + if (!OpnControllerModeEnabled() && NSPointInRect(point, _storeNavFrame)) { if (self.onStoreSelected) self.onStoreSelected(); return; } diff --git a/src/views/OPNGameCardView.h b/src/views/OPNGameCardView.h index b468a5652..a3b4c3983 100644 --- a/src/views/OPNGameCardView.h +++ b/src/views/OPNGameCardView.h @@ -5,6 +5,7 @@ @property (nonatomic, readonly) OPN::GameInfo game; @property (nonatomic, assign) int selectedVariantIndex; +@property (nonatomic, assign, getter=isControllerFocused) BOOL controllerFocused; @property (nonatomic, copy) void (^onPlay)(); - (instancetype)initWithFrame:(NSRect)frame game:(const OPN::GameInfo &)game; diff --git a/src/views/OPNGameCardView.mm b/src/views/OPNGameCardView.mm index c3ce6e5f2..ebc5f9e04 100644 --- a/src/views/OPNGameCardView.mm +++ b/src/views/OPNGameCardView.mm @@ -131,6 +131,7 @@ @interface OPNGameCardView () @property (nonatomic, strong) NSButton *playButton; @property (nonatomic, strong) NSMutableArray *storeChipButtons; - (void)loadImageFromCandidates:(NSArray *)urlStrings index:(NSUInteger)index; +- (void)applyFocusStyle; @end @implementation OPNGameCardView @@ -222,6 +223,23 @@ - (instancetype)initWithFrame:(NSRect)frame game:(const OPN::GameInfo &)game { return self; } +- (void)setControllerFocused:(BOOL)controllerFocused { + if (_controllerFocused == controllerFocused) return; + _controllerFocused = controllerFocused; + [self applyFocusStyle]; +} + +- (void)applyFocusStyle { + BOOL selected = self.controllerFocused; + self.playButton.hidden = !selected; + self.layer.borderColor = selected ? OpnColor(kBrandGreen, 0.86).CGColor : OpnColor(0xFFFFFF, 0.10).CGColor; + self.layer.borderWidth = selected ? 2.0 : 1.0; + self.layer.shadowColor = OpnColor(kBrandGreen).CGColor; + self.layer.shadowOpacity = selected ? 0.42 : 0.0; + self.layer.shadowRadius = selected ? 26.0 : 0.0; + self.layer.shadowOffset = CGSizeZero; +} + - (BOOL)isFlipped { return YES; } - (void)layout { @@ -364,14 +382,18 @@ - (void)loadImageFromCandidates:(NSArray *)urlStrings index:(NSUInte - (void)mouseEntered:(NSEvent *)event { [super mouseEntered:event]; - self.playButton.hidden = NO; - self.layer.borderColor = OpnColor(0xFFFFFF, 0.22).CGColor; + if (!self.controllerFocused) { + self.playButton.hidden = NO; + self.layer.borderColor = OpnColor(0xFFFFFF, 0.22).CGColor; + } } - (void)mouseExited:(NSEvent *)event { [super mouseExited:event]; - self.playButton.hidden = YES; - self.layer.borderColor = OpnColor(0xFFFFFF, 0.10).CGColor; + if (!self.controllerFocused) { + self.playButton.hidden = YES; + self.layer.borderColor = OpnColor(0xFFFFFF, 0.10).CGColor; + } } - (void)updateTrackingAreas { diff --git a/src/views/OPNGameCatalogView.mm b/src/views/OPNGameCatalogView.mm index 72596c63b..5abdf1169 100644 --- a/src/views/OPNGameCatalogView.mm +++ b/src/views/OPNGameCatalogView.mm @@ -3,6 +3,7 @@ #import "OPNLoadingView.h" #import "../common/OPNColorTokens.h" #import "../common/OPNUIHelpers.h" +#import #include #include #include @@ -82,6 +83,12 @@ @interface OPNGameCatalogView () @property (nonatomic, strong) NSTextField *gameCountLabel; @property (nonatomic, strong) NSTextField *statusLabel; @property (nonatomic, strong) OPNLoadingView *loadingView; +@property (nonatomic, strong) NSView *controllerDetailView; +@property (nonatomic, strong) NSTextField *controllerDetailTitleLabel; +@property (nonatomic, strong) NSTextField *controllerDetailMetaLabel; +@property (nonatomic, strong) NSTextField *controllerDetailStoreLabel; +@property (nonatomic, strong) NSTextField *controllerDetailFeaturesLabel; +@property (nonatomic, strong) NSTextField *controllerDetailHintLabel; @property (nonatomic, strong) NSMutableArray *cardViews; @property (nonatomic, assign) std::vector allGames; @property (nonatomic, assign) CGFloat lastLayoutWidth; @@ -92,10 +99,52 @@ @interface OPNGameCatalogView () @property (nonatomic, assign) std::vector catalogSortOptions; @property (nonatomic, assign) NSInteger catalogTotalCount; @property (nonatomic, assign) NSInteger catalogSupportedCount; +@property (nonatomic, assign) NSInteger focusedCardIndex; +@property (nonatomic, assign) NSInteger gridColumnCount; +@property (nonatomic, strong) NSView *detailsOverlayView; +@property (nonatomic, strong) NSTimer *gamepadNavigationTimer; +@property (nonatomic, assign) uint16_t previousGamepadButtons; +@property (nonatomic, assign) CFTimeInterval lastGamepadMoveTime; - (void)scrollLibraryToTop; - (void)requestCatalogBrowse; +- (void)focusCardAtIndex:(NSInteger)index scrollIntoView:(BOOL)scrollIntoView; +- (void)openFocusedGameDetails; +- (void)closeGameDetails; +- (void)launchFocusedGame; +- (void)cycleFocusedVariant; +- (void)updateControllerDetailContent; +- (void)startGamepadNavigationIfNeeded; +- (void)controllerDidConnect:(NSNotification *)notification; +- (void)controllerDidDisconnect:(NSNotification *)notification; @end +static uint16_t OPNCatalogGamepadButtons(void) { + NSArray *controllers = [GCController controllers]; + if (controllers.count == 0) return 0; + GCExtendedGamepad *pad = controllers.firstObject.extendedGamepad; + if (!pad) return 0; + uint16_t buttons = 0; + if (pad.buttonA.value > 0.5) buttons |= 1u << 0; + if (pad.buttonB.value > 0.5) buttons |= 1u << 1; + if (pad.buttonY.value > 0.5) buttons |= 1u << 2; + if (pad.leftShoulder.value > 0.5) buttons |= 1u << 3; + if (pad.rightShoulder.value > 0.5) buttons |= 1u << 4; + if (pad.dpad.up.value > 0.5 || pad.leftThumbstick.yAxis.value > 0.65) buttons |= 1u << 5; + if (pad.dpad.down.value > 0.5 || pad.leftThumbstick.yAxis.value < -0.65) buttons |= 1u << 6; + if (pad.dpad.left.value > 0.5 || pad.leftThumbstick.xAxis.value < -0.65) buttons |= 1u << 7; + if (pad.dpad.right.value > 0.5 || pad.leftThumbstick.xAxis.value > 0.65) buttons |= 1u << 8; + return buttons; +} + +static NSString *OPNCatalogJoinedStrings(const std::vector &values, NSString *fallback) { + NSMutableArray *items = [NSMutableArray array]; + for (const std::string &value : values) { + if (!value.empty()) [items addObject:[NSString stringWithUTF8String:value.c_str()]]; + if (items.count >= 4) break; + } + return items.count > 0 ? [items componentsJoinedByString:@" / "] : fallback; +} + @implementation OPNGameCatalogView using namespace OPN; @@ -106,6 +155,8 @@ - (instancetype)initWithFrame:(NSRect)frame { _cardViews = [NSMutableArray array]; _selectedSortId = @"last_played"; _selectedFilterIds = [NSMutableSet set]; + _focusedCardIndex = -1; + _gridColumnCount = 1; self.wantsLayer = YES; self.layer.backgroundColor = [NSColor clearColor].CGColor; @@ -222,6 +273,32 @@ - (instancetype)initWithFrame:(NSRect)frame { _statusLabel.alignment = NSTextAlignmentCenter; [self addSubview:_statusLabel]; + _controllerDetailView = [[OPNFlippedGridDocumentView alloc] initWithFrame:NSZeroRect]; + _controllerDetailView.hidden = YES; + _controllerDetailView.wantsLayer = YES; + _controllerDetailView.layer.cornerRadius = 26.0; + _controllerDetailView.layer.borderWidth = 1.0; + _controllerDetailView.layer.borderColor = OpnColor(kBrandGreen, 0.20).CGColor; + _controllerDetailView.layer.backgroundColor = OpnColor(0x07090F, 0.32).CGColor; + [self addSubview:_controllerDetailView]; + + _controllerDetailTitleLabel = OpnLabel(@"Select a game", NSZeroRect, 42.0, OpnColor(kTextPrimary), NSFontWeightSemibold); + _controllerDetailTitleLabel.lineBreakMode = NSLineBreakByTruncatingTail; + [_controllerDetailView addSubview:_controllerDetailTitleLabel]; + + _controllerDetailMetaLabel = OpnLabel(@"", NSZeroRect, 15.0, OpnColor(kTextSecondary), NSFontWeightMedium); + [_controllerDetailView addSubview:_controllerDetailMetaLabel]; + + _controllerDetailStoreLabel = OpnLabel(@"", NSZeroRect, 16.0, OpnColor(kBrandGreen), NSFontWeightSemibold); + [_controllerDetailView addSubview:_controllerDetailStoreLabel]; + + _controllerDetailFeaturesLabel = OpnLabel(@"", NSZeroRect, 14.0, OpnColor(kTextMuted), NSFontWeightRegular); + _controllerDetailFeaturesLabel.maximumNumberOfLines = 2; + [_controllerDetailView addSubview:_controllerDetailFeaturesLabel]; + + _controllerDetailHintLabel = OpnLabel(@"✕ Play △ Change Store L1/R1 Menu Options Account", NSZeroRect, 13.0, OpnColor(kTextMuted), NSFontWeightMedium); + [_controllerDetailView addSubview:_controllerDetailHintLabel]; + _loadingView = [[OPNLoadingView alloc] initWithFrame:self.bounds message:@"Loading games..."]; _loadingView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; @@ -231,6 +308,15 @@ - (instancetype)initWithFrame:(NSRect)frame { selector:@selector(interfacePreferencesChanged:) name:OPNInterfacePreferencesDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(controllerDidConnect:) + name:GCControllerDidConnectNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(controllerDidDisconnect:) + name:GCControllerDidDisconnectNotification + object:nil]; + [self startGamepadNavigationIfNeeded]; [self layoutCatalogSubviews]; } return self; @@ -238,11 +324,22 @@ - (instancetype)initWithFrame:(NSRect)frame { - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; + [self.gamepadNavigationTimer invalidate]; } - (void)interfacePreferencesChanged:(NSNotification *)notification { (void)notification; + self.layer.backgroundColor = OpnControllerModeEnabled() ? OpnColor(kBrandGreen, 0.035).CGColor : [NSColor clearColor].CGColor; [self renderGrid]; + [self startGamepadNavigationIfNeeded]; +} + +- (BOOL)acceptsFirstResponder { return YES; } + +- (BOOL)becomeFirstResponder { + BOOL result = [super becomeFirstResponder]; + if (self.focusedCardIndex < 0 && self.cardViews.count > 0) [self focusCardAtIndex:0 scrollIntoView:NO]; + return result; } - (BOOL)isFlipped { return YES; } @@ -322,12 +419,14 @@ - (void)renderGrid { CGFloat cardWidth = [OPNGameCardView cardSize].width; CGFloat cardHeight = [OPNGameCardView cardSize].height; + BOOL controllerMode = OpnControllerModeEnabled(); CGFloat availableWidth = _scrollView.frame.size.width; - NSInteger cols = MAX(1, (NSInteger)((availableWidth + kCardSpacing) / (cardWidth + kCardSpacing))); - CGFloat gridSpacing = cols > 1 ? floor((availableWidth - cols * cardWidth) / (cols - 1)) : kCardSpacing; + NSInteger cols = controllerMode ? 1 : MAX(1, (NSInteger)((availableWidth + kCardSpacing) / (cardWidth + kCardSpacing))); + self.gridColumnCount = cols; + CGFloat gridSpacing = controllerMode ? 26.0 : (cols > 1 ? floor((availableWidth - cols * cardWidth) / (cols - 1)) : kCardSpacing); gridSpacing = MAX(kCardSpacing, gridSpacing); - CGFloat xStart = cols > 1 ? 0.0 : floor(MAX(0.0, (_scrollView.frame.size.width - cardWidth) / 2.0)); - CGFloat yPos = kGridPadding; + CGFloat xStart = controllerMode ? 32.0 : (cols > 1 ? 0.0 : floor(MAX(0.0, (_scrollView.frame.size.width - cardWidth) / 2.0))); + CGFloat yPos = controllerMode ? 24.0 : kGridPadding; std::vector displayGames = _allGames; @@ -335,7 +434,7 @@ - (void)renderGrid { NSInteger visibleCount = 0; for (auto it = displayGames.begin(); it != displayGames.end(); ++it) { auto &game = *it; - CGFloat x = xStart + col * (cardWidth + gridSpacing); + CGFloat x = controllerMode ? xStart + visibleCount * (cardWidth + gridSpacing) : xStart + col * (cardWidth + gridSpacing); NSRect cardFrame = NSMakeRect(x, yPos, cardWidth, cardHeight); OPNGameCardView *card = [[OPNGameCardView alloc] initWithFrame:cardFrame game:game]; GameInfo gameCopy = game; @@ -344,7 +443,12 @@ - (void)renderGrid { card.onPlay = ^{ __typeof__(self) s = weakSelf; OPNGameCardView *c = weakCard; - if (s && s.onSelectGame && c) { + if (!s || !c) return; + NSUInteger cardIndex = [s.cardViews indexOfObject:c]; + if (cardIndex != NSNotFound) [s focusCardAtIndex:(NSInteger)cardIndex scrollIntoView:NO]; + if (OpnControllerModeEnabled()) { + [s launchFocusedGame]; + } else if (s.onSelectGame) { int variantIdx = c.selectedVariantIndex; s.onSelectGame(gameCopy, variantIdx >= 0 ? variantIdx : 0); } @@ -354,15 +458,17 @@ - (void)renderGrid { col++; visibleCount++; - if (col >= cols) { + if (!controllerMode && col >= cols) { col = 0; yPos += cardHeight + kCardSpacing; } } - CGFloat totalHeight = yPos + cardHeight + kGridPadding; - if (col == 0 && visibleCount > 0) totalHeight = yPos + kGridPadding; - CGFloat totalWidth = _scrollView.frame.size.width; + CGFloat totalHeight = controllerMode ? cardHeight + 48.0 : yPos + cardHeight + kGridPadding; + if (!controllerMode && col == 0 && visibleCount > 0) totalHeight = yPos + kGridPadding; + CGFloat totalWidth = controllerMode + ? xStart * 2.0 + visibleCount * cardWidth + MAX(0, visibleCount - 1) * gridSpacing + : _scrollView.frame.size.width; _gridContentView.frame = NSMakeRect(0, 0, MAX(totalWidth, _scrollView.frame.size.width), MAX(totalHeight, _scrollView.frame.size.height)); @@ -370,6 +476,10 @@ - (void)renderGrid { _gameCountLabel.stringValue = [NSString stringWithFormat:@"%ld %@", (long)visibleCount, visibleCount == 1 ? @"game" : @"games"]; if (self.onGameCountChanged) self.onGameCountChanged(visibleCount); _statusLabel.stringValue = visibleCount == 0 ? @"No games found." : @""; + if (self.focusedCardIndex >= (NSInteger)self.cardViews.count) self.focusedCardIndex = (NSInteger)self.cardViews.count - 1; + if (self.focusedCardIndex < 0 && self.cardViews.count > 0) self.focusedCardIndex = 0; + [self focusCardAtIndex:self.focusedCardIndex scrollIntoView:NO]; + [self updateControllerDetailContent]; } - (void)scrollLibraryToTop { @@ -486,11 +596,12 @@ - (void)viewDidEndLiveResize { - (void)layoutCatalogSubviews { CGFloat width = NSWidth(self.bounds); CGFloat height = NSHeight(self.bounds); - self.scrollView.hasVerticalScroller = YES; + BOOL controllerMode = OpnControllerModeEnabled(); + self.scrollView.hasVerticalScroller = !controllerMode; self.scrollView.hasHorizontalScroller = NO; BOOL compact = width < 900.0; - self.searchField.hidden = NO; - self.filterButton.hidden = compact; + self.searchField.hidden = controllerMode; + self.filterButton.hidden = controllerMode || compact; self.signOutButton.hidden = YES; CGFloat searchWidth = compact ? MAX(240.0, width - 64.0) : MIN(520.0, MAX(360.0, width * 0.38)); CGFloat searchX = floor((width - searchWidth) / 2.0); @@ -498,16 +609,36 @@ - (void)layoutCatalogSubviews { self.titleLabel.frame = NSMakeRect(0, 0, 0, 0); self.userLabel.frame = NSMakeRect(24, kNavHeight + 36, 260, 18); self.searchField.frame = NSMakeRect(searchX, compact ? kNavHeight + 62 : kNavHeight + 25, searchWidth, 40); - self.sortButton.hidden = compact; + self.sortButton.hidden = controllerMode || compact; self.filterButton.frame = NSMakeRect(NSMaxX(self.searchField.frame) + 14, kNavHeight + 26, 124, 38); self.sortButton.frame = NSMakeRect(NSMaxX(self.filterButton.frame) + 10, kNavHeight + 26, 154, 38); self.gameCountLabel.frame = NSMakeRect(0, 0, 0, 0); self.gameCountLabel.hidden = YES; self.signOutButton.frame = NSMakeRect(width - 116, kNavHeight + 13, 92, 30); - CGFloat gridY = kNavHeight + (compact ? 116.0 : kToolbarHeight); - self.scrollView.frame = NSMakeRect(0, gridY, width, MAX(0.0, height - gridY)); - self.statusLabel.frame = NSMakeRect(0, gridY + 100, width, 24); + CGFloat cardHeight = [OPNGameCardView cardSize].height; + CGFloat carouselHeight = cardHeight + 86.0; + CGFloat gridY = controllerMode ? MAX(kNavHeight + 116.0, height - carouselHeight - 88.0) : kNavHeight + (compact ? 116.0 : kToolbarHeight); + CGFloat detailY = kNavHeight + 24.0; + CGFloat detailHeight = controllerMode ? MAX(150.0, gridY - detailY - 58.0) : 0.0; + self.controllerDetailView.hidden = !controllerMode || self.cardViews.count == 0; + self.controllerDetailView.frame = NSMakeRect(28.0, detailY, MAX(260.0, width - 56.0), detailHeight); + CGFloat detailWidth = NSWidth(self.controllerDetailView.frame); + self.controllerDetailTitleLabel.frame = NSMakeRect(28.0, 24.0, MAX(220.0, detailWidth - 56.0), 52.0); + self.controllerDetailMetaLabel.frame = NSMakeRect(30.0, 84.0, MAX(220.0, detailWidth - 60.0), 22.0); + self.controllerDetailStoreLabel.frame = NSMakeRect(30.0, 118.0, MAX(220.0, detailWidth - 60.0), 24.0); + self.controllerDetailFeaturesLabel.frame = NSMakeRect(30.0, 154.0, MAX(220.0, detailWidth - 60.0), 46.0); + self.controllerDetailHintLabel.frame = NSMakeRect(30.0, MAX(116.0, detailHeight - 34.0), MAX(220.0, detailWidth - 60.0), 18.0); + self.scrollView.frame = controllerMode + ? NSMakeRect(0, gridY, width, MIN(carouselHeight, MAX(0.0, height - gridY))) + : NSMakeRect(0, gridY, width, MAX(0.0, height - gridY)); + self.statusLabel.frame = controllerMode ? NSMakeRect(28.0, MAX(kNavHeight + 30.0, gridY - 42.0), width - 56.0, 24.0) : NSMakeRect(0, gridY + 100, width, 24); + if (controllerMode && self.cardViews.count > 0) { + self.statusLabel.stringValue = @""; + self.statusLabel.textColor = OpnColor(kTextSecondary); + self.statusLabel.alignment = NSTextAlignmentCenter; + } self.loadingView.frame = self.bounds; + self.detailsOverlayView.frame = self.bounds; } - (void)searchChanged { @@ -534,6 +665,251 @@ - (void)requestCatalogBrowse { self.onCatalogBrowseRequested(self.searchField.stringValue ?: @"", self.selectedSortId ?: @"last_played", filters); } +- (void)focusCardAtIndex:(NSInteger)index scrollIntoView:(BOOL)scrollIntoView { + if (self.cardViews.count == 0) { + self.focusedCardIndex = -1; + return; + } + NSInteger clamped = MAX(0, MIN(index, (NSInteger)self.cardViews.count - 1)); + self.focusedCardIndex = clamped; + for (NSUInteger i = 0; i < self.cardViews.count; i++) { + self.cardViews[i].controllerFocused = OpnControllerModeEnabled() && (NSInteger)i == clamped; + } + [self updateControllerDetailContent]; + if (!scrollIntoView) return; + OPNGameCardView *card = self.cardViews[(NSUInteger)clamped]; + NSRect visibleRect = self.scrollView.contentView.bounds; + NSRect targetRect = NSInsetRect(card.frame, -24.0, -24.0); + if (!NSContainsRect(visibleRect, targetRect)) { + [self.gridContentView scrollRectToVisible:targetRect]; + [self.scrollView reflectScrolledClipView:self.scrollView.contentView]; + } +} + +- (OPNGameCardView *)focusedCard { + if (self.focusedCardIndex < 0 || self.focusedCardIndex >= (NSInteger)self.cardViews.count) return nil; + return self.cardViews[(NSUInteger)self.focusedCardIndex]; +} + +- (void)moveFocusByRows:(NSInteger)rows columns:(NSInteger)columns { + if (OpnControllerModeEnabled() && rows != 0) return; + NSInteger next = self.focusedCardIndex + rows * MAX(1, self.gridColumnCount) + columns; + [self focusCardAtIndex:next scrollIntoView:YES]; +} + +- (void)cycleFocusedVariant { + OPNGameCardView *card = [self focusedCard]; + if (!card || card.game.variants.size() <= 1) return; + int next = (card.selectedVariantIndex + 1) % (int)card.game.variants.size(); + [card selectVariantAtIndex:next]; + [self updateControllerDetailContent]; +} + +- (void)updateControllerDetailContent { + if (!OpnControllerModeEnabled()) return; + OPNGameCardView *card = [self focusedCard]; + if (!card) { + self.controllerDetailTitleLabel.stringValue = @"Select a game"; + self.controllerDetailMetaLabel.stringValue = @""; + self.controllerDetailStoreLabel.stringValue = @""; + self.controllerDetailFeaturesLabel.stringValue = @""; + return; + } + + const OPN::GameInfo game = card.game; + self.controllerDetailTitleLabel.stringValue = OPNCatalogString(game.title, @"Untitled Game"); + + NSString *genres = OPNCatalogJoinedStrings(game.genres, @"Cloud game"); + NSString *tier = OPNCatalogString(game.membershipTierLabel, @""); + NSString *playability = OPNCatalogString(game.playabilityState, @""); + NSMutableArray *meta = [NSMutableArray arrayWithObject:genres]; + if (tier.length > 0) [meta addObject:tier]; + if (playability.length > 0) [meta addObject:playability.capitalizedString]; + self.controllerDetailMetaLabel.stringValue = [meta componentsJoinedByString:@" • "]; + + NSString *store = @"Default store"; + if (card.selectedVariantIndex >= 0 && card.selectedVariantIndex < (int)game.variants.size()) { + store = OPNCatalogString(game.variants[(size_t)card.selectedVariantIndex].appStore, store); + } else if (!game.availableStores.empty()) { + store = OPNCatalogString(game.availableStores.front(), store); + } + NSString *storePrefix = game.variants.size() > 1 ? @"Selected store" : @"Store"; + self.controllerDetailStoreLabel.stringValue = [NSString stringWithFormat:@"%@: %@", storePrefix, store]; + + NSString *features = OPNCatalogJoinedStrings(game.featureLabels, @""); + if (features.length == 0 && !game.shortName.empty()) features = OPNCatalogString(game.shortName, @""); + self.controllerDetailFeaturesLabel.stringValue = features.length > 0 ? features : @"Press Cross / A to launch this game."; + self.controllerDetailHintLabel.stringValue = game.variants.size() > 1 + ? @"✕ Play △ Change Store L1/R1 Menu Options Account" + : @"✕ Play L1/R1 Menu Options Account"; +} + +- (void)openFocusedGameDetails { + OPNGameCardView *card = [self focusedCard]; + if (!card) return; + [self.detailsOverlayView removeFromSuperview]; + NSView *overlay = [[NSView alloc] initWithFrame:self.bounds]; + overlay.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + overlay.wantsLayer = YES; + overlay.layer.backgroundColor = OpnColor(kBlack, 0.58).CGColor; + + CGFloat panelWidth = MIN(760.0, MAX(420.0, NSWidth(self.bounds) - 96.0)); + CGFloat panelHeight = 390.0; + NSView *panel = [[OPNFlippedGridDocumentView alloc] initWithFrame:NSMakeRect(floor((NSWidth(self.bounds) - panelWidth) / 2.0), + floor((NSHeight(self.bounds) - panelHeight) / 2.0), + panelWidth, + panelHeight)]; + panel.wantsLayer = YES; + panel.layer.cornerRadius = 30.0; + panel.layer.borderWidth = 1.5; + panel.layer.borderColor = OpnColor(kBrandGreen, 0.58).CGColor; + panel.layer.backgroundColor = OpnColor(0x080A10, 0.94).CGColor; + panel.layer.shadowColor = OpnColor(kBrandGreen).CGColor; + panel.layer.shadowOpacity = 0.34; + panel.layer.shadowRadius = 42.0; + panel.layer.shadowOffset = CGSizeZero; + [overlay addSubview:panel]; + + NSString *title = OPNCatalogString(card.game.title, @"Game Details"); + NSTextField *titleLabel = OpnLabel(title, NSMakeRect(36.0, 34.0, panelWidth - 72.0, 42.0), 30.0, OpnColor(kTextPrimary), NSFontWeightSemibold); + titleLabel.lineBreakMode = NSLineBreakByTruncatingTail; + [panel addSubview:titleLabel]; + + NSString *store = @"Default store"; + if (card.selectedVariantIndex >= 0 && card.selectedVariantIndex < (int)card.game.variants.size()) { + store = OPNCatalogString(card.game.variants[(size_t)card.selectedVariantIndex].appStore, store); + } + NSTextField *storeLabel = OpnLabel([NSString stringWithFormat:@"Selected store: %@", store], + NSMakeRect(38.0, 92.0, panelWidth - 76.0, 24.0), + 15.0, + OpnColor(kBrandGreen), + NSFontWeightSemibold); + [panel addSubview:storeLabel]; + + NSString *body = card.game.variants.size() > 1 + ? @"Press Triangle / Y to cycle stores. Press Cross / A to launch. Press Circle / B to return to the library." + : @"Press Cross / A to launch. Press Circle / B to return to the library."; + NSTextField *bodyLabel = OpnLabel(body, NSMakeRect(38.0, 136.0, panelWidth - 76.0, 58.0), 14.0, OpnColor(kTextSecondary), NSFontWeightRegular); + bodyLabel.maximumNumberOfLines = 3; + [panel addSubview:bodyLabel]; + + NSButton *playButton = OpnButton(@"Play", NSMakeRect(38.0, panelHeight - 96.0, 180.0, 52.0), OpnColor(kBrandGreen, 0.96), OpnColor(kAccentOn)); + playButton.target = self; + playButton.action = @selector(detailsPlayClicked:); + playButton.layer.cornerRadius = 18.0; + [panel addSubview:playButton]; + + NSButton *closeButton = OpnButton(@"Back", NSMakeRect(232.0, panelHeight - 96.0, 132.0, 52.0), OpnColor(0xFFFFFF, 0.08), OpnColor(kTextPrimary), true, OpnColor(0xFFFFFF, 0.16)); + closeButton.target = self; + closeButton.action = @selector(detailsCloseClicked:); + closeButton.layer.cornerRadius = 18.0; + [panel addSubview:closeButton]; + + NSTextField *hints = OpnLabel(@"✕ Play △ Change Store ○ Back", NSMakeRect(38.0, panelHeight - 34.0, panelWidth - 76.0, 20.0), 12.0, OpnColor(kTextMuted), NSFontWeightMedium); + [panel addSubview:hints]; + + self.detailsOverlayView = overlay; + [self addSubview:overlay]; +} + +- (void)closeGameDetails { + [self.detailsOverlayView removeFromSuperview]; + self.detailsOverlayView = nil; + [self.window makeFirstResponder:self]; +} + +- (void)launchFocusedGame { + OPNGameCardView *card = [self focusedCard]; + if (!card || !self.onSelectGame) return; + int variantIdx = card.selectedVariantIndex >= 0 ? card.selectedVariantIndex : 0; + self.onSelectGame(card.game, variantIdx); +} + +- (void)detailsPlayClicked:(id)sender { + (void)sender; + [self launchFocusedGame]; +} + +- (void)detailsCloseClicked:(id)sender { + (void)sender; + [self closeGameDetails]; +} + +- (void)keyDown:(NSEvent *)event { + if (!OpnControllerModeEnabled()) { + [super keyDown:event]; + return; + } + NSString *chars = event.charactersIgnoringModifiers.lowercaseString ?: @""; + switch (event.keyCode) { + case 123: [self moveFocusByRows:0 columns:-1]; return; + case 124: [self moveFocusByRows:0 columns:1]; return; + case 125: [self moveFocusByRows:1 columns:0]; return; + case 126: [self moveFocusByRows:-1 columns:0]; return; + case 36: + case 49: + [self launchFocusedGame]; + return; + case 53: + return; + default: + break; + } + if ([chars isEqualToString:@"v"] || [chars isEqualToString:@"y"]) { + [self cycleFocusedVariant]; + return; + } + [super keyDown:event]; +} + +- (void)startGamepadNavigationIfNeeded { + if (!OpnControllerModeEnabled() || self.gamepadNavigationTimer) return; + self.gamepadNavigationTimer = [NSTimer scheduledTimerWithTimeInterval:(1.0 / 30.0) + target:self + selector:@selector(pollGamepadNavigation) + userInfo:nil + repeats:YES]; +} + +- (void)controllerDidConnect:(NSNotification *)notification { + (void)notification; + [self startGamepadNavigationIfNeeded]; +} + +- (void)controllerDidDisconnect:(NSNotification *)notification { + (void)notification; + self.previousGamepadButtons = 0; +} + +- (void)pollGamepadNavigation { + if (!OpnControllerModeEnabled()) { + [self.gamepadNavigationTimer invalidate]; + self.gamepadNavigationTimer = nil; + self.previousGamepadButtons = 0; + return; + } + if (self.window.firstResponder != self.searchField) [self.window makeFirstResponder:self]; + uint16_t buttons = OPNCatalogGamepadButtons(); + uint16_t pressed = buttons & (uint16_t)~self.previousGamepadButtons; + CFTimeInterval now = CACurrentMediaTime(); + BOOL repeatMove = (now - self.lastGamepadMoveTime) > 0.22; + uint16_t moves = buttons & ((1u << 5) | (1u << 6) | (1u << 7) | (1u << 8)); + if (moves && repeatMove) { + pressed |= moves; + self.lastGamepadMoveTime = now; + } + if (pressed & (1u << 0)) { + [self launchFocusedGame]; + } + if (pressed & (1u << 1)) { } + if (pressed & (1u << 2)) [self cycleFocusedVariant]; + if (pressed & (1u << 5)) [self moveFocusByRows:-1 columns:0]; + if (pressed & (1u << 6)) [self moveFocusByRows:1 columns:0]; + if (pressed & (1u << 7)) [self moveFocusByRows:0 columns:-1]; + if (pressed & (1u << 8)) [self moveFocusByRows:0 columns:1]; + self.previousGamepadButtons = buttons; +} + - (void)signOutClicked { if (self.onSignOut) self.onSignOut(); } diff --git a/src/views/OPNSettingsView.mm b/src/views/OPNSettingsView.mm index 4c2825ffe..224462e92 100644 --- a/src/views/OPNSettingsView.mm +++ b/src/views/OPNSettingsView.mm @@ -735,7 +735,7 @@ - (void)buildInputContent { } - (void)buildInterfaceContent { - NSView *panel = [self panelWithTitle:@"Interface" height:408.0]; + NSView *panel = [self panelWithTitle:@"Interface" height:512.0]; CGFloat panelWidth = MAX(320.0, NSWidth(panel.frame)); CGFloat controlX = [self controlXForPanelWidth:panelWidth]; CGFloat controlWidth = [self controlWidthForPanelWidth:panelWidth]; @@ -745,15 +745,34 @@ - (void)buildInterfaceContent { NSInteger green = (NSInteger)((accent >> 8) & 0xFF); NSInteger blue = (NSInteger)(accent & 0xFF); - [panel addSubview:[self rowLabel:@"Accent Color" y:104.0]]; + [panel addSubview:[self rowLabel:@"Controller Mode" y:104.0]]; + NSButton *controllerModeToggle = [[NSButton alloc] initWithFrame:NSMakeRect(controlX, 96.0, controlWidth, 28.0)]; + controllerModeToggle.buttonType = NSButtonTypeSwitch; + controllerModeToggle.title = @"Use console-style menus optimized for gamepad navigation"; + controllerModeToggle.font = [NSFont systemFontOfSize:13.0 weight:NSFontWeightMedium]; + controllerModeToggle.contentTintColor = OpnColor(kBrandGreen); + controllerModeToggle.state = OpnControllerModeEnabled() ? NSControlStateValueOn : NSControlStateValueOff; + controllerModeToggle.target = self; + controllerModeToggle.action = @selector(controllerModeToggleChanged:); + [panel addSubview:controllerModeToggle]; + + NSTextField *controllerHint = OpnLabel(@"Controller Mode keeps mouse and keyboard support, but makes gamepad focus, details, and launch flow primary.", + NSMakeRect(controlX, 132.0, controlWidth, 38.0), + 12.0, + OpnColor(kTextMuted), + NSFontWeightRegular); + controllerHint.maximumNumberOfLines = 2; + [panel addSubview:controllerHint]; + + [panel addSubview:[self rowLabel:@"Accent Color" y:198.0]]; NSTextField *accentSummary = OpnLabel([NSString stringWithFormat:@"RGB %ld, %ld, %ld", (long)red, (long)green, (long)blue], - NSMakeRect(controlX, 104.0, controlWidth, 20.0), + NSMakeRect(controlX, 198.0, controlWidth, 20.0), 13.0, OpnColor(kTextPrimary), NSFontWeightSemibold); [panel addSubview:accentSummary]; - NSView *swatch = [[NSView alloc] initWithFrame:NSMakeRect(controlX + MIN(162.0, controlWidth - 36.0), 101.0, 34.0, 24.0)]; + NSView *swatch = [[NSView alloc] initWithFrame:NSMakeRect(controlX + MIN(162.0, controlWidth - 36.0), 195.0, 34.0, 24.0)]; swatch.wantsLayer = YES; swatch.layer.cornerRadius = 8.0; swatch.layer.backgroundColor = OpnColor(kBrandGreen).CGColor; @@ -764,7 +783,7 @@ - (void)buildInterfaceContent { NSArray *channelNames = @[@"Red", @"Green", @"Blue"]; NSArray *channelValues = @[@(red), @(green), @(blue)]; for (NSInteger i = 0; i < 3; i++) { - CGFloat y = 140.0 + i * 42.0; + CGFloat y = 234.0 + i * 42.0; NSTextField *label = OpnLabel(channelNames[(NSUInteger)i], NSMakeRect(controlX, y + 3.0, 62.0, 20.0), 12.0, OpnColor(kTextSecondary), NSFontWeightMedium); [panel addSubview:label]; @@ -790,8 +809,8 @@ - (void)buildInterfaceContent { if (i == 2) self.accentBlueValueLabel = valueLabel; } - [panel addSubview:[self rowLabel:@"Poster Size" y:274.0]]; - NSSlider *posterSlider = [[NSSlider alloc] initWithFrame:NSMakeRect(controlX, 268.0, MIN(300.0, controlWidth - 72.0), 28.0)]; + [panel addSubview:[self rowLabel:@"Poster Size" y:368.0]]; + NSSlider *posterSlider = [[NSSlider alloc] initWithFrame:NSMakeRect(controlX, 362.0, MIN(300.0, controlWidth - 72.0), 28.0)]; posterSlider.minValue = 80.0; posterSlider.maxValue = 130.0; posterSlider.doubleValue = OpnPosterSizeScale() * 100.0; @@ -801,15 +820,15 @@ - (void)buildInterfaceContent { [panel addSubview:posterSlider]; self.posterSizeValueLabel = OpnLabel([NSString stringWithFormat:@"%.0f%%", posterSlider.doubleValue], - NSMakeRect(controlX + MIN(312.0, controlWidth - 60.0), 272.0, 60.0, 22.0), + NSMakeRect(controlX + MIN(312.0, controlWidth - 60.0), 366.0, 60.0, 22.0), 12.0, OpnColor(kTextSecondary), NSFontWeightSemibold, NSTextAlignmentRight); [panel addSubview:self.posterSizeValueLabel]; - [panel addSubview:[self rowLabel:@"Auto Full Screen" y:346.0]]; - NSButton *autoFullScreenToggle = [[NSButton alloc] initWithFrame:NSMakeRect(controlX, 338.0, controlWidth, 28.0)]; + [panel addSubview:[self rowLabel:@"Auto Full Screen" y:440.0]]; + NSButton *autoFullScreenToggle = [[NSButton alloc] initWithFrame:NSMakeRect(controlX, 432.0, controlWidth, 28.0)]; autoFullScreenToggle.buttonType = NSButtonTypeSwitch; autoFullScreenToggle.title = @"Enter full screen automatically when a stream starts"; autoFullScreenToggle.font = [NSFont systemFontOfSize:13.0 weight:NSFontWeightMedium]; @@ -1075,6 +1094,11 @@ - (void)autoFullScreenToggleChanged:(NSButton *)sender { OpnSetAutoFullScreenEnabled(sender.state == NSControlStateValueOn); } +- (void)controllerModeToggleChanged:(NSButton *)sender { + OpnSetControllerModeEnabled(sender.state == NSControlStateValueOn); + [self rebuildContent]; +} + - (void)microphoneModePopupChanged:(NSPopUpButton *)sender { std::vector modes = OPN::StreamMicrophoneModeOptions(); NSInteger index = MAX(0, MIN(sender.indexOfSelectedItem, (NSInteger)modes.size() - 1)); From 067ada4973b8f39ed80db99c67c1393e35f4c5f0 Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Mon, 11 May 2026 12:52:33 -0500 Subject: [PATCH 04/18] Fix controller settings layout --- src/views/OPNSettingsView.mm | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/views/OPNSettingsView.mm b/src/views/OPNSettingsView.mm index 224462e92..07e6bac54 100644 --- a/src/views/OPNSettingsView.mm +++ b/src/views/OPNSettingsView.mm @@ -10,6 +10,7 @@ #include static const CGFloat kSettingsNavHeight = 64.0; +static const CGFloat kSettingsControllerNavHeight = 136.0; static const CGFloat kSettingsTopInset = 72.0; static const CGFloat kSettingsSidebarWidth = 300.0; static const CGFloat kSettingsColumnGap = 28.0; @@ -359,7 +360,8 @@ - (void)layout { CGFloat outerMargin = width < 900.0 ? 24.0 : 64.0; CGFloat contentWidth = MIN(1560.0, MAX(360.0, width - outerMargin * 2.0)); CGFloat x = floor((width - contentWidth) / 2.0); - CGFloat y = kSettingsNavHeight + kSettingsTopInset; + CGFloat navHeight = OpnControllerModeEnabled() ? kSettingsControllerNavHeight : kSettingsNavHeight; + CGFloat y = navHeight + kSettingsTopInset; self.titleLabel.frame = NSMakeRect(x, y - 48.0, 240.0, 34.0); CGFloat shellHeight = MAX(360.0, NSHeight(self.bounds) - y - 34.0); self.shellView.frame = NSMakeRect(x, y, contentWidth, shellHeight); From 7dfc922f73be51871027abb0566ee2a5a40e3b81 Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Mon, 11 May 2026 19:43:05 -0500 Subject: [PATCH 05/18] Remove app focus highlights --- src/OPNAppDelegate.mm | 6 ++++++ src/common/OPNUIHelpers.h | 2 ++ src/common/OPNUIHelpers.mm | 9 +++++++++ src/views/OPNGameCardView.mm | 9 ++++----- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/OPNAppDelegate.mm b/src/OPNAppDelegate.mm index 1ea5ad35c..6d6ad3623 100644 --- a/src/OPNAppDelegate.mm +++ b/src/OPNAppDelegate.mm @@ -372,6 +372,7 @@ - (void)launchGame:(const OPN::GameInfo &)game variantIndex:(int)variantIndex re streamVC.view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; OPNConfigureStreamWindow(self.window); self.window.contentViewController = streamVC; + OpnDisableFocusHighlights(streamVC.view); if (preserveFrame) { [self.window setFrame:preservedFrame display:YES animate:NO]; } @@ -435,6 +436,7 @@ - (void)installLibraryRootIfNeeded { [NSApp terminate:strongSelf]; }; self.window.contentView = self.rootView; + OpnDisableFocusHighlights(self.rootView); } if (!self.contentContainer || self.contentContainer.superview != self.rootView) { @@ -490,6 +492,7 @@ - (void)transitionToScreen:(OPN::AuthScreen)screen { }; [self.contentContainer addSubview:view]; + OpnDisableFocusHighlights(view); self.window.title = @"OpenNOW"; break; } @@ -559,6 +562,7 @@ - (void)transitionToScreen:(OPN::AuthScreen)screen { }; [self.contentContainer addSubview:store]; + OpnDisableFocusHighlights(store); self.window.title = @"OpenNOW - Store"; [self loadStorePanelsWithRetry:YES]; break; @@ -617,6 +621,7 @@ - (void)transitionToScreen:(OPN::AuthScreen)screen { }; [self.contentContainer addSubview:catalog]; + OpnDisableFocusHighlights(catalog); self.window.title = @"OpenNOW"; // Fetch user info if displayName not already set (OAuth flow) @@ -670,6 +675,7 @@ - (void)transitionToScreen:(OPN::AuthScreen)screen { settings.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; self.settingsView = settings; [self.contentContainer addSubview:settings]; + OpnDisableFocusHighlights(settings); self.window.title = @"OpenNOW - Settings"; break; } diff --git a/src/common/OPNUIHelpers.h b/src/common/OPNUIHelpers.h index 408eeedcc..dfc930ebc 100644 --- a/src/common/OPNUIHelpers.h +++ b/src/common/OPNUIHelpers.h @@ -28,3 +28,5 @@ NSButton *OpnButton(NSString *title, NSRect frame, NSColor *background, NSColor NSTextField *OpnTextField(NSRect frame, NSString *placeholder, bool isSecure = false); NSProgressIndicator *OpnSpinner(NSRect frame); + +void OpnDisableFocusHighlights(NSView *view); diff --git a/src/common/OPNUIHelpers.mm b/src/common/OPNUIHelpers.mm index e38751451..776de8bd1 100644 --- a/src/common/OPNUIHelpers.mm +++ b/src/common/OPNUIHelpers.mm @@ -142,6 +142,7 @@ static unsigned OpnResolvedInterfaceColor(unsigned rgb) { button.title = title; button.bezelStyle = NSBezelStyleRegularSquare; button.bordered = NO; + button.focusRingType = NSFocusRingTypeNone; button.font = [NSFont systemFontOfSize:14.0 weight:NSFontWeightSemibold]; button.contentTintColor = textColor; button.wantsLayer = YES; @@ -175,3 +176,11 @@ static unsigned OpnResolvedInterfaceColor(unsigned rgb) { spinner.displayedWhenStopped = NO; return spinner; } + +void OpnDisableFocusHighlights(NSView *view) { + if (!view) return; + view.focusRingType = NSFocusRingTypeNone; + for (NSView *subview in view.subviews) { + OpnDisableFocusHighlights(subview); + } +} diff --git a/src/views/OPNGameCardView.mm b/src/views/OPNGameCardView.mm index ebc5f9e04..ccf89ff86 100644 --- a/src/views/OPNGameCardView.mm +++ b/src/views/OPNGameCardView.mm @@ -232,11 +232,10 @@ - (void)setControllerFocused:(BOOL)controllerFocused { - (void)applyFocusStyle { BOOL selected = self.controllerFocused; self.playButton.hidden = !selected; - self.layer.borderColor = selected ? OpnColor(kBrandGreen, 0.86).CGColor : OpnColor(0xFFFFFF, 0.10).CGColor; - self.layer.borderWidth = selected ? 2.0 : 1.0; - self.layer.shadowColor = OpnColor(kBrandGreen).CGColor; - self.layer.shadowOpacity = selected ? 0.42 : 0.0; - self.layer.shadowRadius = selected ? 26.0 : 0.0; + self.layer.borderColor = OpnColor(0xFFFFFF, 0.10).CGColor; + self.layer.borderWidth = 1.0; + self.layer.shadowOpacity = 0.0; + self.layer.shadowRadius = 0.0; self.layer.shadowOffset = CGSizeZero; } From 80e7e1d27ede17fbe158372efda9d393ba65e887 Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Mon, 11 May 2026 21:00:49 -0500 Subject: [PATCH 06/18] Polish controller mode depth --- src/OPNAppDelegate.mm | 9 +- src/views/OPNBackdropView.mm | 242 +++++++++++++++++++++++++++++--- src/views/OPNGameCardView.mm | 50 +++++-- src/views/OPNGameCatalogView.mm | 94 +++++++++++-- src/views/OPNLoadingView.mm | 56 +++++--- 5 files changed, 389 insertions(+), 62 deletions(-) diff --git a/src/OPNAppDelegate.mm b/src/OPNAppDelegate.mm index 6d6ad3623..9c5dc68e8 100644 --- a/src/OPNAppDelegate.mm +++ b/src/OPNAppDelegate.mm @@ -104,11 +104,10 @@ static void OPNConfigureStreamWindow(NSWindow *window) { static NSString *OPNFormatHours(double hours) { if (!std::isfinite(hours) || hours < 0) hours = 0; - double rounded = hours >= 10.0 ? std::round(hours) : std::round(hours * 10.0) / 10.0; - if (std::fabs(rounded - std::round(rounded)) < 0.01) { - return [NSString stringWithFormat:@"%.0fh", rounded]; - } - return [NSString stringWithFormat:@"%.1fh", rounded]; + NSInteger totalMinutes = MAX(0, (NSInteger)llround(hours * 60.0)); + NSInteger wholeHours = totalMinutes / 60; + NSInteger minutes = totalMinutes % 60; + return [NSString stringWithFormat:@"%ldh %02ldm", (long)wholeHours, (long)minutes]; } static NSString *OPNFormatRemainingPlayTime(const OPN::SubscriptionInfo &subscription) { diff --git a/src/views/OPNBackdropView.mm b/src/views/OPNBackdropView.mm index 5b750a3ee..69cd6cb7d 100644 --- a/src/views/OPNBackdropView.mm +++ b/src/views/OPNBackdropView.mm @@ -4,6 +4,13 @@ #import "../common/OPNAuthTypes.h" #import +@interface OPNBackdropControllerMenuView : NSView +@end + +@implementation OPNBackdropControllerMenuView +- (BOOL)isFlipped { return YES; } +@end + @implementation OPNBackdropView { NSRect _storeNavFrame; NSRect _libraryNavFrame; @@ -13,6 +20,7 @@ @implementation OPNBackdropView { NSButton *_libraryButton; NSButton *_settingsButton; NSButton *_accountButton; + NSView *_controllerAccountMenuView; NSTimer *_controllerNavigationTimer; uint16_t _previousControllerButtons; } @@ -139,6 +147,7 @@ - (BOOL)isFlipped { return YES; } - (void)setMode:(OPNBackdropMode)mode { _mode = mode; + [self dismissControllerAccountMenu]; [self setNeedsDisplay:YES]; } @@ -169,10 +178,12 @@ - (void)setGameCountText:(NSString *)gameCountText { - (void)setAccountMenuItems:(NSArray *> *)accountMenuItems { _accountMenuItems = [accountMenuItems copy]; + [self dismissControllerAccountMenu]; } - (void)setCurrentAccountIdentifier:(NSString *)currentAccountIdentifier { _currentAccountIdentifier = [currentAccountIdentifier copy]; + [self dismissControllerAccountMenu]; } - (void)layout { @@ -181,9 +192,9 @@ - (void)layout { BOOL controllerMode = OpnControllerModeEnabled(); if (controllerMode && showNavigation) { _storeNavFrame = NSZeroRect; - _libraryNavFrame = NSMakeRect(28.0, 90.0, 86.0, 34.0); - _settingsNavFrame = NSMakeRect(124.0, 90.0, 90.0, 34.0); - _accountFrame = NSMakeRect(NSWidth(self.bounds) - 264.0, 10.0, 244.0, 44.0); + _libraryNavFrame = NSMakeRect(28.0, 78.0, 86.0, 34.0); + _settingsNavFrame = NSMakeRect(124.0, 78.0, 90.0, 34.0); + _accountFrame = NSMakeRect(NSWidth(self.bounds) - 304.0, 10.0, 284.0, 92.0); } BOOL showStore = showNavigation && !controllerMode; _storeButton.frame = showStore && !NSEqualRects(_storeNavFrame, NSZeroRect) ? _storeNavFrame : NSZeroRect; @@ -194,6 +205,10 @@ - (void)layout { _libraryButton.hidden = !showNavigation; _settingsButton.hidden = !showNavigation; _accountButton.hidden = !showNavigation; + if (_controllerAccountMenuView) { + CGFloat menuWidth = 320.0; + _controllerAccountMenuView.frame = NSMakeRect(MAX(20.0, NSWidth(self.bounds) - menuWidth - 20.0), 106.0, menuWidth, NSHeight(_controllerAccountMenuView.frame)); + } } - (void)drawRect:(NSRect)dirtyRect { @@ -231,6 +246,17 @@ - (void)drawRect:(NSRect)dirtyRect { [controllerMode ? OpnColor(kBrandGreen, 0.10) : OpnColor(kLinkBlue, 0.045) setFill]; [lowerGlow fill]; + if (controllerMode) { + NSGradient *depthGlow = [[NSGradient alloc] initWithStartingColor:OpnColor(kBrandGreen, 0.16) + endingColor:OpnColor(kBrandGreen, 0.0)]; + [depthGlow drawInBezierPath:[NSBezierPath bezierPathWithOvalInRect:NSMakeRect(-260.0, 210.0, 760.0, 560.0)] angle:35.0]; + [depthGlow drawInBezierPath:[NSBezierPath bezierPathWithOvalInRect:NSMakeRect(NSWidth(bounds) * 0.48, NSHeight(bounds) - 420.0, 980.0, 520.0)] angle:210.0]; + + NSBezierPath *horizon = [NSBezierPath bezierPathWithRoundedRect:NSMakeRect(24.0, 137.0, NSWidth(bounds) - 48.0, 1.0) xRadius:0.5 yRadius:0.5]; + [OpnColor(kBrandGreen, 0.18) setFill]; + [horizon fill]; + } + if (self.mode == OPNBackdropModeAuth) { return; } @@ -247,22 +273,39 @@ - (void)drawRect:(NSRect)dirtyRect { withAttributes:OpnTextStyle(16.0, OpnColor(kTextPrimary), NSFontWeightSemibold)]; } + if (controllerMode) { + NSDateFormatter *timeFormatter = [[NSDateFormatter alloc] init]; + timeFormatter.dateFormat = @"h:mm a"; + NSString *timeText = [[timeFormatter stringFromDate:NSDate.date] uppercaseString]; + NSBezierPath *timeGlow = [NSBezierPath bezierPathWithRoundedRect:NSMakeRect(20.0, 34.0, 128.0, 30.0) xRadius:15.0 yRadius:15.0]; + [OpnColor(kBrandGreen, 0.075) setFill]; + [timeGlow fill]; + [timeText drawInRect:NSMakeRect(32.0, 42.0, 112.0, 18.0) + withAttributes:OpnTextStyle(13.0, OpnColor(kTextSecondary), NSFontWeightSemibold)]; + } + NSArray *items = controllerMode ? @[@"Library", @"Settings"] : @[@"Store", @"Library", @"Settings"]; CGFloat widths[] = {86.0, 90.0, 78.0}; CGFloat navWidth = controllerMode ? widths[0] + widths[1] + 10.0 : widths[2] + widths[0] + widths[1] + 8.0; CGFloat x = controllerMode ? 28.0 : floor((NSWidth(bounds) - navWidth) / 2.0); _storeNavFrame = controllerMode ? NSZeroRect : _storeNavFrame; - NSRect segmentedRect = NSMakeRect(x - 8.0, controllerMode ? 86.0 : 15.0, navWidth + 16.0, controllerMode ? 42.0 : 34.0); + CGFloat navRowY = controllerMode ? 74.0 : 15.0; + NSRect segmentedRect = NSMakeRect(x - 8.0, navRowY, navWidth + 16.0, controllerMode ? 42.0 : 34.0); NSBezierPath *segmented = [NSBezierPath bezierPathWithRoundedRect:segmentedRect xRadius:controllerMode ? 21.0 : 10.0 yRadius:controllerMode ? 21.0 : 10.0]; - [controllerMode ? OpnColor(0xFFFFFF, 0.035) : OpnColor(0xFFFFFF, 0.055) setFill]; + [controllerMode ? OpnColor(0xFFFFFF, 0.055) : OpnColor(0xFFFFFF, 0.055) setFill]; [segmented fill]; + if (controllerMode) { + [OpnColor(kBrandGreen, 0.18) setStroke]; + segmented.lineWidth = 1.0; + [segmented stroke]; + } for (NSUInteger i = 0; i < items.count; i++) { NSString *item = items[i]; CGFloat itemWidth = [item isEqualToString:@"Store"] ? widths[2] : ([item isEqualToString:@"Library"] ? widths[0] : widths[1]); BOOL active = ([item isEqualToString:@"Store"] && self.mode == OPNBackdropModeStore) || ([item isEqualToString:@"Library"] && self.mode == OPNBackdropModeLibrary) || ([item isEqualToString:@"Settings"] && self.mode == OPNBackdropModeSettings); - NSRect itemRect = NSMakeRect(x, controllerMode ? 90.0 : 18.0, itemWidth, controllerMode ? 34.0 : 28.0); + NSRect itemRect = NSMakeRect(x, controllerMode ? 78.0 : 18.0, itemWidth, controllerMode ? 34.0 : 28.0); if ([item isEqualToString:@"Store"]) _storeNavFrame = itemRect; if ([item isEqualToString:@"Library"]) _libraryNavFrame = itemRect; if ([item isEqualToString:@"Settings"]) _settingsNavFrame = itemRect; @@ -270,6 +313,11 @@ - (void)drawRect:(NSRect)dirtyRect { NSBezierPath *pill = [NSBezierPath bezierPathWithRoundedRect:itemRect xRadius:controllerMode ? 17.0 : 8.0 yRadius:controllerMode ? 17.0 : 8.0]; [controllerMode ? OpnColor(kBrandGreen, 0.26) : OpnColor(0xFFFFFF, 0.14) setFill]; [pill fill]; + if (controllerMode) { + [OpnColor(kBrandGreen, 0.58) setStroke]; + pill.lineWidth = 1.0; + [pill stroke]; + } } NSColor *textColor = active ? OpnColor(kTextPrimary) : OpnColor(kTextMuted); NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; @@ -281,10 +329,17 @@ - (void)drawRect:(NSRect)dirtyRect { } NSString *remaining = self.remainingPlayTime.length > 0 ? self.remainingPlayTime : @"--"; - NSRect planRect = controllerMode ? NSMakeRect(28.0, 52.0, 108.0, 26.0) : NSMakeRect(NSWidth(bounds) - 294, 11.0, 108, 26); + CGFloat controllerStatsWidth = 292.0; + CGFloat controllerStatsX = MAX(NSMaxX(segmentedRect) + 18.0, NSWidth(bounds) - controllerStatsWidth - 28.0); + NSRect planRect = controllerMode ? NSMakeRect(controllerStatsX, 82.0, 132.0, 26.0) : NSMakeRect(NSWidth(bounds) - 294, 11.0, 108, 26); NSBezierPath *planPill = [NSBezierPath bezierPathWithRoundedRect:planRect xRadius:14 yRadius:14]; - [OpnColor(0xFFFFFF, 0.075) setFill]; + [controllerMode ? OpnColor(kBrandGreen, 0.10) : OpnColor(0xFFFFFF, 0.075) setFill]; [planPill fill]; + if (controllerMode) { + [OpnColor(kBrandGreen, 0.24) setStroke]; + planPill.lineWidth = 1.0; + [planPill stroke]; + } NSMutableParagraphStyle *remainingStyle = [[NSMutableParagraphStyle alloc] init]; remainingStyle.alignment = NSTextAlignmentCenter; NSMutableDictionary *remainingAttrs = [OpnTextStyle(12, OpnColor(kTextSecondary), NSFontWeightSemibold) mutableCopy]; @@ -294,13 +349,13 @@ - (void)drawRect:(NSRect)dirtyRect { NSString *gameCount = self.gameCountText.length > 0 ? self.gameCountText : @""; NSMutableParagraphStyle *gameCountStyle = [[NSMutableParagraphStyle alloc] init]; - gameCountStyle.alignment = NSTextAlignmentCenter; + gameCountStyle.alignment = controllerMode ? NSTextAlignmentRight : NSTextAlignmentCenter; NSMutableDictionary *gameCountAttrs = [OpnTextStyle(10, OpnColor(kTextMuted), NSFontWeightMedium) mutableCopy]; gameCountAttrs[NSParagraphStyleAttributeName] = gameCountStyle; - [gameCount drawInRect:controllerMode ? NSMakeRect(NSMaxX(planRect) + 12.0, 58.0, 120.0, 14.0) : NSMakeRect(NSMinX(planRect), 40.0, NSWidth(planRect), 14) + [gameCount drawInRect:controllerMode ? NSMakeRect(NSMaxX(planRect) + 14.0, 88.0, 146.0, 14.0) : NSMakeRect(NSMinX(planRect), 40.0, NSWidth(planRect), 14) withAttributes:gameCountAttrs]; - NSRect avatarRect = controllerMode ? NSMakeRect(NSWidth(bounds) - 252.0, 18.0, 30.0, 30.0) : NSMakeRect(NSWidth(bounds) - 164, 17.0, 30, 30); + NSRect avatarRect = controllerMode ? NSMakeRect(NSWidth(bounds) - 292.0, 18.0, 30.0, 30.0) : NSMakeRect(NSWidth(bounds) - 164, 17.0, 30, 30); NSBezierPath *avatar = [NSBezierPath bezierPathWithOvalInRect:avatarRect]; NSString *name = self.accountName.length > 0 ? self.accountName : @"User"; @@ -323,13 +378,29 @@ - (void)drawRect:(NSRect)dirtyRect { [initial drawInRect:NSMakeRect(NSMinX(avatarRect), NSMinY(avatarRect) + 7, 30, 16) withAttributes:avatarAttrs]; } - [name drawInRect:controllerMode ? NSMakeRect(NSWidth(bounds) - 212.0, 17.0, 160.0, 17.0) : NSMakeRect(NSWidth(bounds) - 124, 16.0, 72, 17) - withAttributes:OpnTextStyle(12, OpnColor(kTextPrimary), NSFontWeightSemibold)]; + if (controllerMode) { + NSMutableParagraphStyle *accountTextStyle = [[NSMutableParagraphStyle alloc] init]; + accountTextStyle.alignment = NSTextAlignmentCenter; + NSMutableDictionary *nameAttrs = [OpnTextStyle(12, OpnColor(kTextPrimary), NSFontWeightSemibold) mutableCopy]; + nameAttrs[NSParagraphStyleAttributeName] = accountTextStyle; + [name drawInRect:NSMakeRect(NSWidth(bounds) - 252.0, 17.0, 200.0, 17.0) withAttributes:nameAttrs]; + } else { + [name drawInRect:NSMakeRect(NSWidth(bounds) - 124, 16.0, 72, 17) + withAttributes:OpnTextStyle(12, OpnColor(kTextPrimary), NSFontWeightSemibold)]; + } NSString *status = self.accountStatus.length > 0 ? self.accountStatus : @"Signed in"; - [status drawInRect:controllerMode ? NSMakeRect(NSWidth(bounds) - 212.0, 33.0, 160.0, 14.0) : NSMakeRect(NSWidth(bounds) - 124, 32.0, 72, 14) - withAttributes:OpnTextStyle(10, OpnColor(kTextMuted), NSFontWeightRegular)]; + if (controllerMode) { + NSMutableParagraphStyle *statusTextStyle = [[NSMutableParagraphStyle alloc] init]; + statusTextStyle.alignment = NSTextAlignmentCenter; + NSMutableDictionary *statusAttrs = [OpnTextStyle(10, OpnColor(kTextMuted), NSFontWeightRegular) mutableCopy]; + statusAttrs[NSParagraphStyleAttributeName] = statusTextStyle; + [status drawInRect:NSMakeRect(NSWidth(bounds) - 252.0, 33.0, 200.0, 14.0) withAttributes:statusAttrs]; + } else { + [status drawInRect:NSMakeRect(NSWidth(bounds) - 124, 32.0, 72, 14) + withAttributes:OpnTextStyle(10, OpnColor(kTextMuted), NSFontWeightRegular)]; + } - _accountFrame = controllerMode ? NSMakeRect(NSWidth(bounds) - 264.0, 10.0, 244.0, 44.0) : NSMakeRect(NSWidth(bounds) - 174, 9.0, 154, 48); + _accountFrame = controllerMode ? NSMakeRect(NSWidth(bounds) - 304.0, 10.0, 284.0, 92.0) : NSMakeRect(NSWidth(bounds) - 174, 9.0, 154, 48); NSBezierPath *chevron = [NSBezierPath bezierPath]; CGFloat chevronX = controllerMode ? NSWidth(bounds) - 36.0 : NSWidth(bounds) - 36.0; CGFloat chevronY = controllerMode ? 31.0 : 28.0; @@ -356,8 +427,119 @@ - (void)settingsButtonPressed:(id)sender { if (self.onSettingsSelected) self.onSettingsSelected(); } +- (void)dismissControllerAccountMenu { + [_controllerAccountMenuView removeFromSuperview]; + _controllerAccountMenuView = nil; +} + +- (NSButton *)controllerAccountMenuButtonWithTitle:(NSString *)title + y:(CGFloat)y + height:(CGFloat)height + action:(SEL)action + identifier:(NSString *)identifier + selected:(BOOL)selected + warning:(BOOL)warning { + NSButton *button = [[NSButton alloc] initWithFrame:NSMakeRect(14.0, y, 292.0, height)]; + button.bordered = NO; + button.target = self; + button.action = action; + button.identifier = identifier ?: @""; + button.wantsLayer = YES; + button.layer.cornerRadius = 14.0; + button.layer.backgroundColor = selected ? OpnColor(OPN::kBrandGreen, 0.22).CGColor : OpnColor(0xFFFFFF, 0.045).CGColor; + button.layer.borderWidth = selected ? 1.0 : 0.0; + button.layer.borderColor = OpnColor(OPN::kBrandGreen, 0.50).CGColor; + NSColor *textColor = warning ? OpnColor(0xFF8A8A) : (selected ? OpnColor(OPN::kTextPrimary) : OpnColor(OPN::kTextSecondary)); + NSString *displayTitle = selected ? [NSString stringWithFormat:@"%@ Current", title] : title; + NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; + style.alignment = NSTextAlignmentLeft; + button.attributedTitle = [[NSAttributedString alloc] initWithString:displayTitle attributes:@{ + NSFontAttributeName: [NSFont systemFontOfSize:14.0 weight:selected ? NSFontWeightSemibold : NSFontWeightMedium], + NSForegroundColorAttributeName: textColor, + NSParagraphStyleAttributeName: style, + }]; + return button; +} + +- (void)showControllerAccountMenu { + if (_controllerAccountMenuView) { + [self dismissControllerAccountMenu]; + return; + } + + CGFloat menuWidth = 320.0; + CGFloat rowHeight = 42.0; + CGFloat y = 50.0; + NSInteger accountCount = 0; + for (NSDictionary *account in self.accountMenuItems) { + NSString *identifier = account[@"identifier"]; + NSString *title = account[@"label"]; + if (identifier.length == 0 || title.length == 0) continue; + accountCount++; + y += rowHeight + 8.0; + } + CGFloat menuHeight = y + 170.0; + CGFloat menuX = MAX(20.0, NSWidth(self.bounds) - menuWidth - 20.0); + + NSView *menu = [[OPNBackdropControllerMenuView alloc] initWithFrame:NSMakeRect(menuX, 106.0, menuWidth, menuHeight)]; + menu.wantsLayer = YES; + menu.layer.cornerRadius = 24.0; + menu.layer.borderWidth = 1.0; + menu.layer.borderColor = OpnColor(OPN::kBrandGreen, 0.28).CGColor; + menu.layer.backgroundColor = OpnColor(0x080A10, 0.94).CGColor; + menu.layer.shadowColor = OpnColor(OPN::kBrandGreen).CGColor; + menu.layer.shadowOpacity = 0.24; + menu.layer.shadowRadius = 30.0; + menu.layer.shadowOffset = CGSizeZero; + + NSTextField *titleLabel = OpnLabel(@"Account", NSMakeRect(18.0, 18.0, menuWidth - 36.0, 22.0), 15.0, OpnColor(OPN::kTextPrimary), NSFontWeightSemibold); + [menu addSubview:titleLabel]; + + y = 52.0; + if (accountCount == 0) { + NSTextField *emptyLabel = OpnLabel(@"No saved accounts", NSMakeRect(18.0, y, menuWidth - 36.0, 22.0), 13.0, OpnColor(OPN::kTextMuted), NSFontWeightMedium); + [menu addSubview:emptyLabel]; + y += 34.0; + } else { + for (NSDictionary *account in self.accountMenuItems) { + NSString *identifier = account[@"identifier"]; + NSString *title = account[@"label"]; + if (identifier.length == 0 || title.length == 0) continue; + BOOL selected = [identifier isEqualToString:self.currentAccountIdentifier]; + NSButton *button = [self controllerAccountMenuButtonWithTitle:title + y:y + height:rowHeight + action:@selector(controllerAccountMenuItemPressed:) + identifier:identifier + selected:selected + warning:NO]; + [menu addSubview:button]; + y += rowHeight + 8.0; + } + } + + NSView *divider = [[NSView alloc] initWithFrame:NSMakeRect(18.0, y + 8.0, menuWidth - 36.0, 1.0)]; + divider.wantsLayer = YES; + divider.layer.backgroundColor = OpnColor(0xFFFFFF, 0.10).CGColor; + [menu addSubview:divider]; + y += 24.0; + + [menu addSubview:[self controllerAccountMenuButtonWithTitle:@"Add Account" y:y height:rowHeight action:@selector(controllerAddAccountPressed:) identifier:nil selected:NO warning:NO]]; + y += rowHeight + 8.0; + [menu addSubview:[self controllerAccountMenuButtonWithTitle:@"Sign Out" y:y height:rowHeight action:@selector(controllerSignOutPressed:) identifier:nil selected:NO warning:NO]]; + y += rowHeight + 8.0; + [menu addSubview:[self controllerAccountMenuButtonWithTitle:@"Exit OpenNOW" y:y height:rowHeight action:@selector(controllerExitPressed:) identifier:nil selected:NO warning:YES]]; + + _controllerAccountMenuView = menu; + [self addSubview:menu positioned:NSWindowAbove relativeTo:nil]; +} + - (void)accountButtonPressed:(id)sender { (void)sender; + if (OpnControllerModeEnabled()) { + [self showControllerAccountMenu]; + return; + } NSMenu *menu = [[NSMenu alloc] initWithTitle:@"Account"]; menu.appearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]; menu.autoenablesItems = NO; @@ -406,6 +588,30 @@ - (void)accountButtonPressed:(id)sender { inView:_accountButton]; } +- (void)controllerAccountMenuItemPressed:(NSButton *)sender { + NSString *identifier = sender.identifier; + [self dismissControllerAccountMenu]; + if (identifier.length > 0 && self.onAccountSelected) self.onAccountSelected(identifier); +} + +- (void)controllerAddAccountPressed:(id)sender { + (void)sender; + [self dismissControllerAccountMenu]; + if (self.onAddAccountSelected) self.onAddAccountSelected(); +} + +- (void)controllerSignOutPressed:(id)sender { + (void)sender; + [self dismissControllerAccountMenu]; + if (self.onSignOutSelected) self.onSignOutSelected(); +} + +- (void)controllerExitPressed:(id)sender { + (void)sender; + [self dismissControllerAccountMenu]; + if (self.onExitSelected) self.onExitSelected(); +} + - (void)accountMenuItemPressed:(NSMenuItem *)sender { NSString *identifier = [sender.representedObject isKindOfClass:NSString.class] ? sender.representedObject : nil; if (identifier.length > 0 && self.onAccountSelected) self.onAccountSelected(identifier); @@ -428,6 +634,10 @@ - (void)exitMenuItemPressed:(id)sender { - (void)mouseDown:(NSEvent *)event { NSPoint point = [self convertPoint:event.locationInWindow fromView:nil]; + if (_controllerAccountMenuView && !NSPointInRect(point, _controllerAccountMenuView.frame) && !NSPointInRect(point, _accountFrame)) { + [self dismissControllerAccountMenu]; + return; + } if (!OpnControllerModeEnabled() && NSPointInRect(point, _storeNavFrame)) { if (self.onStoreSelected) self.onStoreSelected(); return; diff --git a/src/views/OPNGameCardView.mm b/src/views/OPNGameCardView.mm index ccf89ff86..4ec7ae9a9 100644 --- a/src/views/OPNGameCardView.mm +++ b/src/views/OPNGameCardView.mm @@ -124,6 +124,7 @@ static BOOL OPNIsNumericString(const std::string &value) { @interface OPNGameCardView () @property (nonatomic, assign) OPN::GameInfo gameData; +@property (nonatomic, strong) NSView *contentView; @property (nonatomic, strong) NSImageView *imageView; @property (nonatomic, strong) NSView *gradientOverlay; @property (nonatomic, strong) NSView *storeChipsContainer; @@ -150,16 +151,27 @@ - (instancetype)initWithFrame:(NSRect)frame game:(const OPN::GameInfo &)game { _gameData = game; self.wantsLayer = YES; self.layer.cornerRadius = 18.0; - self.layer.masksToBounds = YES; - self.layer.backgroundColor = OpnColor(kSurfaceRaised, 0.82).CGColor; + self.layer.masksToBounds = NO; + self.layer.backgroundColor = NSColor.clearColor.CGColor; self.layer.borderWidth = 1.0; self.layer.borderColor = OpnColor(0xFFFFFF, 0.10).CGColor; + self.layer.shadowColor = NSColor.blackColor.CGColor; + self.layer.shadowOpacity = 0.34; + self.layer.shadowRadius = 18.0; + self.layer.shadowOffset = CGSizeMake(0.0, 14.0); + + _contentView = [[NSView alloc] initWithFrame:self.bounds]; + _contentView.wantsLayer = YES; + _contentView.layer.cornerRadius = 18.0; + _contentView.layer.masksToBounds = YES; + _contentView.layer.backgroundColor = OpnColor(kSurfaceRaised, 0.82).CGColor; + [self addSubview:_contentView]; _imageView = [[NSImageView alloc] initWithFrame:self.bounds]; _imageView.imageScaling = NSImageScaleProportionallyUpOrDown; _imageView.wantsLayer = YES; _imageView.layer.backgroundColor = OpnColor(kBackgroundC).CGColor; - [self addSubview:_imageView]; + [_contentView addSubview:_imageView]; _gradientOverlay = [[NSView alloc] initWithFrame:NSMakeRect(0, NSHeight(self.bounds) - gGradientOverlayHeight, NSWidth(self.bounds), gGradientOverlayHeight)]; _gradientOverlay.wantsLayer = YES; @@ -173,7 +185,7 @@ - (instancetype)initWithFrame:(NSRect)frame game:(const OPN::GameInfo &)game { gradient.startPoint = CGPointMake(0.5, 1.0); gradient.endPoint = CGPointMake(0.5, 0.0); _gradientOverlay.layer = gradient; - [self addSubview:_gradientOverlay]; + [_contentView addSubview:_gradientOverlay]; _playButton = [[NSButton alloc] initWithFrame: NSMakeRect((NSWidth(self.bounds) - 46) / 2, (NSHeight(self.bounds) - 46) / 2, 46, 46)]; @@ -210,7 +222,7 @@ - (instancetype)initWithFrame:(NSRect)frame game:(const OPN::GameInfo &)game { _storeChipsContainer = [[NSView alloc] initWithFrame: NSMakeRect(16, NSHeight(self.bounds) - 37, NSWidth(self.bounds) - 32, 24)]; - [self addSubview:_storeChipsContainer]; + [_contentView addSubview:_storeChipsContainer]; [self buildStoreChips]; [self loadImage]; @@ -232,11 +244,26 @@ - (void)setControllerFocused:(BOOL)controllerFocused { - (void)applyFocusStyle { BOOL selected = self.controllerFocused; self.playButton.hidden = !selected; - self.layer.borderColor = OpnColor(0xFFFFFF, 0.10).CGColor; - self.layer.borderWidth = 1.0; - self.layer.shadowOpacity = 0.0; - self.layer.shadowRadius = 0.0; - self.layer.shadowOffset = CGSizeZero; + [CATransaction begin]; + [CATransaction setAnimationDuration:0.22]; + [CATransaction setAnimationTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]]; + self.layer.zPosition = selected ? 20.0 : 0.0; + self.layer.borderColor = selected ? OpnColor(kBrandGreen, 0.92).CGColor : OpnColor(0xFFFFFF, 0.10).CGColor; + self.layer.borderWidth = selected ? 2.0 : 1.0; + self.layer.shadowColor = OpnColor(kBrandGreen).CGColor; + self.layer.shadowOpacity = selected ? 0.62 : 0.30; + self.layer.shadowRadius = selected ? 44.0 : 18.0; + self.layer.shadowOffset = selected ? CGSizeMake(0.0, 22.0) : CGSizeMake(0.0, 12.0); + CATransform3D transform = CATransform3DIdentity; + transform.m34 = -1.0 / 900.0; + if (selected) { + transform = CATransform3DScale(transform, 1.075, 1.075, 1.0); + transform = CATransform3DRotate(transform, -0.035, 1.0, 0.0, 0.0); + } + self.layer.transform = transform; + self.playButton.layer.shadowOpacity = selected ? 0.72 : 0.22; + self.playButton.layer.shadowRadius = selected ? 20.0 : 12.0; + [CATransaction commit]; } - (BOOL)isFlipped { return YES; } @@ -245,10 +272,13 @@ - (void)layout { [super layout]; CGFloat width = NSWidth(self.bounds); CGFloat height = NSHeight(self.bounds); + self.contentView.frame = self.bounds; + self.contentView.layer.cornerRadius = 18.0; self.imageView.frame = self.bounds; self.gradientOverlay.frame = NSMakeRect(0, MAX(0.0, height - gGradientOverlayHeight), width, MIN(gGradientOverlayHeight, height)); self.playButton.frame = NSMakeRect((width - 46.0) / 2.0, (height - 46.0) / 2.0, 46.0, 46.0); self.storeChipsContainer.frame = NSMakeRect(16.0, MAX(0.0, height - 37.0), MAX(40.0, width - 32.0), 24.0); + self.layer.shadowPath = [NSBezierPath bezierPathWithRoundedRect:self.bounds xRadius:18.0 yRadius:18.0].CGPath; } - (void)playClicked { diff --git a/src/views/OPNGameCatalogView.mm b/src/views/OPNGameCatalogView.mm index 5abdf1169..6a008ed65 100644 --- a/src/views/OPNGameCatalogView.mm +++ b/src/views/OPNGameCatalogView.mm @@ -87,8 +87,11 @@ @interface OPNGameCatalogView () @property (nonatomic, strong) NSTextField *controllerDetailTitleLabel; @property (nonatomic, strong) NSTextField *controllerDetailMetaLabel; @property (nonatomic, strong) NSTextField *controllerDetailStoreLabel; +@property (nonatomic, strong) NSTextField *controllerDetailStatsLabel; @property (nonatomic, strong) NSTextField *controllerDetailFeaturesLabel; @property (nonatomic, strong) NSTextField *controllerDetailHintLabel; +@property (nonatomic, strong) CAGradientLayer *controllerDetailGradientLayer; +@property (nonatomic, strong) CALayer *controllerDetailAccentLayer; @property (nonatomic, strong) NSMutableArray *cardViews; @property (nonatomic, assign) std::vector allGames; @property (nonatomic, assign) CGFloat lastLayoutWidth; @@ -278,8 +281,30 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailView.wantsLayer = YES; _controllerDetailView.layer.cornerRadius = 26.0; _controllerDetailView.layer.borderWidth = 1.0; - _controllerDetailView.layer.borderColor = OpnColor(kBrandGreen, 0.20).CGColor; - _controllerDetailView.layer.backgroundColor = OpnColor(0x07090F, 0.32).CGColor; + _controllerDetailView.layer.borderColor = OpnColor(kBrandGreen, 0.30).CGColor; + _controllerDetailView.layer.backgroundColor = OpnColor(0x07090F, 0.46).CGColor; + _controllerDetailView.layer.shadowColor = OpnColor(kBrandGreen).CGColor; + _controllerDetailView.layer.shadowOpacity = 0.34; + _controllerDetailView.layer.shadowRadius = 48.0; + _controllerDetailView.layer.shadowOffset = CGSizeMake(0.0, 24.0); + CATransform3D detailTransform = CATransform3DIdentity; + detailTransform.m34 = -1.0 / 1200.0; + detailTransform = CATransform3DRotate(detailTransform, 0.012, 1.0, 0.0, 0.0); + _controllerDetailView.layer.transform = detailTransform; + + _controllerDetailGradientLayer = [CAGradientLayer layer]; + _controllerDetailGradientLayer.colors = @[(id)OpnColor(kBrandGreen, 0.24).CGColor, + (id)OpnColor(0xFFFFFF, 0.070).CGColor, + (id)OpnColor(kBlack, 0.0).CGColor]; + _controllerDetailGradientLayer.locations = @[@0.0, @0.44, @1.0]; + _controllerDetailGradientLayer.startPoint = CGPointMake(0.0, 0.0); + _controllerDetailGradientLayer.endPoint = CGPointMake(1.0, 1.0); + [_controllerDetailView.layer addSublayer:_controllerDetailGradientLayer]; + + _controllerDetailAccentLayer = [CALayer layer]; + _controllerDetailAccentLayer.backgroundColor = OpnColor(kBrandGreen, 0.74).CGColor; + _controllerDetailAccentLayer.cornerRadius = 2.0; + [_controllerDetailView.layer addSublayer:_controllerDetailAccentLayer]; [self addSubview:_controllerDetailView]; _controllerDetailTitleLabel = OpnLabel(@"Select a game", NSZeroRect, 42.0, OpnColor(kTextPrimary), NSFontWeightSemibold); @@ -292,8 +317,11 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailStoreLabel = OpnLabel(@"", NSZeroRect, 16.0, OpnColor(kBrandGreen), NSFontWeightSemibold); [_controllerDetailView addSubview:_controllerDetailStoreLabel]; + _controllerDetailStatsLabel = OpnLabel(@"", NSZeroRect, 14.0, OpnColor(kTextSecondary), NSFontWeightMedium); + [_controllerDetailView addSubview:_controllerDetailStatsLabel]; + _controllerDetailFeaturesLabel = OpnLabel(@"", NSZeroRect, 14.0, OpnColor(kTextMuted), NSFontWeightRegular); - _controllerDetailFeaturesLabel.maximumNumberOfLines = 2; + _controllerDetailFeaturesLabel.maximumNumberOfLines = 4; [_controllerDetailView addSubview:_controllerDetailFeaturesLabel]; _controllerDetailHintLabel = OpnLabel(@"✕ Play △ Change Store L1/R1 Menu Options Account", NSZeroRect, 13.0, OpnColor(kTextMuted), NSFontWeightMedium); @@ -480,6 +508,7 @@ - (void)renderGrid { if (self.focusedCardIndex < 0 && self.cardViews.count > 0) self.focusedCardIndex = 0; [self focusCardAtIndex:self.focusedCardIndex scrollIntoView:NO]; [self updateControllerDetailContent]; + [self layoutCatalogSubviews]; } - (void)scrollLibraryToTop { @@ -616,18 +645,38 @@ - (void)layoutCatalogSubviews { self.gameCountLabel.hidden = YES; self.signOutButton.frame = NSMakeRect(width - 116, kNavHeight + 13, 92, 30); CGFloat cardHeight = [OPNGameCardView cardSize].height; - CGFloat carouselHeight = cardHeight + 86.0; - CGFloat gridY = controllerMode ? MAX(kNavHeight + 116.0, height - carouselHeight - 88.0) : kNavHeight + (compact ? 116.0 : kToolbarHeight); - CGFloat detailY = kNavHeight + 24.0; - CGFloat detailHeight = controllerMode ? MAX(150.0, gridY - detailY - 58.0) : 0.0; + CGFloat minimumDetailHeight = 176.0; + CGFloat desiredCarouselHeight = cardHeight + 86.0; + CGFloat controllerNavHeight = 136.0; + CGFloat detailY = (controllerMode ? controllerNavHeight : kNavHeight) + 22.0; + CGFloat bottomInset = 56.0; + CGFloat detailGap = 24.0; + CGFloat availableForControllerContent = MAX(0.0, height - detailY - bottomInset); + CGFloat carouselHeight = desiredCarouselHeight; + CGFloat detailHeight = 0.0; + CGFloat gridY = kNavHeight + (compact ? 116.0 : kToolbarHeight); + if (controllerMode) { + carouselHeight = MIN(desiredCarouselHeight, MAX(168.0, availableForControllerContent - minimumDetailHeight - detailGap)); + CGFloat naturalDetailHeight = availableForControllerContent - carouselHeight - detailGap; + detailHeight = MIN(440.0, MAX(190.0, naturalDetailHeight)); + gridY = MAX(detailY + detailHeight + detailGap, height - carouselHeight - bottomInset); + } self.controllerDetailView.hidden = !controllerMode || self.cardViews.count == 0; self.controllerDetailView.frame = NSMakeRect(28.0, detailY, MAX(260.0, width - 56.0), detailHeight); + self.controllerDetailView.layer.shadowPath = [NSBezierPath bezierPathWithRoundedRect:self.controllerDetailView.bounds xRadius:26.0 yRadius:26.0].CGPath; CGFloat detailWidth = NSWidth(self.controllerDetailView.frame); - self.controllerDetailTitleLabel.frame = NSMakeRect(28.0, 24.0, MAX(220.0, detailWidth - 56.0), 52.0); - self.controllerDetailMetaLabel.frame = NSMakeRect(30.0, 84.0, MAX(220.0, detailWidth - 60.0), 22.0); - self.controllerDetailStoreLabel.frame = NSMakeRect(30.0, 118.0, MAX(220.0, detailWidth - 60.0), 24.0); - self.controllerDetailFeaturesLabel.frame = NSMakeRect(30.0, 154.0, MAX(220.0, detailWidth - 60.0), 46.0); - self.controllerDetailHintLabel.frame = NSMakeRect(30.0, MAX(116.0, detailHeight - 34.0), MAX(220.0, detailWidth - 60.0), 18.0); + self.controllerDetailGradientLayer.frame = self.controllerDetailView.bounds; + self.controllerDetailAccentLayer.frame = NSMakeRect(32.0, 24.0, 78.0, 4.0); + BOOL compactDetail = detailHeight < 210.0; + self.controllerDetailTitleLabel.font = [NSFont systemFontOfSize:compactDetail ? 30.0 : 42.0 weight:NSFontWeightSemibold]; + self.controllerDetailTitleLabel.frame = NSMakeRect(32.0, compactDetail ? 18.0 : 28.0, MAX(220.0, detailWidth - 64.0), compactDetail ? 38.0 : 52.0); + self.controllerDetailMetaLabel.frame = NSMakeRect(34.0, compactDetail ? 64.0 : 88.0, MAX(220.0, detailWidth - 68.0), 22.0); + self.controllerDetailStoreLabel.frame = NSMakeRect(34.0, compactDetail ? 94.0 : 122.0, MAX(220.0, detailWidth - 68.0), 24.0); + self.controllerDetailStatsLabel.frame = NSMakeRect(34.0, compactDetail ? 122.0 : 156.0, MAX(220.0, detailWidth - 68.0), 22.0); + CGFloat featuresY = compactDetail ? 0.0 : 192.0; + self.controllerDetailFeaturesLabel.hidden = compactDetail; + self.controllerDetailFeaturesLabel.frame = NSMakeRect(34.0, featuresY, MAX(220.0, detailWidth - 68.0), MAX(0.0, detailHeight - featuresY - 58.0)); + self.controllerDetailHintLabel.frame = NSMakeRect(34.0, MAX(146.0, detailHeight - 34.0), MAX(220.0, detailWidth - 68.0), 18.0); self.scrollView.frame = controllerMode ? NSMakeRect(0, gridY, width, MIN(carouselHeight, MAX(0.0, height - gridY))) : NSMakeRect(0, gridY, width, MAX(0.0, height - gridY)); @@ -708,10 +757,16 @@ - (void)cycleFocusedVariant { - (void)updateControllerDetailContent { if (!OpnControllerModeEnabled()) return; OPNGameCardView *card = [self focusedCard]; + CATransition *fade = [CATransition animation]; + fade.type = kCATransitionFade; + fade.duration = 0.18; + fade.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + [self.controllerDetailView.layer addAnimation:fade forKey:@"opn.detail.fade"]; if (!card) { self.controllerDetailTitleLabel.stringValue = @"Select a game"; self.controllerDetailMetaLabel.stringValue = @""; self.controllerDetailStoreLabel.stringValue = @""; + self.controllerDetailStatsLabel.stringValue = @""; self.controllerDetailFeaturesLabel.stringValue = @""; return; } @@ -736,9 +791,20 @@ - (void)updateControllerDetailContent { NSString *storePrefix = game.variants.size() > 1 ? @"Selected store" : @"Store"; self.controllerDetailStoreLabel.stringValue = [NSString stringWithFormat:@"%@: %@", storePrefix, store]; + NSMutableArray *stats = [NSMutableArray array]; + [stats addObject:game.isInLibrary ? @"In Library" : @"Catalog"]; + if (!game.playType.empty()) [stats addObject:OPNCatalogString(game.playType, @"").capitalizedString]; + if (!game.availableStores.empty()) { + [stats addObject:[NSString stringWithFormat:@"%lu %@", (unsigned long)game.availableStores.size(), game.availableStores.size() == 1 ? @"store" : @"stores"]]; + } + if (!game.variants.empty()) { + [stats addObject:[NSString stringWithFormat:@"%lu %@", (unsigned long)game.variants.size(), game.variants.size() == 1 ? @"launch option" : @"launch options"]]; + } + self.controllerDetailStatsLabel.stringValue = [stats componentsJoinedByString:@" • "]; + NSString *features = OPNCatalogJoinedStrings(game.featureLabels, @""); - if (features.length == 0 && !game.shortName.empty()) features = OPNCatalogString(game.shortName, @""); - self.controllerDetailFeaturesLabel.stringValue = features.length > 0 ? features : @"Press Cross / A to launch this game."; + if (features.length == 0 && !game.shortName.empty()) features = [NSString stringWithFormat:@"%@ is ready to launch from the carousel.", OPNCatalogString(game.shortName, @"This game")]; + self.controllerDetailFeaturesLabel.stringValue = features.length > 0 ? features : @"Ready to stream. Press Cross / A to launch this game, or use Triangle / Y when multiple stores are available."; self.controllerDetailHintLabel.stringValue = game.variants.size() > 1 ? @"✕ Play △ Change Store L1/R1 Menu Options Account" : @"✕ Play L1/R1 Menu Options Account"; diff --git a/src/views/OPNLoadingView.mm b/src/views/OPNLoadingView.mm index 03bdcf588..a0ed0d540 100644 --- a/src/views/OPNLoadingView.mm +++ b/src/views/OPNLoadingView.mm @@ -31,6 +31,7 @@ @interface OPNLoadingView () @property (nonatomic, assign) BOOL adVisible; @property (nonatomic, assign) BOOL adStartReported; @property (nonatomic, assign) BOOL adFinishReported; +- (void)applyAccentColors; @end @implementation OPNLoadingView @@ -61,10 +62,6 @@ - (instancetype)initWithFrame:(NSRect)frame message:(NSString *)message { [self.layer addSublayer:_panelLayer]; _sweepLayer = [CAGradientLayer layer]; - _sweepLayer.colors = @[(id)OpnColor(0x6EB6FF, 0.0).CGColor, - (id)OpnColor(0x6EB6FF, 0.28).CGColor, - (id)OpnColor(0xE8F4FF, 0.42).CGColor, - (id)OpnColor(0x6EB6FF, 0.0).CGColor]; _sweepLayer.locations = @[@0.0, @0.42, @0.50, @1.0]; _sweepLayer.startPoint = CGPointMake(0.0, 0.5); _sweepLayer.endPoint = CGPointMake(1.0, 0.5); @@ -72,7 +69,6 @@ - (instancetype)initWithFrame:(NSRect)frame message:(NSString *)message { _orbitLayer = [CAShapeLayer layer]; _orbitLayer.fillColor = NSColor.clearColor.CGColor; - _orbitLayer.strokeColor = OpnColor(0x8EC8FF, 0.78).CGColor; _orbitLayer.lineWidth = 2.0; _orbitLayer.lineCap = kCALineCapRound; _orbitLayer.strokeStart = 0.04; @@ -87,16 +83,12 @@ - (instancetype)initWithFrame:(NSRect)frame message:(NSString *)message { [self.layer addSublayer:_innerOrbitLayer]; _coreLayer = [CALayer layer]; - _coreLayer.backgroundColor = OpnColor(0xDDF0FF, 0.92).CGColor; - _coreLayer.shadowColor = OpnColor(0x69B7FF, 1.0).CGColor; _coreLayer.shadowOpacity = 0.86; _coreLayer.shadowRadius = 14.0; _coreLayer.shadowOffset = CGSizeZero; [self.layer addSublayer:_coreLayer]; _sparkLayer = [CALayer layer]; - _sparkLayer.backgroundColor = OpnColor(0x8EC8FF, 0.92).CGColor; - _sparkLayer.shadowColor = OpnColor(0x8EC8FF, 1.0).CGColor; _sparkLayer.shadowOpacity = 0.9; _sparkLayer.shadowRadius = 10.0; _sparkLayer.shadowOffset = CGSizeZero; @@ -104,7 +96,6 @@ - (instancetype)initWithFrame:(NSRect)frame message:(NSString *)message { for (NSUInteger i = 0; i < 4; i++) { CALayer *bar = [CALayer layer]; - bar.backgroundColor = OpnColor(0x77BAFF, 0.54).CGColor; bar.cornerRadius = 2.0; [self.layer addSublayer:bar]; [_barLayers addObject:bar]; @@ -112,9 +103,7 @@ - (instancetype)initWithFrame:(NSRect)frame message:(NSString *)message { for (NSUInteger i = 0; i < 5; i++) { CALayer *dot = [CALayer layer]; - dot.backgroundColor = OpnColor(0xCFEAFF, 0.74).CGColor; dot.cornerRadius = 2.5; - dot.shadowColor = OpnColor(0x8EC8FF, 1.0).CGColor; dot.shadowOpacity = 0.36; dot.shadowRadius = 5.0; dot.shadowOffset = CGSizeZero; @@ -126,13 +115,13 @@ - (instancetype)initWithFrame:(NSRect)frame message:(NSString *)message { _messageLabel.maximumNumberOfLines = 2; [self addSubview:_messageLabel]; - _queuePositionLabel = OpnLabel(@"", NSZeroRect, 13.0, OpnColor(0x9FD3FF), NSFontWeightSemibold, NSTextAlignmentCenter); + _queuePositionLabel = OpnLabel(@"", NSZeroRect, 13.0, OpnColor(OPN::kBrandGreen), NSFontWeightSemibold, NSTextAlignmentCenter); _queuePositionLabel.hidden = YES; _queuePositionLabel.wantsLayer = YES; _queuePositionLabel.layer.backgroundColor = OpnColor(0x0A1624, 0.72).CGColor; _queuePositionLabel.layer.cornerRadius = 12.0; _queuePositionLabel.layer.borderWidth = 1.0; - _queuePositionLabel.layer.borderColor = OpnColor(0x79C2FF, 0.22).CGColor; + _queuePositionLabel.layer.borderColor = OpnColor(OPN::kBrandGreen, 0.22).CGColor; [self addSubview:_queuePositionLabel]; _adContainerView = [[NSView alloc] initWithFrame:NSZeroRect]; @@ -144,7 +133,7 @@ - (instancetype)initWithFrame:(NSRect)frame message:(NSString *)message { _adContainerView.hidden = YES; [self addSubview:_adContainerView]; - _adChipLabel = OpnLabel(@"Ad Queue", NSZeroRect, 12.0, OpnColor(0x9FD3FF), NSFontWeightSemibold, NSTextAlignmentLeft); + _adChipLabel = OpnLabel(@"Ad Queue", NSZeroRect, 12.0, OpnColor(OPN::kBrandGreen), NSFontWeightSemibold, NSTextAlignmentLeft); [_adContainerView addSubview:_adChipLabel]; _adTitleLabel = OpnLabel(@"Ad playback required", NSZeroRect, 20.0, OpnColor(OPN::kTextPrimary), NSFontWeightBold, NSTextAlignmentLeft); _adTitleLabel.maximumNumberOfLines = 2; @@ -157,10 +146,43 @@ - (instancetype)initWithFrame:(NSRect)frame message:(NSString *)message { _adPlayerView.videoGravity = AVLayerVideoGravityResizeAspect; _adPlayerView.hidden = YES; [_adContainerView addSubview:_adPlayerView]; + [self applyAccentColors]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(interfacePreferencesChanged:) + name:OPNInterfacePreferencesDidChangeNotification + object:nil]; } return self; } +- (void)interfacePreferencesChanged:(NSNotification *)notification { + (void)notification; + [self applyAccentColors]; +} + +- (void)applyAccentColors { + self.sweepLayer.colors = @[(id)OpnColor(OPN::kBrandGreen, 0.0).CGColor, + (id)OpnColor(OPN::kBrandGreen, 0.28).CGColor, + (id)OpnColor(OPN::kBrandGreenHover, 0.42).CGColor, + (id)OpnColor(OPN::kBrandGreen, 0.0).CGColor]; + self.orbitLayer.strokeColor = OpnColor(OPN::kBrandGreen, 0.78).CGColor; + self.coreLayer.backgroundColor = OpnColor(OPN::kBrandGreenHover, 0.92).CGColor; + self.coreLayer.shadowColor = OpnColor(OPN::kBrandGreen, 1.0).CGColor; + self.sparkLayer.backgroundColor = OpnColor(OPN::kBrandGreen, 0.92).CGColor; + self.sparkLayer.shadowColor = OpnColor(OPN::kBrandGreen, 1.0).CGColor; + for (CALayer *bar in self.barLayers) { + bar.backgroundColor = OpnColor(OPN::kBrandGreen, 0.54).CGColor; + } + for (CALayer *dot in self.dotLayers) { + dot.backgroundColor = OpnColor(OPN::kBrandGreenHover, 0.74).CGColor; + dot.shadowColor = OpnColor(OPN::kBrandGreen, 1.0).CGColor; + } + self.queuePositionLabel.textColor = OpnColor(OPN::kBrandGreen); + self.queuePositionLabel.layer.borderColor = OpnColor(OPN::kBrandGreen, 0.22).CGColor; + self.adChipLabel.textColor = OpnColor(OPN::kBrandGreen); + [self restyleStepIndicators]; +} + - (BOOL)isFlipped { return YES; } - (void)setMessage:(NSString *)message { @@ -366,8 +388,8 @@ - (void)restyleStepIndicators { BOOL completed = (NSInteger)i < self.currentStepIndex; BOOL current = (NSInteger)i == self.currentStepIndex; indicator.backgroundColor = current - ? OpnColor(0xDDF0FF, 0.96).CGColor - : (completed ? OpnColor(0x7ED6A5, 0.54).CGColor : OpnColor(0xFFFFFF, 0.16).CGColor); + ? OpnColor(OPN::kBrandGreenHover, 0.96).CGColor + : (completed ? OpnColor(OPN::kBrandGreen, 0.54).CGColor : OpnColor(0xFFFFFF, 0.16).CGColor); } } From 53368a1af2fb048c6ad54ddad4b528fa896c2a23 Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Mon, 11 May 2026 21:50:38 -0500 Subject: [PATCH 07/18] Refine stream HUD and controller input --- src/OPNAppDelegate.mm | 9 + src/streaming/OPNLibWebRTCStreamSession.mm | 1 + src/streaming/OPNStreamViewController.mm | 278 +++++++++------------ src/views/OPNBackdropView.mm | 23 +- src/views/OPNGameCardView.mm | 5 +- src/views/OPNGameCatalogView.mm | 32 ++- 6 files changed, 175 insertions(+), 173 deletions(-) diff --git a/src/OPNAppDelegate.mm b/src/OPNAppDelegate.mm index 9c5dc68e8..2b5d30217 100644 --- a/src/OPNAppDelegate.mm +++ b/src/OPNAppDelegate.mm @@ -321,6 +321,15 @@ - (void)windowFullScreenStateChanged:(NSNotification *)notification { - (void)launchGame:(const OPN::GameInfo &)game variantIndex:(int)variantIndex returnScreen:(OPN::AuthScreen)returnScreen { using namespace OPN; + if (self.streamingController) { + NSLog(@"[AppDelegate] Ignoring game launch while stream is active: title=%s, id=%s", game.title.c_str(), game.id.c_str()); + return; + } + + self.catalogView = nil; + self.storeView = nil; + self.settingsView = nil; + NSLog(@"[AppDelegate] Game selected: title=%s, id=%s, uuid=%s, variantIndex=%d", game.title.c_str(), game.id.c_str(), game.uuid.c_str(), variantIndex); std::string apiToken = self.currentSession.idToken.empty() diff --git a/src/streaming/OPNLibWebRTCStreamSession.mm b/src/streaming/OPNLibWebRTCStreamSession.mm index d51d045af..0551c2dc4 100644 --- a/src/streaming/OPNLibWebRTCStreamSession.mm +++ b/src/streaming/OPNLibWebRTCStreamSession.mm @@ -1194,6 +1194,7 @@ static bool OPNAttachMicrophoneTrack(OPNLibWebRTCSessionImpl *impl, RTCAudioTrac #if defined(OPN_HAVE_LIBWEBRTC) if (m_impl) { OPNLibWebRTCSessionImpl *impl = (__bridge_transfer OPNLibWebRTCSessionImpl *)m_impl; + impl.owner = nullptr; impl.reliableInputChannel.delegate = nil; impl.partialInputChannel.delegate = nil; impl.peerConnection.delegate = nil; diff --git a/src/streaming/OPNStreamViewController.mm b/src/streaming/OPNStreamViewController.mm index 3b1a66e37..04637e4a4 100644 --- a/src/streaming/OPNStreamViewController.mm +++ b/src/streaming/OPNStreamViewController.mm @@ -548,28 +548,8 @@ - (void)layout { @end @implementation OPNStatsOverlayView { - NSTextField *_titleLabel; - NSTextField *_shortcutLabel; - NSTextField *_latencyLabel; - NSTextField *_jitterLabel; - NSTextField *_bitrateLabel; - NSTextField *_lossLabel; - NSTextField *_gpuLabel; - NSTextField *_streamLabel; - NSTextField *_serverLabel; - NSTextField *_webrtcLabel; - NSTextField *_decodeLabel; - NSTextField *_renderLabel; - NSTextField *_latencyValue; - NSTextField *_jitterValue; - NSTextField *_bitrateValue; - NSTextField *_lossValue; - NSTextField *_gpuValue; - NSTextField *_streamValue; - NSTextField *_serverValue; - NSTextField *_webrtcValue; - NSTextField *_decodeValue; - NSTextField *_renderValue; + CALayer *_textTintLayer; + NSTextField *_statsLineLabel; } static NSTextField *OPNStatsText(NSString *text, CGFloat size, NSFontWeight weight, NSColor *color, NSTextAlignment alignment) { @@ -586,79 +566,60 @@ @implementation OPNStatsOverlayView { return label; } +static NSAttributedString *OPNStatsOutlinedLine(NSString *text) { + NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; + style.alignment = NSTextAlignmentCenter; + style.lineBreakMode = NSLineBreakByTruncatingMiddle; + return [[NSAttributedString alloc] initWithString:text ?: @"" + attributes:@{ + NSFontAttributeName: [NSFont monospacedSystemFontOfSize:12.0 weight:NSFontWeightSemibold], + NSForegroundColorAttributeName: OPNQuitColor(1.0, 1.0, 1.0, 1.0), + NSStrokeColorAttributeName: OPNQuitColor(0.0, 0.0, 0.0, 0.95), + NSStrokeWidthAttributeName: @-3.0, + NSParagraphStyleAttributeName: style, + }]; +} + +static NSString *OPNStatsZoneName(NSString *zone) { + NSString *trimmed = [zone stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (trimmed.length == 0) return @"pending"; + + NSString *host = [NSURLComponents componentsWithString:trimmed].host; + if (host.length == 0) { + host = trimmed; + NSRange schemeRange = [host rangeOfString:@"://"]; + if (schemeRange.location != NSNotFound) { + host = [host substringFromIndex:NSMaxRange(schemeRange)]; + } + NSRange pathRange = [host rangeOfString:@"/"]; + if (pathRange.location != NSNotFound) { + host = [host substringToIndex:pathRange.location]; + } + } + + NSRange portRange = [host rangeOfString:@":"]; + if (portRange.location != NSNotFound) { + host = [host substringToIndex:portRange.location]; + } + NSArray *labels = [host componentsSeparatedByString:@"."]; + NSString *zoneName = labels.count > 0 ? labels.firstObject : host; + return zoneName.length > 0 ? zoneName : @"pending"; +} + - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { self.wantsLayer = YES; - self.autoresizingMask = NSViewMinXMargin | NSViewMaxYMargin; - - _titleLabel = OPNStatsText(@"Stream Stats", 13.0, NSFontWeightSemibold, - OPNQuitColor(0.96, 0.98, 0.95, 1.0), NSTextAlignmentLeft); - _shortcutLabel = OPNStatsText(@"Command-N", 10.0, NSFontWeightMedium, - OPNQuitColor(0.55, 0.57, 0.62, 1.0), NSTextAlignmentRight); - _latencyLabel = OPNStatsText(@"Latency", 11.0, NSFontWeightMedium, - OPNQuitColor(0.58, 0.62, 0.58, 1.0), NSTextAlignmentLeft); - _jitterLabel = OPNStatsText(@"Jitter", 11.0, NSFontWeightMedium, - OPNQuitColor(0.58, 0.62, 0.58, 1.0), NSTextAlignmentLeft); - _bitrateLabel = OPNStatsText(@"Bitrate", 11.0, NSFontWeightMedium, - OPNQuitColor(0.58, 0.62, 0.58, 1.0), NSTextAlignmentLeft); - _lossLabel = OPNStatsText(@"Loss", 11.0, NSFontWeightMedium, - OPNQuitColor(0.58, 0.62, 0.58, 1.0), NSTextAlignmentLeft); - _gpuLabel = OPNStatsText(@"GPU", 11.0, NSFontWeightMedium, - OPNQuitColor(0.58, 0.62, 0.58, 1.0), NSTextAlignmentLeft); - _streamLabel = OPNStatsText(@"Stream", 11.0, NSFontWeightMedium, - OPNQuitColor(0.58, 0.62, 0.58, 1.0), NSTextAlignmentLeft); - _serverLabel = OPNStatsText(@"Server", 11.0, NSFontWeightMedium, - OPNQuitColor(0.58, 0.62, 0.58, 1.0), NSTextAlignmentLeft); - _webrtcLabel = OPNStatsText(@"WebRTC", 11.0, NSFontWeightMedium, - OPNQuitColor(0.58, 0.62, 0.58, 1.0), NSTextAlignmentLeft); - _decodeLabel = OPNStatsText(@"Decode", 11.0, NSFontWeightMedium, - OPNQuitColor(0.58, 0.62, 0.58, 1.0), NSTextAlignmentLeft); - _renderLabel = OPNStatsText(@"Frames", 11.0, NSFontWeightMedium, - OPNQuitColor(0.58, 0.62, 0.58, 1.0), NSTextAlignmentLeft); - _latencyValue = OPNStatsText(@"-- ms", 12.0, NSFontWeightSemibold, - OPNQuitColor(0.96, 0.98, 0.95, 1.0), NSTextAlignmentRight); - _jitterValue = OPNStatsText(@"-- ms", 12.0, NSFontWeightSemibold, - OPNQuitColor(0.96, 0.98, 0.95, 1.0), NSTextAlignmentRight); - _bitrateValue = OPNStatsText(@"-- Mbps", 12.0, NSFontWeightSemibold, - OPNQuitColor(0.96, 0.98, 0.95, 1.0), NSTextAlignmentRight); - _lossValue = OPNStatsText(@"--", 12.0, NSFontWeightSemibold, - OPNQuitColor(0.96, 0.98, 0.95, 1.0), NSTextAlignmentRight); - _gpuValue = OPNStatsText(@"Pending", 12.0, NSFontWeightSemibold, - OPNQuitColor(0.96, 0.98, 0.95, 1.0), NSTextAlignmentRight); - _streamValue = OPNStatsText(@"--", 12.0, NSFontWeightSemibold, - OPNQuitColor(0.96, 0.98, 0.95, 1.0), NSTextAlignmentRight); - _serverValue = OPNStatsText(@"Pending", 12.0, NSFontWeightSemibold, - OPNQuitColor(0.96, 0.98, 0.95, 1.0), NSTextAlignmentRight); - _webrtcValue = OPNStatsText(@"Pending", 12.0, NSFontWeightSemibold, - OPNQuitColor(0.96, 0.98, 0.95, 1.0), NSTextAlignmentRight); - _decodeValue = OPNStatsText(@"Pending", 12.0, NSFontWeightSemibold, - OPNQuitColor(0.96, 0.98, 0.95, 1.0), NSTextAlignmentRight); - _renderValue = OPNStatsText(@"Pending", 12.0, NSFontWeightSemibold, - OPNQuitColor(0.96, 0.98, 0.95, 1.0), NSTextAlignmentRight); - - [self addSubview:_titleLabel]; - [self addSubview:_shortcutLabel]; - [self addSubview:_latencyLabel]; - [self addSubview:_jitterLabel]; - [self addSubview:_bitrateLabel]; - [self addSubview:_lossLabel]; - [self addSubview:_gpuLabel]; - [self addSubview:_streamLabel]; - [self addSubview:_serverLabel]; - [self addSubview:_webrtcLabel]; - [self addSubview:_decodeLabel]; - [self addSubview:_renderLabel]; - [self addSubview:_latencyValue]; - [self addSubview:_jitterValue]; - [self addSubview:_bitrateValue]; - [self addSubview:_lossValue]; - [self addSubview:_gpuValue]; - [self addSubview:_streamValue]; - [self addSubview:_serverValue]; - [self addSubview:_webrtcValue]; - [self addSubview:_decodeValue]; - [self addSubview:_renderValue]; + self.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; + _textTintLayer = [CALayer layer]; + _textTintLayer.backgroundColor = OPNQuitColor(0.0, 0.0, 0.0, 0.42).CGColor; + _textTintLayer.cornerRadius = 8.0; + [self.layer addSublayer:_textTintLayer]; + + _statsLineLabel = OPNStatsText(@"", 12.0, NSFontWeightSemibold, NSColor.clearColor, NSTextAlignmentCenter); + _statsLineLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; + _statsLineLabel.attributedStringValue = OPNStatsOutlinedLine(@"Stats: measuring"); + [self addSubview:_statsLineLabel]; } return self; } @@ -672,36 +633,8 @@ - (NSView *)hitTest:(NSPoint)point { - (void)layout { [super layout]; - CGFloat w = NSWidth(self.bounds); - _titleLabel.frame = NSMakeRect(16, 13, 180, 20); - _shortcutLabel.frame = NSMakeRect(w - 116, 15, 98, 16); - - NSArray *labels = @[_latencyLabel, _jitterLabel, _bitrateLabel, _lossLabel, _gpuLabel, _streamLabel, _serverLabel, _webrtcLabel, _decodeLabel, _renderLabel]; - NSArray *values = @[_latencyValue, _jitterValue, _bitrateValue, _lossValue, _gpuValue, _streamValue, _serverValue, _webrtcValue, _decodeValue, _renderValue]; - CGFloat y = 48.0; - for (NSUInteger i = 0; i < labels.count; i++) { - labels[i].frame = NSMakeRect(16, y, 88, 18); - values[i].frame = NSMakeRect(110, y, w - 126, 18); - y += 22.0; - } -} - -- (void)drawRect:(NSRect)dirtyRect { - (void)dirtyRect; - NSBezierPath *panel = [NSBezierPath bezierPathWithRoundedRect:NSInsetRect(self.bounds, 0.5, 0.5) - xRadius:16.0 - yRadius:16.0]; - NSGradient *gradient = [[NSGradient alloc] initWithStartingColor:OPNQuitColor(0.15, 0.16, 0.18, 0.88) - endingColor:OPNQuitColor(0.09, 0.10, 0.12, 0.90)]; - [gradient drawInBezierPath:panel angle:90.0]; - [OPNQuitColor(1.0, 1.0, 1.0, 0.12) setStroke]; - panel.lineWidth = 1.0; - [panel stroke]; - - NSBezierPath *accent = [NSBezierPath bezierPathWithRoundedRect:NSMakeRect(16, 35, NSWidth(self.bounds) - 32, 1) - xRadius:0.5 yRadius:0.5]; - [OPNQuitColor(1.0, 1.0, 1.0, 0.09) setFill]; - [accent fill]; + _textTintLayer.frame = NSInsetRect(self.bounds, 4.0, 1.0); + _statsLineLabel.frame = NSInsetRect(self.bounds, 8.0, 2.0); } - (void)updateLatencyMs:(NSInteger)latencyMs @@ -721,19 +654,18 @@ - (void)updateLatencyMs:(NSInteger)latencyMs pipelineMode:(NSString *)pipelineMode webrtcBackend:(NSString *)webrtcBackend framesReceived:(uint64_t)framesReceived - framesDecoded:(uint64_t)framesDecoded - framesDropped:(uint64_t)framesDropped { - _latencyValue.stringValue = latencyMs >= 0 ? [NSString stringWithFormat:@"%ld ms", (long)latencyMs] : @"Measuring"; - _jitterValue.stringValue = jitterMs >= 0 ? [NSString stringWithFormat:@"%ld ms", (long)jitterMs] : @"--"; - _bitrateValue.stringValue = bitrateMbps >= 0.0 ? [NSString stringWithFormat:@"%.1f Mbps", bitrateMbps] : @"--"; + framesDecoded:(uint64_t)framesDecoded + framesDropped:(uint64_t)framesDropped { + NSString *latencyText = latencyMs >= 0 ? [NSString stringWithFormat:@"%ld ms", (long)latencyMs] : @"measuring"; + NSString *jitterText = jitterMs >= 0 ? [NSString stringWithFormat:@"%ld ms", (long)jitterMs] : @"--"; + NSString *bitrateText = bitrateMbps >= 0.0 ? [NSString stringWithFormat:@"%.1f Mbps", bitrateMbps] : @"--"; + NSString *lossText = @"--"; if (packetLossPercent >= 0.0 && packetsLost >= 0) { - _lossValue.stringValue = [NSString stringWithFormat:@"%.2f%% (%lld)", packetLossPercent, (long long)packetsLost]; + lossText = [NSString stringWithFormat:@"%.2f%%/%lld", packetLossPercent, (long long)packetsLost]; } else if (packetsLost >= 0) { - _lossValue.stringValue = [NSString stringWithFormat:@"%lld", (long long)packetsLost]; - } else { - _lossValue.stringValue = @"--"; + lossText = [NSString stringWithFormat:@"%lld", (long long)packetsLost]; } - _gpuValue.stringValue = gpu.length > 0 ? gpu : @"Unknown"; + (void)gpu; NSString *streamText = @"--"; if (resolution.length > 0 && fps > 0) { @@ -742,35 +674,59 @@ - (void)updateLatencyMs:(NSInteger)latencyMs streamText = resolution; } if (codec.length > 0) { - _streamValue.stringValue = [NSString stringWithFormat:@"%@ / %@", streamText, codec]; - } else { - _streamValue.stringValue = streamText; + streamText = [NSString stringWithFormat:@"%@/%@", streamText, codec]; } - _serverValue.stringValue = zone.length > 0 ? zone : @"Pending"; - _webrtcValue.stringValue = webrtcBackend.length > 0 ? webrtcBackend : @"Unknown"; + NSString *serverText = OPNStatsZoneName(zone); + NSString *webrtcText = webrtcBackend.length > 0 ? webrtcBackend : @"unknown"; + NSString *decodeText = @"pending"; if (decoder.length > 0 && decodeTimeMs >= 0.0) { - _decodeValue.stringValue = [NSString stringWithFormat:@"%@ • %.1f ms", decoder, decodeTimeMs]; - } else { - _decodeValue.stringValue = decoder.length > 0 ? decoder : @"Pending"; + decodeText = [NSString stringWithFormat:@"%@ %.1f ms", decoder, decodeTimeMs]; + } else if (decoder.length > 0) { + decodeText = decoder; } + NSString *renderText = @"pending"; if (framesReceived > 0 || framesDecoded > 0 || framesDropped > 0) { - NSString *fpsText = renderFps >= 0.0 ? [NSString stringWithFormat:@"%.0f fps • ", renderFps] : @""; - NSString *sinkText = sink.length > 0 ? [NSString stringWithFormat:@" • %@", sink] : @""; - _renderValue.stringValue = [NSString stringWithFormat:@"%@%llu rx, %llu dec, %llu drop%@", - fpsText, - (unsigned long long)framesReceived, - (unsigned long long)framesDecoded, - (unsigned long long)framesDropped, - sinkText]; + NSString *fpsText = renderFps >= 0.0 ? [NSString stringWithFormat:@"%.0f fps ", renderFps] : @""; + NSString *sinkText = sink.length > 0 ? [NSString stringWithFormat:@" %@", sink] : @""; + renderText = [NSString stringWithFormat:@"%@%llu drop%@", + fpsText, + (unsigned long long)framesDropped, + sinkText]; } else if (sink.length > 0 && pipelineMode.length > 0) { - _renderValue.stringValue = [NSString stringWithFormat:@"%@ / %@", sink, pipelineMode]; - } else { - _renderValue.stringValue = @"Pending"; + renderText = [NSString stringWithFormat:@"%@/%@", sink, pipelineMode]; } + NSArray *parts = @[ + [NSString stringWithFormat:@"Latency %@", latencyText], + [NSString stringWithFormat:@"Jitter %@", jitterText], + [NSString stringWithFormat:@"Bitrate %@", bitrateText], + [NSString stringWithFormat:@"Loss %@", lossText], + [NSString stringWithFormat:@"Stream %@", streamText], + [NSString stringWithFormat:@"Server %@", serverText], + [NSString stringWithFormat:@"WebRTC %@", webrtcText], + [NSString stringWithFormat:@"Decode %@", decodeText], + [NSString stringWithFormat:@"Frames %@", renderText], + ]; + _statsLineLabel.attributedStringValue = OPNStatsOutlinedLine([parts componentsJoinedByString:@" | "]); } @end +static void OPNReleaseSignalingClientAfterCallbacks(OPN::SignalingClient *client) { + if (!client) return; + client->Disconnect(); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + delete client; + }); +} + +static void OPNReleaseStreamSessionAfterCallbacks(OPN::IStreamSession *session) { + if (!session) return; + session->Stop(); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + delete session; + }); +} + @implementation OPNStreamViewController { OPN::SignalingClient *_signaling; OPN::IStreamSession *_session; @@ -896,7 +852,6 @@ - (void)viewDidAppear { - (void)viewWillDisappear { [super viewWillDisappear]; [self removeQuitShortcutMonitor]; - [self endStreamWithSuccess:NO errorMessage:"Stream view removed"]; } - (void)setStatus:(NSString *)msg { @@ -1030,10 +985,10 @@ - (void)installQuitShortcutMonitor { } - (NSRect)statsOverlayFrame { - CGFloat width = MIN(560.0, MAX(420.0, NSWidth(self.view.bounds) - 36.0)); - CGFloat height = 280.0; - return NSMakeRect(floor(NSWidth(self.view.bounds) - width - 18.0), - floor(NSHeight(self.view.bounds) - height - 18.0), + CGFloat width = MAX(0.0, NSWidth(self.view.bounds) - 32.0); + CGFloat height = 26.0; + return NSMakeRect(16.0, + floor(NSHeight(self.view.bounds) - height - 10.0), width, height); } @@ -1336,13 +1291,12 @@ - (void)resetTransportForRecovery { [self.streamView detachFromPipeline]; } if (_signaling) { - _signaling->Disconnect(); - delete _signaling; + OPNReleaseSignalingClientAfterCallbacks(_signaling); _signaling = nullptr; } if (_session) { - _session->Stop(); - delete _session; + OPNReleaseStreamSessionAfterCallbacks(_session); + _session = nullptr; } OPN::StreamWebRTCBackend backend = OPN::ResolveStreamWebRTCBackend(); _session = OPN::CreateStreamSession(backend).release(); @@ -1856,13 +1810,11 @@ - (void)cleanup { self.statusLabel = nil; } if (_signaling) { - _signaling->Disconnect(); - delete _signaling; + OPNReleaseSignalingClientAfterCallbacks(_signaling); _signaling = nullptr; } if (_session) { - _session->Stop(); - delete _session; + OPNReleaseStreamSessionAfterCallbacks(_session); _session = nullptr; } } diff --git a/src/views/OPNBackdropView.mm b/src/views/OPNBackdropView.mm index 69cd6cb7d..21846a8dc 100644 --- a/src/views/OPNBackdropView.mm +++ b/src/views/OPNBackdropView.mm @@ -11,6 +11,12 @@ @implementation OPNBackdropControllerMenuView - (BOOL)isFlipped { return YES; } @end +static BOOL OPNBackdropControllerNavigationActive(NSView *view) { + NSWindow *window = view.window; + if (!window || window.contentViewController != nil) return NO; + return window.contentView == view || [view isDescendantOf:window.contentView]; +} + @implementation OPNBackdropView { NSRect _storeNavFrame; NSRect _libraryNavFrame; @@ -66,6 +72,17 @@ - (void)dealloc { [_controllerNavigationTimer invalidate]; } +- (void)viewDidMoveToWindow { + [super viewDidMoveToWindow]; + if (self.window) { + [self startControllerNavigationIfNeeded]; + } else { + [_controllerNavigationTimer invalidate]; + _controllerNavigationTimer = nil; + _previousControllerButtons = 0; + } +} + - (void)interfacePreferencesChanged:(NSNotification *)notification { (void)notification; [self setNeedsDisplay:YES]; @@ -73,9 +90,9 @@ - (void)interfacePreferencesChanged:(NSNotification *)notification { } - (void)startControllerNavigationIfNeeded { - if (!OpnControllerModeEnabled() || _controllerNavigationTimer) return; + if (!OpnControllerModeEnabled() || _controllerNavigationTimer || !OPNBackdropControllerNavigationActive(self)) return; _controllerNavigationTimer = [NSTimer scheduledTimerWithTimeInterval:(1.0 / 30.0) - target:self + target:self selector:@selector(pollControllerNavigation) userInfo:nil repeats:YES]; @@ -118,7 +135,7 @@ - (void)selectNextControllerTab { } - (void)pollControllerNavigation { - if (!OpnControllerModeEnabled()) { + if (!OpnControllerModeEnabled() || !OPNBackdropControllerNavigationActive(self)) { [_controllerNavigationTimer invalidate]; _controllerNavigationTimer = nil; _previousControllerButtons = 0; diff --git a/src/views/OPNGameCardView.mm b/src/views/OPNGameCardView.mm index 4ec7ae9a9..36f22c45c 100644 --- a/src/views/OPNGameCardView.mm +++ b/src/views/OPNGameCardView.mm @@ -272,9 +272,11 @@ - (void)layout { [super layout]; CGFloat width = NSWidth(self.bounds); CGFloat height = NSHeight(self.bounds); + BOOL controllerMode = OpnControllerModeEnabled(); self.contentView.frame = self.bounds; self.contentView.layer.cornerRadius = 18.0; self.imageView.frame = self.bounds; + self.gradientOverlay.hidden = controllerMode; self.gradientOverlay.frame = NSMakeRect(0, MAX(0.0, height - gGradientOverlayHeight), width, MIN(gGradientOverlayHeight, height)); self.playButton.frame = NSMakeRect((width - 46.0) / 2.0, (height - 46.0) / 2.0, 46.0, 46.0); self.storeChipsContainer.frame = NSMakeRect(16.0, MAX(0.0, height - 37.0), MAX(40.0, width - 32.0), 24.0); @@ -288,7 +290,8 @@ - (void)playClicked { - (void)buildStoreChips { for (NSView *v in _storeChipsContainer.subviews) { [v removeFromSuperview]; } [_storeChipButtons removeAllObjects]; - self.storeChipsContainer.hidden = _gameData.variants.size() <= 1; + self.storeChipsContainer.hidden = OpnControllerModeEnabled() || _gameData.variants.size() <= 1; + if (self.storeChipsContainer.hidden) return; if (_gameData.variants.empty()) return; if (_gameData.variants.size() <= 1) return; diff --git a/src/views/OPNGameCatalogView.mm b/src/views/OPNGameCatalogView.mm index 6a008ed65..eb0be5a38 100644 --- a/src/views/OPNGameCatalogView.mm +++ b/src/views/OPNGameCatalogView.mm @@ -108,6 +108,7 @@ @interface OPNGameCatalogView () @property (nonatomic, strong) NSTimer *gamepadNavigationTimer; @property (nonatomic, assign) uint16_t previousGamepadButtons; @property (nonatomic, assign) CFTimeInterval lastGamepadMoveTime; +- (void)stopGamepadNavigation; - (void)scrollLibraryToTop; - (void)requestCatalogBrowse; - (void)focusCardAtIndex:(NSInteger)index scrollIntoView:(BOOL)scrollIntoView; @@ -139,6 +140,12 @@ static uint16_t OPNCatalogGamepadButtons(void) { return buttons; } +static BOOL OPNCatalogGamepadNavigationActive(NSView *view) { + NSWindow *window = view.window; + if (!window || window.contentViewController != nil) return NO; + return window.contentView == view || [view isDescendantOf:window.contentView]; +} + static NSString *OPNCatalogJoinedStrings(const std::vector &values, NSString *fallback) { NSMutableArray *items = [NSMutableArray array]; for (const std::string &value : values) { @@ -352,7 +359,16 @@ - (instancetype)initWithFrame:(NSRect)frame { - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; - [self.gamepadNavigationTimer invalidate]; + [self stopGamepadNavigation]; +} + +- (void)viewDidMoveToWindow { + [super viewDidMoveToWindow]; + if (self.window) { + [self startGamepadNavigationIfNeeded]; + } else { + [self stopGamepadNavigation]; + } } - (void)interfacePreferencesChanged:(NSNotification *)notification { @@ -929,7 +945,7 @@ - (void)keyDown:(NSEvent *)event { } - (void)startGamepadNavigationIfNeeded { - if (!OpnControllerModeEnabled() || self.gamepadNavigationTimer) return; + if (!OpnControllerModeEnabled() || self.gamepadNavigationTimer || !OPNCatalogGamepadNavigationActive(self)) return; self.gamepadNavigationTimer = [NSTimer scheduledTimerWithTimeInterval:(1.0 / 30.0) target:self selector:@selector(pollGamepadNavigation) @@ -937,6 +953,12 @@ - (void)startGamepadNavigationIfNeeded { repeats:YES]; } +- (void)stopGamepadNavigation { + [self.gamepadNavigationTimer invalidate]; + self.gamepadNavigationTimer = nil; + self.previousGamepadButtons = 0; +} + - (void)controllerDidConnect:(NSNotification *)notification { (void)notification; [self startGamepadNavigationIfNeeded]; @@ -948,10 +970,8 @@ - (void)controllerDidDisconnect:(NSNotification *)notification { } - (void)pollGamepadNavigation { - if (!OpnControllerModeEnabled()) { - [self.gamepadNavigationTimer invalidate]; - self.gamepadNavigationTimer = nil; - self.previousGamepadButtons = 0; + if (!OpnControllerModeEnabled() || !OPNCatalogGamepadNavigationActive(self)) { + [self stopGamepadNavigation]; return; } if (self.window.firstResponder != self.searchField) [self.window makeFirstResponder:self]; From 55cf1a9aa5bbca37455db6c749f2742adedc03bb Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Mon, 11 May 2026 22:05:52 -0500 Subject: [PATCH 08/18] Fix active stream shutdown --- src/OPNAppDelegate.mm | 6 ++--- src/streaming/OPNStreamViewController.h | 1 + src/streaming/OPNStreamViewController.mm | 29 ++++++++++++++++++++---- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/OPNAppDelegate.mm b/src/OPNAppDelegate.mm index 2b5d30217..d0739ed2f 100644 --- a/src/OPNAppDelegate.mm +++ b/src/OPNAppDelegate.mm @@ -291,8 +291,8 @@ - (void)applicationWillTerminate:(NSNotification *)notification { [self.window saveFrameUsingName:OPNMainWindowFrameAutosaveName]; [self saveWindowPresentation]; [self stopGameLibraryRefreshTimer]; - // Clear streaming controller reference (block will be released with controller) if (self.streamingController) { + [self.streamingController shutdownForApplicationTermination]; self.streamingController = nil; } } @@ -1188,8 +1188,8 @@ - (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender - (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender { (void)sender; if (self.streamingController) { - [self.streamingController requestQuitGameConfirmation]; - return NSTerminateCancel; + [self.streamingController shutdownForApplicationTermination]; + self.streamingController = nil; } return NSTerminateNow; } diff --git a/src/streaming/OPNStreamViewController.h b/src/streaming/OPNStreamViewController.h index 2b94735a2..7e455d077 100644 --- a/src/streaming/OPNStreamViewController.h +++ b/src/streaming/OPNStreamViewController.h @@ -17,6 +17,7 @@ NS_ASSUME_NONNULL_BEGIN (BOOL success, const std::string &errorMessage); - (void)requestQuitGameConfirmation; +- (void)shutdownForApplicationTermination; @end diff --git a/src/streaming/OPNStreamViewController.mm b/src/streaming/OPNStreamViewController.mm index 04637e4a4..72ecdc06e 100644 --- a/src/streaming/OPNStreamViewController.mm +++ b/src/streaming/OPNStreamViewController.mm @@ -569,7 +569,7 @@ @implementation OPNStatsOverlayView { static NSAttributedString *OPNStatsOutlinedLine(NSString *text) { NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; style.alignment = NSTextAlignmentCenter; - style.lineBreakMode = NSLineBreakByTruncatingMiddle; + style.lineBreakMode = NSLineBreakByWordWrapping; return [[NSAttributedString alloc] initWithString:text ?: @"" attributes:@{ NSFontAttributeName: [NSFont monospacedSystemFontOfSize:12.0 weight:NSFontWeightSemibold], @@ -617,7 +617,8 @@ - (instancetype)initWithFrame:(NSRect)frame { [self.layer addSublayer:_textTintLayer]; _statsLineLabel = OPNStatsText(@"", 12.0, NSFontWeightSemibold, NSColor.clearColor, NSTextAlignmentCenter); - _statsLineLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; + _statsLineLabel.lineBreakMode = NSLineBreakByWordWrapping; + _statsLineLabel.maximumNumberOfLines = 0; _statsLineLabel.attributedStringValue = OPNStatsOutlinedLine(@"Stats: measuring"); [self addSubview:_statsLineLabel]; } @@ -633,8 +634,8 @@ - (NSView *)hitTest:(NSPoint)point { - (void)layout { [super layout]; - _textTintLayer.frame = NSInsetRect(self.bounds, 4.0, 1.0); - _statsLineLabel.frame = NSInsetRect(self.bounds, 8.0, 2.0); + _textTintLayer.frame = NSInsetRect(self.bounds, 4.0, 2.0); + _statsLineLabel.frame = NSInsetRect(self.bounds, 10.0, 5.0); } - (void)updateLatencyMs:(NSInteger)latencyMs @@ -986,7 +987,7 @@ - (void)installQuitShortcutMonitor { - (NSRect)statsOverlayFrame { CGFloat width = MAX(0.0, NSWidth(self.view.bounds) - 32.0); - CGFloat height = 26.0; + CGFloat height = 56.0; return NSMakeRect(16.0, floor(NSHeight(self.view.bounds) - height - 10.0), width, @@ -1345,6 +1346,24 @@ - (void)endStreamFromUserQuit { [self endStreamWithSuccess:YES errorMessage:""]; } +- (void)shutdownForApplicationTermination { + if (![NSThread isMainThread]) { + __weak OPNStreamViewController *weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + OPNStreamViewController *strongSelf = weakSelf; + if (!strongSelf) return; + [strongSelf shutdownForApplicationTermination]; + }); + return; + } + + if (_streamEnded) return; + _streamEnded = YES; + [self requestRemoteStopForActiveSession]; + [self finishLaunchMeasurementWithSuccess:YES reason:@"application terminating"]; + [self cleanup]; +} + - (void)cancelRemoteIceGraceTimer { if (!_remoteIceGraceTimer) return; dispatch_source_t timer = (__bridge_transfer dispatch_source_t)_remoteIceGraceTimer; From 19d0614b4c27b4133ed1ab13190f029a0f715e33 Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Mon, 11 May 2026 22:23:48 -0500 Subject: [PATCH 09/18] Remove decoder backend setting --- src/streaming/OPNStreamPreferences.h | 9 --------- src/streaming/OPNStreamPreferences.mm | 19 ------------------- src/streaming/OPNStreamSession.h | 1 - src/streaming/OPNStreamTypes.h | 1 - src/streaming/OPNStreamViewController.mm | 6 +----- src/views/OPNSettingsView.mm | 20 +++++--------------- 6 files changed, 6 insertions(+), 50 deletions(-) diff --git a/src/streaming/OPNStreamPreferences.h b/src/streaming/OPNStreamPreferences.h index f946e3505..50147c8d1 100644 --- a/src/streaming/OPNStreamPreferences.h +++ b/src/streaming/OPNStreamPreferences.h @@ -44,11 +44,6 @@ struct StreamColorQualityOption { std::string value; }; -struct StreamDecoderBackendOption { - std::string label; - std::string value; -}; - struct StreamMicrophoneModeOption { std::string label; std::string value; @@ -67,7 +62,6 @@ struct StreamPreferenceProfile { int codecIndex = 0; int bitrateIndex = 2; int colorQualityIndex = 0; - int decoderBackendIndex = 0; int rendererPacingIndex = 1; int fps = 60; int rendererPacingFps = 60; @@ -88,7 +82,6 @@ struct StreamPreferenceProfile { StreamCodecOption codec; StreamBitrateOption bitrate; StreamColorQualityOption colorQuality; - StreamDecoderBackendOption decoderBackend; double AspectRatio() const; }; @@ -98,7 +91,6 @@ const std::vector &StreamFpsOptions(); const std::vector &StreamCodecOptions(); const std::vector &StreamBitrateOptions(); const std::vector &StreamColorQualityOptions(); -const std::vector &StreamDecoderBackendOptions(); const std::vector &StreamRendererPacingOptions(); const std::vector &StreamMicrophoneModeOptions(); std::vector LoadMicrophoneDeviceOptions(); @@ -120,7 +112,6 @@ void SaveStreamFpsIndex(int fpsIndex); void SaveStreamCodecIndex(int codecIndex); void SaveStreamBitrateIndex(int bitrateIndex); void SaveStreamColorQualityIndex(int colorQualityIndex); -void SaveStreamDecoderBackendIndex(int decoderBackendIndex); void SaveStreamRendererPacingIndex(int rendererPacingIndex); void SaveStreamL4SEnabled(bool enabled); void SaveStreamPowerSaverEnabled(bool enabled); diff --git a/src/streaming/OPNStreamPreferences.mm b/src/streaming/OPNStreamPreferences.mm index 7ede28763..57cbd86a0 100644 --- a/src/streaming/OPNStreamPreferences.mm +++ b/src/streaming/OPNStreamPreferences.mm @@ -13,7 +13,6 @@ static NSString *const kCodecIndexKey = @"OpenNOW.Stream.CodecIndex"; static NSString *const kBitrateIndexKey = @"OpenNOW.Stream.BitrateIndex"; static NSString *const kColorQualityIndexKey = @"OpenNOW.Stream.ColorQualityIndex"; -static NSString *const kDecoderBackendIndexKey = @"OpenNOW.Stream.DecoderBackendIndex"; static NSString *const kRendererPacingIndexKey = @"OpenNOW.Stream.RendererPacingIndex"; static NSString *const kL4SEnabledKey = @"OpenNOW.Stream.L4SEnabled"; static NSString *const kPowerSaverEnabledKey = @"OpenNOW.Stream.PowerSaverEnabled"; @@ -94,15 +93,6 @@ return options; } -const std::vector &StreamDecoderBackendOptions() { - static const std::vector options = { - {"Auto", "auto"}, - {"VideoToolbox", "videotoolbox"}, - {"FFmpeg / Software", "ffmpeg"}, - }; - return options; -} - const std::vector &StreamRendererPacingOptions() { static const std::vector options = {30, 60, 120}; return options; @@ -363,10 +353,6 @@ StreamPreferenceProfile LoadStreamPreferenceProfile() { profile.colorQualityIndex = ClampedStoredInteger(kColorQualityIndexKey, 0, (int)colorQualityOptions.size()); profile.colorQuality = colorQualityOptions[(size_t)profile.colorQualityIndex]; - const auto &decoderBackendOptions = StreamDecoderBackendOptions(); - profile.decoderBackendIndex = ClampedStoredInteger(kDecoderBackendIndexKey, 0, (int)decoderBackendOptions.size()); - profile.decoderBackend = decoderBackendOptions[(size_t)profile.decoderBackendIndex]; - const auto &rendererPacingOptions = StreamRendererPacingOptions(); profile.rendererPacingIndex = ClampedStoredInteger(kRendererPacingIndexKey, 1, (int)rendererPacingOptions.size()); profile.rendererPacingFps = rendererPacingOptions[(size_t)profile.rendererPacingIndex]; @@ -595,11 +581,6 @@ void SaveStreamColorQualityIndex(int colorQualityIndex) { [NSUserDefaults.standardUserDefaults setInteger:clamped forKey:kColorQualityIndexKey]; } -void SaveStreamDecoderBackendIndex(int decoderBackendIndex) { - int clamped = std::max(0, std::min(decoderBackendIndex, (int)StreamDecoderBackendOptions().size() - 1)); - [NSUserDefaults.standardUserDefaults setInteger:clamped forKey:kDecoderBackendIndexKey]; -} - void SaveStreamRendererPacingIndex(int rendererPacingIndex) { int clamped = std::max(0, std::min(rendererPacingIndex, (int)StreamRendererPacingOptions().size() - 1)); [NSUserDefaults.standardUserDefaults setInteger:clamped forKey:kRendererPacingIndexKey]; diff --git a/src/streaming/OPNStreamSession.h b/src/streaming/OPNStreamSession.h index 2f737718f..2290f1d3c 100644 --- a/src/streaming/OPNStreamSession.h +++ b/src/streaming/OPNStreamSession.h @@ -151,7 +151,6 @@ class StreamSession final : public IStreamSession { std::atomic m_audioDeviceListenerInstalled{false}; double m_gameVolume = 1.0; double m_microphoneVolumeLevel = 1.0; - std::string m_decoderBackend = "auto"; int m_lastIceConnectionState = -1; int m_lastPeerConnectionState = -1; int m_lastSignalingState = -1; diff --git a/src/streaming/OPNStreamTypes.h b/src/streaming/OPNStreamTypes.h index 33e833ee1..63618e269 100644 --- a/src/streaming/OPNStreamTypes.h +++ b/src/streaming/OPNStreamTypes.h @@ -11,7 +11,6 @@ struct StreamSettings { int fps = 60; std::string codec = "H264"; std::string colorQuality = "8bit_420"; - std::string decoderBackend = "auto"; int maxBitrateMbps = 50; bool enableCloudGsync = false; bool enableL4S = false; diff --git a/src/streaming/OPNStreamViewController.mm b/src/streaming/OPNStreamViewController.mm index 72ecdc06e..941bb38e0 100644 --- a/src/streaming/OPNStreamViewController.mm +++ b/src/streaming/OPNStreamViewController.mm @@ -678,7 +678,6 @@ - (void)updateLatencyMs:(NSInteger)latencyMs streamText = [NSString stringWithFormat:@"%@/%@", streamText, codec]; } NSString *serverText = OPNStatsZoneName(zone); - NSString *webrtcText = webrtcBackend.length > 0 ? webrtcBackend : @"unknown"; NSString *decodeText = @"pending"; if (decoder.length > 0 && decodeTimeMs >= 0.0) { decodeText = [NSString stringWithFormat:@"%@ %.1f ms", decoder, decodeTimeMs]; @@ -703,7 +702,6 @@ - (void)updateLatencyMs:(NSInteger)latencyMs [NSString stringWithFormat:@"Loss %@", lossText], [NSString stringWithFormat:@"Stream %@", streamText], [NSString stringWithFormat:@"Server %@", serverText], - [NSString stringWithFormat:@"WebRTC %@", webrtcText], [NSString stringWithFormat:@"Decode %@", decodeText], [NSString stringWithFormat:@"Frames %@", renderText], ]; @@ -1501,7 +1499,6 @@ - (void)startStreamLaunchFlow { settings.fps = streamProfile.enablePowerSaver ? std::min(streamProfile.fps, 30) : streamProfile.fps; settings.codec = OPNEffectiveStreamCodec(streamProfile, OPN::ResolveStreamWebRTCBackend()); settings.colorQuality = streamProfile.colorQuality.value.empty() ? "8bit_420" : streamProfile.colorQuality.value; - settings.decoderBackend = streamProfile.decoderBackend.value.empty() ? "auto" : streamProfile.decoderBackend.value; settings.maxBitrateMbps = OPNEffectiveMaxBitrateMbps(streamProfile); settings.enableL4S = streamProfile.enableL4S; settings.microphoneMode = streamProfile.microphoneMode; @@ -1565,14 +1562,13 @@ - (void)startStreamLaunchFlow { settings.codec.c_str(), streamProfile.enablePowerSaver ? "on" : "off"); - NSLog(@"[StreamVC] Selected stream profile display=%dx%d stream=%s fps=%d bitrate=%dMbps codec=%s decoder=%s aspect=%s %.4f l4s=%s powerSaver=%s requested=%s@%dfps/%dMbps/%s", + NSLog(@"[StreamVC] Selected stream profile display=%dx%d stream=%s fps=%d bitrate=%dMbps codec=%s aspect=%s %.4f l4s=%s powerSaver=%s requested=%s@%dfps/%dMbps/%s", displayProfile.displayWidth, displayProfile.displayHeight, settings.resolution.c_str(), settings.fps, settings.maxBitrateMbps, settings.codec.c_str(), - settings.decoderBackend.c_str(), streamProfile.aspect.label.c_str(), streamProfile.AspectRatio(), settings.enableL4S ? "on" : "off", diff --git a/src/views/OPNSettingsView.mm b/src/views/OPNSettingsView.mm index 07e6bac54..92792d2fb 100644 --- a/src/views/OPNSettingsView.mm +++ b/src/views/OPNSettingsView.mm @@ -170,7 +170,6 @@ @interface OPNSettingsView () @property (nonatomic, assign) NSInteger selectedCodec; @property (nonatomic, assign) NSInteger selectedBitrate; @property (nonatomic, assign) NSInteger selectedColorDepth; -@property (nonatomic, assign) NSInteger selectedDecoderBackend; @property (nonatomic, assign) NSInteger selectedRendererPacing; @property (nonatomic, assign) NSInteger selectedMicrophoneMode; @property (nonatomic, assign) NSInteger selectedMicrophoneDevice; @@ -212,7 +211,6 @@ - (instancetype)initWithFrame:(NSRect)frame { _selectedCodec = profile.codecIndex; _selectedBitrate = profile.bitrateIndex; _selectedColorDepth = profile.colorQualityIndex; - _selectedDecoderBackend = profile.decoderBackendIndex; _selectedRendererPacing = profile.rendererPacingIndex; _selectedMicrophoneMode = profile.microphoneMode == "push-to-talk" ? 1 : (profile.microphoneMode == "voice-activity" ? 2 : 0); _selectedMicrophoneDevice = 0; @@ -521,27 +519,21 @@ - (void)buildVideoContent { } [video addSubview:[self rowLabel:@"Codec" y:416.0]]; [self addOptionGroupTo:video group:4 titles:codecTitles selected:self.selectedCodec y:406.0 widths:@[@142.0, @116.0, @96.0, @70.0]]; - NSMutableArray *decoderBackendTitles = [NSMutableArray array]; - for (const OPN::StreamDecoderBackendOption &option : OPN::StreamDecoderBackendOptions()) { - [decoderBackendTitles addObject:[NSString stringWithUTF8String:option.label.c_str()]]; - } - [video addSubview:[self rowLabel:@"Decoder Backend" y:492.0]]; - [self addOptionGroupTo:video group:5 titles:decoderBackendTitles selected:self.selectedDecoderBackend y:482.0 widths:@[@78.0, @126.0, @150.0]]; NSMutableArray *colorDepthTitles = [NSMutableArray array]; for (const OPN::StreamColorQualityOption &option : OPN::StreamColorQualityOptions()) { [colorDepthTitles addObject:[NSString stringWithUTF8String:option.label.c_str()]]; } - [video addSubview:[self rowLabel:@"Color Depth" y:582.0]]; - [self addOptionGroupTo:video group:7 titles:colorDepthTitles selected:self.selectedColorDepth y:572.0 widths:@[@112.0, @112.0, @124.0, @124.0]]; + [video addSubview:[self rowLabel:@"Color Depth" y:492.0]]; + [self addOptionGroupTo:video group:7 titles:colorDepthTitles selected:self.selectedColorDepth y:482.0 widths:@[@112.0, @112.0, @124.0, @124.0]]; NSMutableArray *rendererPacingTitles = [NSMutableArray array]; for (int fps : OPN::StreamRendererPacingOptions()) { [rendererPacingTitles addObject:[NSString stringWithFormat:@"%d", fps]]; } - [video addSubview:[self rowLabel:@"Advanced: Renderer Pacing" y:672.0]]; - [self addOptionGroupTo:video group:10 titles:rendererPacingTitles selected:self.selectedRendererPacing y:662.0 widths:@[@62.0, @62.0, @62.0]]; + [video addSubview:[self rowLabel:@"Advanced: Renderer Pacing" y:582.0]]; + [self addOptionGroupTo:video group:10 titles:rendererPacingTitles selected:self.selectedRendererPacing y:572.0 widths:@[@62.0, @62.0, @62.0]]; NSTextField *pacingHint = OpnLabel(@"Advanced experimental renderer timing. Do not change unless you know what it does; incorrect values can make motion look worse.", - NSMakeRect(controlX, 704.0, controlWidth, 34.0), + NSMakeRect(controlX, 614.0, controlWidth, 34.0), 12.0, OpnColor(kTextMuted), NSFontWeightRegular); @@ -993,7 +985,6 @@ - (void)optionClicked:(NSButton *)sender { case 1: OPN::SaveStreamAspectIndex((int)index); break; case 3: OPN::SaveStreamFpsIndex((int)index); break; case 4: OPN::SaveStreamCodecIndex((int)index); break; - case 5: OPN::SaveStreamDecoderBackendIndex((int)index); break; case 7: OPN::SaveStreamColorQualityIndex((int)index); break; case 8: OPN::SaveStreamBitrateIndex((int)index); break; case 9: [self applyPerformanceProfile:index]; break; @@ -1007,7 +998,6 @@ - (void)optionClicked:(NSButton *)sender { self.selectedCodec = profile.codecIndex; self.selectedBitrate = profile.bitrateIndex; self.selectedColorDepth = profile.colorQualityIndex; - self.selectedDecoderBackend = profile.decoderBackendIndex; self.selectedRendererPacing = profile.rendererPacingIndex; self.enableL4S = profile.enableL4S; [self rebuildContent]; From a37e523c206ce03f33174f1efdf4a63a13bdb4ca Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Mon, 11 May 2026 23:16:48 -0500 Subject: [PATCH 10/18] Add controller mode game art backdrop --- src/views/OPNGameCardView.mm | 24 +++- src/views/OPNGameCatalogView.mm | 199 +++++++++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 11 deletions(-) diff --git a/src/views/OPNGameCardView.mm b/src/views/OPNGameCardView.mm index 36f22c45c..7c5c46c29 100644 --- a/src/views/OPNGameCardView.mm +++ b/src/views/OPNGameCardView.mm @@ -130,6 +130,7 @@ @interface OPNGameCardView () @property (nonatomic, strong) NSView *storeChipsContainer; @property (nonatomic, strong) NSTrackingArea *trackingArea; @property (nonatomic, strong) NSButton *playButton; +@property (nonatomic, strong) CALayer *reflectionLayer; @property (nonatomic, strong) NSMutableArray *storeChipButtons; - (void)loadImageFromCandidates:(NSArray *)urlStrings index:(NSUInteger)index; - (void)applyFocusStyle; @@ -160,6 +161,16 @@ - (instancetype)initWithFrame:(NSRect)frame game:(const OPN::GameInfo &)game { self.layer.shadowRadius = 18.0; self.layer.shadowOffset = CGSizeMake(0.0, 14.0); + _reflectionLayer = [CALayer layer]; + _reflectionLayer.backgroundColor = OpnColor(kBrandGreen, 0.22).CGColor; + _reflectionLayer.cornerRadius = 16.0; + _reflectionLayer.opacity = 0.0; + _reflectionLayer.shadowColor = OpnColor(kBrandGreen).CGColor; + _reflectionLayer.shadowOpacity = 0.72; + _reflectionLayer.shadowRadius = 22.0; + _reflectionLayer.shadowOffset = CGSizeZero; + [self.layer addSublayer:_reflectionLayer]; + _contentView = [[NSView alloc] initWithFrame:self.bounds]; _contentView.wantsLayer = YES; _contentView.layer.cornerRadius = 18.0; @@ -252,15 +263,17 @@ - (void)applyFocusStyle { self.layer.borderWidth = selected ? 2.0 : 1.0; self.layer.shadowColor = OpnColor(kBrandGreen).CGColor; self.layer.shadowOpacity = selected ? 0.62 : 0.30; - self.layer.shadowRadius = selected ? 44.0 : 18.0; - self.layer.shadowOffset = selected ? CGSizeMake(0.0, 22.0) : CGSizeMake(0.0, 12.0); + self.layer.shadowRadius = selected ? 56.0 : 18.0; + self.layer.shadowOffset = selected ? CGSizeMake(0.0, 26.0) : CGSizeMake(0.0, 12.0); CATransform3D transform = CATransform3DIdentity; - transform.m34 = -1.0 / 900.0; + transform.m34 = -1.0 / 760.0; if (selected) { - transform = CATransform3DScale(transform, 1.075, 1.075, 1.0); - transform = CATransform3DRotate(transform, -0.035, 1.0, 0.0, 0.0); + transform = CATransform3DTranslate(transform, 0.0, -10.0, 34.0); + transform = CATransform3DScale(transform, 1.105, 1.105, 1.0); + transform = CATransform3DRotate(transform, -0.052, 1.0, 0.0, 0.0); } self.layer.transform = transform; + self.reflectionLayer.opacity = selected ? 0.82 : 0.0; self.playButton.layer.shadowOpacity = selected ? 0.72 : 0.22; self.playButton.layer.shadowRadius = selected ? 20.0 : 12.0; [CATransaction commit]; @@ -280,6 +293,7 @@ - (void)layout { self.gradientOverlay.frame = NSMakeRect(0, MAX(0.0, height - gGradientOverlayHeight), width, MIN(gGradientOverlayHeight, height)); self.playButton.frame = NSMakeRect((width - 46.0) / 2.0, (height - 46.0) / 2.0, 46.0, 46.0); self.storeChipsContainer.frame = NSMakeRect(16.0, MAX(0.0, height - 37.0), MAX(40.0, width - 32.0), 24.0); + self.reflectionLayer.frame = NSMakeRect(18.0, height - 8.0, MAX(24.0, width - 36.0), 16.0); self.layer.shadowPath = [NSBezierPath bezierPathWithRoundedRect:self.bounds xRadius:18.0 yRadius:18.0].CGPath; } diff --git a/src/views/OPNGameCatalogView.mm b/src/views/OPNGameCatalogView.mm index eb0be5a38..9c89409ac 100644 --- a/src/views/OPNGameCatalogView.mm +++ b/src/views/OPNGameCatalogView.mm @@ -83,6 +83,12 @@ @interface OPNGameCatalogView () @property (nonatomic, strong) NSTextField *gameCountLabel; @property (nonatomic, strong) NSTextField *statusLabel; @property (nonatomic, strong) OPNLoadingView *loadingView; +@property (nonatomic, strong) NSImageView *controllerAmbientImageView; +@property (nonatomic, strong) NSVisualEffectView *controllerAmbientBlurView; +@property (nonatomic, strong) CAGradientLayer *controllerAmbientShadeLayer; +@property (nonatomic, strong) CALayer *controllerAmbientOrbLayer; +@property (nonatomic, strong) CALayer *controllerAmbientSecondaryOrbLayer; +@property (nonatomic, copy) NSString *controllerAmbientImageKey; @property (nonatomic, strong) NSView *controllerDetailView; @property (nonatomic, strong) NSTextField *controllerDetailTitleLabel; @property (nonatomic, strong) NSTextField *controllerDetailMetaLabel; @@ -117,6 +123,8 @@ - (void)closeGameDetails; - (void)launchFocusedGame; - (void)cycleFocusedVariant; - (void)updateControllerDetailContent; +- (void)updateControllerAmbientForFocusedGame; +- (void)loadControllerAmbientImageCandidates:(NSArray *)candidates key:(NSString *)key index:(NSUInteger)index; - (void)startGamepadNavigationIfNeeded; - (void)controllerDidConnect:(NSNotification *)notification; - (void)controllerDidDisconnect:(NSNotification *)notification; @@ -155,6 +163,31 @@ static BOOL OPNCatalogGamepadNavigationActive(NSView *view) { return items.count > 0 ? [items componentsJoinedByString:@" / "] : fallback; } +static NSArray *OPNCatalogArtworkURLStrings(const OPN::GameInfo &game) { + NSMutableArray *candidates = [NSMutableArray array]; + std::string steamAppId; + for (const OPN::GameVariant &variant : game.variants) { + NSString *store = [NSString stringWithUTF8String:variant.appStore.c_str()]; + BOOL steamStore = [store.uppercaseString containsString:@"STEAM"]; + BOOL numericId = !variant.id.empty() && variant.id.find_first_not_of("0123456789") == std::string::npos; + if (steamStore && numericId) { + steamAppId = variant.id; + break; + } + } + if (steamAppId.empty() && !game.launchAppId.empty() && game.launchAppId.find_first_not_of("0123456789") == std::string::npos) { + steamAppId = game.launchAppId; + } + if (!steamAppId.empty()) { + [candidates addObject:[NSString stringWithFormat:@"https://cdn.cloudflare.steamstatic.com/steam/apps/%s/library_hero.jpg", steamAppId.c_str()]]; + [candidates addObject:[NSString stringWithFormat:@"https://cdn.cloudflare.steamstatic.com/steam/apps/%s/header.jpg", steamAppId.c_str()]]; + [candidates addObject:[NSString stringWithFormat:@"https://cdn.cloudflare.steamstatic.com/steam/apps/%s/capsule_616x353.jpg", steamAppId.c_str()]]; + } + if (!game.heroImageUrl.empty()) [candidates addObject:[NSString stringWithUTF8String:game.heroImageUrl.c_str()]]; + if (!game.imageUrl.empty()) [candidates addObject:[NSString stringWithUTF8String:game.imageUrl.c_str()]]; + return candidates; +} + @implementation OPNGameCatalogView using namespace OPN; @@ -170,6 +203,52 @@ - (instancetype)initWithFrame:(NSRect)frame { self.wantsLayer = YES; self.layer.backgroundColor = [NSColor clearColor].CGColor; + _controllerAmbientImageView = [[NSImageView alloc] initWithFrame:self.bounds]; + _controllerAmbientImageView.imageScaling = NSImageScaleAxesIndependently; + _controllerAmbientImageView.alphaValue = 0.0; + _controllerAmbientImageView.hidden = YES; + _controllerAmbientImageView.wantsLayer = YES; + _controllerAmbientImageView.layer.masksToBounds = YES; + [self addSubview:_controllerAmbientImageView]; + + _controllerAmbientBlurView = [[NSVisualEffectView alloc] initWithFrame:self.bounds]; + _controllerAmbientBlurView.material = NSVisualEffectMaterialHUDWindow; + _controllerAmbientBlurView.blendingMode = NSVisualEffectBlendingModeWithinWindow; + _controllerAmbientBlurView.state = NSVisualEffectStateActive; + _controllerAmbientBlurView.alphaValue = 0.0; + _controllerAmbientBlurView.hidden = YES; + [self addSubview:_controllerAmbientBlurView]; + + _controllerAmbientShadeLayer = [CAGradientLayer layer]; + _controllerAmbientShadeLayer.colors = @[(id)OpnColor(kBlack, 0.20).CGColor, + (id)OpnColor(kBlack, 0.0).CGColor, + (id)OpnColor(kBlack, 0.38).CGColor]; + _controllerAmbientShadeLayer.locations = @[@0.0, @0.42, @1.0]; + _controllerAmbientShadeLayer.startPoint = CGPointMake(0.0, 0.0); + _controllerAmbientShadeLayer.endPoint = CGPointMake(1.0, 1.0); + _controllerAmbientShadeLayer.hidden = YES; + [self.layer addSublayer:_controllerAmbientShadeLayer]; + + _controllerAmbientOrbLayer = [CALayer layer]; + _controllerAmbientOrbLayer.backgroundColor = OpnColor(kBrandGreen, 0.18).CGColor; + _controllerAmbientOrbLayer.cornerRadius = 220.0; + _controllerAmbientOrbLayer.shadowColor = OpnColor(kBrandGreen).CGColor; + _controllerAmbientOrbLayer.shadowOpacity = 0.58; + _controllerAmbientOrbLayer.shadowRadius = 92.0; + _controllerAmbientOrbLayer.shadowOffset = CGSizeZero; + _controllerAmbientOrbLayer.hidden = YES; + [self.layer addSublayer:_controllerAmbientOrbLayer]; + + _controllerAmbientSecondaryOrbLayer = [CALayer layer]; + _controllerAmbientSecondaryOrbLayer.backgroundColor = OpnColor(0xFFFFFF, 0.075).CGColor; + _controllerAmbientSecondaryOrbLayer.cornerRadius = 160.0; + _controllerAmbientSecondaryOrbLayer.shadowColor = OpnColor(0xFFFFFF, 0.38).CGColor; + _controllerAmbientSecondaryOrbLayer.shadowOpacity = 0.35; + _controllerAmbientSecondaryOrbLayer.shadowRadius = 78.0; + _controllerAmbientSecondaryOrbLayer.shadowOffset = CGSizeZero; + _controllerAmbientSecondaryOrbLayer.hidden = YES; + [self.layer addSublayer:_controllerAmbientSecondaryOrbLayer]; + _libraryIconLabel = OpnLabel(@"", NSMakeRect(30, kNavHeight + 36, 0, 0), 1, OpnColor(kBrandGreen), NSFontWeightBold); _libraryIconLabel.hidden = YES; @@ -288,8 +367,8 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailView.wantsLayer = YES; _controllerDetailView.layer.cornerRadius = 26.0; _controllerDetailView.layer.borderWidth = 1.0; - _controllerDetailView.layer.borderColor = OpnColor(kBrandGreen, 0.30).CGColor; - _controllerDetailView.layer.backgroundColor = OpnColor(0x07090F, 0.46).CGColor; + _controllerDetailView.layer.borderColor = OpnColor(kBrandGreen, 0.24).CGColor; + _controllerDetailView.layer.backgroundColor = OpnColor(0x07090F, 0.20).CGColor; _controllerDetailView.layer.shadowColor = OpnColor(kBrandGreen).CGColor; _controllerDetailView.layer.shadowOpacity = 0.34; _controllerDetailView.layer.shadowRadius = 48.0; @@ -300,8 +379,8 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailView.layer.transform = detailTransform; _controllerDetailGradientLayer = [CAGradientLayer layer]; - _controllerDetailGradientLayer.colors = @[(id)OpnColor(kBrandGreen, 0.24).CGColor, - (id)OpnColor(0xFFFFFF, 0.070).CGColor, + _controllerDetailGradientLayer.colors = @[(id)OpnColor(kBrandGreen, 0.11).CGColor, + (id)OpnColor(0xFFFFFF, 0.028).CGColor, (id)OpnColor(kBlack, 0.0).CGColor]; _controllerDetailGradientLayer.locations = @[@0.0, @0.44, @1.0]; _controllerDetailGradientLayer.startPoint = CGPointMake(0.0, 0.0); @@ -642,6 +721,21 @@ - (void)layoutCatalogSubviews { CGFloat width = NSWidth(self.bounds); CGFloat height = NSHeight(self.bounds); BOOL controllerMode = OpnControllerModeEnabled(); + self.controllerAmbientImageView.hidden = !controllerMode || self.cardViews.count == 0; + self.controllerAmbientBlurView.hidden = self.controllerAmbientImageView.hidden; + self.controllerAmbientShadeLayer.hidden = self.controllerAmbientImageView.hidden; + self.controllerAmbientOrbLayer.hidden = YES; + self.controllerAmbientSecondaryOrbLayer.hidden = YES; + CGFloat controllerNavHeight = 136.0; + NSRect ambientFrame = controllerMode + ? NSMakeRect(0.0, controllerNavHeight, width, MAX(0.0, height - controllerNavHeight)) + : self.bounds; + CGFloat ambientBleed = 120.0; + self.controllerAmbientImageView.frame = NSInsetRect(ambientFrame, -ambientBleed, -ambientBleed); + self.controllerAmbientBlurView.frame = ambientFrame; + self.controllerAmbientShadeLayer.frame = ambientFrame; + self.controllerAmbientOrbLayer.frame = NSMakeRect(width * 0.58, height * 0.10, 440.0, 440.0); + self.controllerAmbientSecondaryOrbLayer.frame = NSMakeRect(-120.0, height * 0.38, 320.0, 320.0); self.scrollView.hasVerticalScroller = !controllerMode; self.scrollView.hasHorizontalScroller = NO; BOOL compact = width < 900.0; @@ -663,7 +757,6 @@ - (void)layoutCatalogSubviews { CGFloat cardHeight = [OPNGameCardView cardSize].height; CGFloat minimumDetailHeight = 176.0; CGFloat desiredCarouselHeight = cardHeight + 86.0; - CGFloat controllerNavHeight = 136.0; CGFloat detailY = (controllerMode ? controllerNavHeight : kNavHeight) + 22.0; CGFloat bottomInset = 56.0; CGFloat detailGap = 24.0; @@ -738,8 +831,11 @@ - (void)focusCardAtIndex:(NSInteger)index scrollIntoView:(BOOL)scrollIntoView { NSInteger clamped = MAX(0, MIN(index, (NSInteger)self.cardViews.count - 1)); self.focusedCardIndex = clamped; for (NSUInteger i = 0; i < self.cardViews.count; i++) { - self.cardViews[i].controllerFocused = OpnControllerModeEnabled() && (NSInteger)i == clamped; + BOOL selected = OpnControllerModeEnabled() && (NSInteger)i == clamped; + self.cardViews[i].controllerFocused = selected; + self.cardViews[i].alphaValue = OpnControllerModeEnabled() && !selected ? 0.72 : 1.0; } + [self updateControllerAmbientForFocusedGame]; [self updateControllerDetailContent]; if (!scrollIntoView) return; OPNGameCardView *card = self.cardViews[(NSUInteger)clamped]; @@ -770,6 +866,97 @@ - (void)cycleFocusedVariant { [self updateControllerDetailContent]; } +- (void)startControllerAmbientMotion { + if (!OpnControllerModeEnabled()) return; + if (self.controllerAmbientOrbLayer.hidden && self.controllerAmbientSecondaryOrbLayer.hidden) return; + if ([self.controllerAmbientOrbLayer animationForKey:@"opn.ambient.drift"]) return; + + CABasicAnimation *primaryDrift = [CABasicAnimation animationWithKeyPath:@"transform.translation.x"]; + primaryDrift.fromValue = @(-26.0); + primaryDrift.toValue = @(30.0); + primaryDrift.duration = 7.5; + primaryDrift.autoreverses = YES; + primaryDrift.repeatCount = HUGE_VALF; + primaryDrift.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + [self.controllerAmbientOrbLayer addAnimation:primaryDrift forKey:@"opn.ambient.drift"]; + + CABasicAnimation *secondaryDrift = [CABasicAnimation animationWithKeyPath:@"transform.translation.y"]; + secondaryDrift.fromValue = @(20.0); + secondaryDrift.toValue = @(-24.0); + secondaryDrift.duration = 9.0; + secondaryDrift.autoreverses = YES; + secondaryDrift.repeatCount = HUGE_VALF; + secondaryDrift.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + [self.controllerAmbientSecondaryOrbLayer addAnimation:secondaryDrift forKey:@"opn.ambient.secondaryDrift"]; +} + +- (void)updateControllerAmbientForFocusedGame { + if (!OpnControllerModeEnabled()) { + self.controllerAmbientImageKey = nil; + self.controllerAmbientImageView.image = nil; + self.controllerAmbientImageView.alphaValue = 0.0; + return; + } + + OPNGameCardView *card = [self focusedCard]; + if (!card) return; + + NSArray *candidates = OPNCatalogArtworkURLStrings(card.game); + NSString *key = [candidates componentsJoinedByString:@"|"]; + if (key.length == 0 || [key isEqualToString:self.controllerAmbientImageKey]) { + [self startControllerAmbientMotion]; + return; + } + self.controllerAmbientImageKey = key; + self.controllerAmbientImageView.alphaValue = 0.0; + [self loadControllerAmbientImageCandidates:candidates key:key index:0]; +} + +- (void)loadControllerAmbientImageCandidates:(NSArray *)candidates key:(NSString *)key index:(NSUInteger)index { + if (index >= candidates.count || ![key isEqualToString:self.controllerAmbientImageKey]) return; + + NSString *urlString = candidates[index]; + NSURL *url = [NSURL URLWithString:urlString]; + if (!url) { + [self loadControllerAmbientImageCandidates:candidates key:key index:index + 1]; + return; + } + + __weak __typeof__(self) weakSelf = self; + NSURLSessionDataTask *task = [NSURLSession.sharedSession dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + NSHTTPURLResponse *http = [response isKindOfClass:NSHTTPURLResponse.class] ? (NSHTTPURLResponse *)response : nil; + if (error || !data || (http && http.statusCode >= 400)) { + dispatch_async(dispatch_get_main_queue(), ^{ + __typeof__(self) strongSelf = weakSelf; + if (!strongSelf) return; + [strongSelf loadControllerAmbientImageCandidates:candidates key:key index:index + 1]; + }); + return; + } + NSImage *image = [[NSImage alloc] initWithData:data]; + if (!image) { + dispatch_async(dispatch_get_main_queue(), ^{ + __typeof__(self) strongSelf = weakSelf; + if (!strongSelf) return; + [strongSelf loadControllerAmbientImageCandidates:candidates key:key index:index + 1]; + }); + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + __typeof__(self) strongSelf = weakSelf; + if (!strongSelf || ![strongSelf.controllerAmbientImageKey isEqualToString:key]) return; + strongSelf.controllerAmbientImageView.image = image; + [NSAnimationContext runAnimationGroup:^(NSAnimationContext *context) { + context.duration = 0.28; + context.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + strongSelf.controllerAmbientImageView.animator.alphaValue = 0.86; + } completionHandler:nil]; + [strongSelf startControllerAmbientMotion]; + }); + }]; + [task resume]; +} + - (void)updateControllerDetailContent { if (!OpnControllerModeEnabled()) return; OPNGameCardView *card = [self focusedCard]; From e6657f45d81aa7482d54ef1365817b60f4b9b5cf Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Mon, 11 May 2026 23:44:05 -0500 Subject: [PATCH 11/18] Add controller mode effects --- src/common/OPNUIHelpers.h | 9 + src/common/OPNUIHelpers.mm | 112 +++++++ src/streaming/OPNStreamViewController.mm | 12 +- src/views/OPNBackdropView.mm | 11 + src/views/OPNGameCatalogView.mm | 362 +++++++++++------------ 5 files changed, 314 insertions(+), 192 deletions(-) diff --git a/src/common/OPNUIHelpers.h b/src/common/OPNUIHelpers.h index dfc930ebc..05a57cf48 100644 --- a/src/common/OPNUIHelpers.h +++ b/src/common/OPNUIHelpers.h @@ -15,6 +15,15 @@ void OpnSetAutoFullScreenEnabled(BOOL enabled); BOOL OpnControllerModeEnabled(void); void OpnSetControllerModeEnabled(BOOL enabled); +typedef NS_ENUM(NSInteger, OPNConsoleTone) { + OPNConsoleToneMove = 0, + OPNConsoleToneSelect = 1, + OPNConsoleToneChange = 2, + OPNConsoleToneBack = 3, +}; + +void OpnPlayConsoleTone(OPNConsoleTone tone); + NSDictionary *OpnTextStyle(CGFloat size, NSColor *color, NSFontWeight weight = NSFontWeightRegular); diff --git a/src/common/OPNUIHelpers.mm b/src/common/OPNUIHelpers.mm index 776de8bd1..301683072 100644 --- a/src/common/OPNUIHelpers.mm +++ b/src/common/OPNUIHelpers.mm @@ -1,5 +1,6 @@ #import "OPNUIHelpers.h" #import "OPNColorTokens.h" +#import #include NSString *const OPNInterfacePreferencesDidChangeNotification = @"OpenNOW.InterfacePreferencesDidChange"; @@ -88,6 +89,117 @@ void OpnSetControllerModeEnabled(BOOL enabled) { [NSNotificationCenter.defaultCenter postNotificationName:OPNInterfacePreferencesDidChangeNotification object:nil]; } +static void OPNAppendLittleEndianUInt16(NSMutableData *data, uint16_t value) { + uint16_t little = CFSwapInt16HostToLittle(value); + [data appendBytes:&little length:sizeof(little)]; +} + +static void OPNAppendLittleEndianUInt32(NSMutableData *data, uint32_t value) { + uint32_t little = CFSwapInt32HostToLittle(value); + [data appendBytes:&little length:sizeof(little)]; +} + +static NSData *OPNConsoleToneWAVData(OPNConsoleTone tone) { + static NSMutableDictionary *cache; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + cache = [NSMutableDictionary dictionary]; + }); + + NSNumber *key = @(tone); + NSData *cached = cache[key]; + if (cached) return cached; + + const uint32_t sampleRate = 44100; + double duration = 0.070; + double primaryFrequency = 660.0; + double secondaryFrequency = 990.0; + double volume = 0.22; + switch (tone) { + case OPNConsoleToneMove: + duration = 0.052; + primaryFrequency = 720.0; + secondaryFrequency = 1080.0; + volume = 0.17; + break; + case OPNConsoleToneSelect: + duration = 0.105; + primaryFrequency = 620.0; + secondaryFrequency = 1240.0; + volume = 0.23; + break; + case OPNConsoleToneChange: + duration = 0.090; + primaryFrequency = 880.0; + secondaryFrequency = 1320.0; + volume = 0.20; + break; + case OPNConsoleToneBack: + duration = 0.080; + primaryFrequency = 440.0; + secondaryFrequency = 330.0; + volume = 0.18; + break; + } + + const uint16_t channels = 1; + const uint16_t bitsPerSample = 16; + const uint32_t frameCount = (uint32_t)std::round(duration * sampleRate); + const uint32_t dataByteCount = frameCount * channels * (bitsPerSample / 8); + NSMutableData *data = [NSMutableData dataWithCapacity:44 + dataByteCount]; + + [data appendBytes:"RIFF" length:4]; + OPNAppendLittleEndianUInt32(data, 36 + dataByteCount); + [data appendBytes:"WAVE" length:4]; + [data appendBytes:"fmt " length:4]; + OPNAppendLittleEndianUInt32(data, 16); + OPNAppendLittleEndianUInt16(data, 1); + OPNAppendLittleEndianUInt16(data, channels); + OPNAppendLittleEndianUInt32(data, sampleRate); + OPNAppendLittleEndianUInt32(data, sampleRate * channels * (bitsPerSample / 8)); + OPNAppendLittleEndianUInt16(data, channels * (bitsPerSample / 8)); + OPNAppendLittleEndianUInt16(data, bitsPerSample); + [data appendBytes:"data" length:4]; + OPNAppendLittleEndianUInt32(data, dataByteCount); + + for (uint32_t frame = 0; frame < frameCount; frame++) { + double t = (double)frame / (double)sampleRate; + double progress = (double)frame / (double)MAX(1u, frameCount - 1); + double attack = MIN(1.0, progress / 0.10); + double release = MIN(1.0, (1.0 - progress) / 0.42); + double envelope = attack * release; + double bend = 1.0 + (tone == OPNConsoleToneBack ? -0.18 : 0.10) * (1.0 - progress); + double sample = sin(2.0 * M_PI * primaryFrequency * bend * t) * 0.68; + sample += sin(2.0 * M_PI * secondaryFrequency * t) * 0.24; + sample += sin(2.0 * M_PI * primaryFrequency * 2.0 * t) * 0.08; + int16_t pcm = (int16_t)std::round(MAX(-1.0, MIN(1.0, sample * envelope * volume)) * 32767.0); + OPNAppendLittleEndianUInt16(data, (uint16_t)pcm); + } + + NSData *immutableData = [data copy]; + cache[key] = immutableData; + return immutableData; +} + +void OpnPlayConsoleTone(OPNConsoleTone tone) { + static NSMutableArray *activePlayers; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + activePlayers = [NSMutableArray array]; + }); + + NSError *error = nil; + AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:OPNConsoleToneWAVData(tone) error:&error]; + if (!player || error) return; + player.volume = 0.85; + [player prepareToPlay]; + [activePlayers addObject:player]; + [player play]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)((player.duration + 0.25) * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [activePlayers removeObject:player]; + }); +} + static unsigned OpnResolvedInterfaceColor(unsigned rgb) { unsigned accent = OpnCurrentAccentRGB(); switch (rgb) { diff --git a/src/streaming/OPNStreamViewController.mm b/src/streaming/OPNStreamViewController.mm index 941bb38e0..576a6dda0 100644 --- a/src/streaming/OPNStreamViewController.mm +++ b/src/streaming/OPNStreamViewController.mm @@ -569,7 +569,7 @@ @implementation OPNStatsOverlayView { static NSAttributedString *OPNStatsOutlinedLine(NSString *text) { NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; style.alignment = NSTextAlignmentCenter; - style.lineBreakMode = NSLineBreakByWordWrapping; + style.lineBreakMode = NSLineBreakByTruncatingMiddle; return [[NSAttributedString alloc] initWithString:text ?: @"" attributes:@{ NSFontAttributeName: [NSFont monospacedSystemFontOfSize:12.0 weight:NSFontWeightSemibold], @@ -617,8 +617,8 @@ - (instancetype)initWithFrame:(NSRect)frame { [self.layer addSublayer:_textTintLayer]; _statsLineLabel = OPNStatsText(@"", 12.0, NSFontWeightSemibold, NSColor.clearColor, NSTextAlignmentCenter); - _statsLineLabel.lineBreakMode = NSLineBreakByWordWrapping; - _statsLineLabel.maximumNumberOfLines = 0; + _statsLineLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; + _statsLineLabel.maximumNumberOfLines = 1; _statsLineLabel.attributedStringValue = OPNStatsOutlinedLine(@"Stats: measuring"); [self addSubview:_statsLineLabel]; } @@ -634,8 +634,8 @@ - (NSView *)hitTest:(NSPoint)point { - (void)layout { [super layout]; - _textTintLayer.frame = NSInsetRect(self.bounds, 4.0, 2.0); - _statsLineLabel.frame = NSInsetRect(self.bounds, 10.0, 5.0); + _textTintLayer.frame = NSInsetRect(self.bounds, 4.0, 1.0); + _statsLineLabel.frame = NSInsetRect(self.bounds, 10.0, 2.0); } - (void)updateLatencyMs:(NSInteger)latencyMs @@ -985,7 +985,7 @@ - (void)installQuitShortcutMonitor { - (NSRect)statsOverlayFrame { CGFloat width = MAX(0.0, NSWidth(self.view.bounds) - 32.0); - CGFloat height = 56.0; + CGFloat height = 26.0; return NSMakeRect(16.0, floor(NSHeight(self.view.bounds) - height - 10.0), width, diff --git a/src/views/OPNBackdropView.mm b/src/views/OPNBackdropView.mm index 21846a8dc..b706d2656 100644 --- a/src/views/OPNBackdropView.mm +++ b/src/views/OPNBackdropView.mm @@ -124,12 +124,14 @@ - (uint16_t)currentControllerNavigationButtons { - (void)selectPreviousControllerTab { if (self.mode == OPNBackdropModeSettings) { + OpnPlayConsoleTone(OPNConsoleToneMove); if (self.onLibrarySelected) self.onLibrarySelected(); } } - (void)selectNextControllerTab { if (self.mode == OPNBackdropModeLibrary) { + OpnPlayConsoleTone(OPNConsoleToneMove); if (self.onSettingsSelected) self.onSettingsSelected(); } } @@ -431,16 +433,19 @@ - (void)drawRect:(NSRect)dirtyRect { - (void)storeButtonPressed:(id)sender { (void)sender; + if (OpnControllerModeEnabled()) OpnPlayConsoleTone(OPNConsoleToneSelect); if (self.onStoreSelected) self.onStoreSelected(); } - (void)libraryButtonPressed:(id)sender { (void)sender; + if (OpnControllerModeEnabled()) OpnPlayConsoleTone(OPNConsoleToneSelect); if (self.onLibrarySelected) self.onLibrarySelected(); } - (void)settingsButtonPressed:(id)sender { (void)sender; + if (OpnControllerModeEnabled()) OpnPlayConsoleTone(OPNConsoleToneSelect); if (self.onSettingsSelected) self.onSettingsSelected(); } @@ -480,9 +485,11 @@ - (NSButton *)controllerAccountMenuButtonWithTitle:(NSString *)title - (void)showControllerAccountMenu { if (_controllerAccountMenuView) { + OpnPlayConsoleTone(OPNConsoleToneBack); [self dismissControllerAccountMenu]; return; } + OpnPlayConsoleTone(OPNConsoleToneSelect); CGFloat menuWidth = 320.0; CGFloat rowHeight = 42.0; @@ -607,24 +614,28 @@ - (void)accountButtonPressed:(id)sender { - (void)controllerAccountMenuItemPressed:(NSButton *)sender { NSString *identifier = sender.identifier; + OpnPlayConsoleTone(OPNConsoleToneSelect); [self dismissControllerAccountMenu]; if (identifier.length > 0 && self.onAccountSelected) self.onAccountSelected(identifier); } - (void)controllerAddAccountPressed:(id)sender { (void)sender; + OpnPlayConsoleTone(OPNConsoleToneSelect); [self dismissControllerAccountMenu]; if (self.onAddAccountSelected) self.onAddAccountSelected(); } - (void)controllerSignOutPressed:(id)sender { (void)sender; + OpnPlayConsoleTone(OPNConsoleToneBack); [self dismissControllerAccountMenu]; if (self.onSignOutSelected) self.onSignOutSelected(); } - (void)controllerExitPressed:(id)sender { (void)sender; + OpnPlayConsoleTone(OPNConsoleToneBack); [self dismissControllerAccountMenu]; if (self.onExitSelected) self.onExitSelected(); } diff --git a/src/views/OPNGameCatalogView.mm b/src/views/OPNGameCatalogView.mm index 9c89409ac..8f6a02109 100644 --- a/src/views/OPNGameCatalogView.mm +++ b/src/views/OPNGameCatalogView.mm @@ -70,6 +70,166 @@ - (void)selectWithFrame:(NSRect)rect @end +@interface OPNControllerElectricBackgroundView : NSView +@end + +@implementation OPNControllerElectricBackgroundView { + NSTimer *_animationTimer; + CGFloat _phase; +} + +- (instancetype)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + self.wantsLayer = YES; + _animationTimer = [NSTimer scheduledTimerWithTimeInterval:(1.0 / 24.0) + target:self + selector:@selector(animationTick:) + userInfo:nil + repeats:YES]; + } + return self; +} + +- (void)dealloc { + [_animationTimer invalidate]; +} + +- (BOOL)isFlipped { return YES; } + +- (void)animationTick:(NSTimer *)timer { + (void)timer; + _phase = fmod(_phase + (1.0 / 144.0), 1.0); + self.needsDisplay = YES; +} + +- (CGFloat)unitHashForSeed:(NSUInteger)seed index:(NSUInteger)index { + uint32_t value = (uint32_t)(seed * 1103515245u + index * 12345u + 0x9E3779B9u); + value ^= value >> 16; + value *= 0x7FEB352Du; + value ^= value >> 15; + return (CGFloat)(value % 10000u) / 10000.0; +} + +- (NSPoint)pointOnBoltFrom:(NSPoint)start to:(NSPoint)end t:(CGFloat)t seed:(NSUInteger)seed { + CGFloat dx = end.x - start.x; + CGFloat dy = end.y - start.y; + CGFloat normalX = -dy; + CGFloat normalY = dx; + CGFloat normalLength = MAX(1.0, hypot(normalX, normalY)); + normalX /= normalLength; + normalY /= normalLength; + NSUInteger bucket = (NSUInteger)floor(t * 18.0); + CGFloat hash = [self unitHashForSeed:seed index:bucket + 3]; + CGFloat envelope = sin(t * 3.14159); + CGFloat offset = (hash - 0.5) * 58.0 * envelope; + return NSMakePoint(start.x + dx * t + normalX * offset, + start.y + dy * t + normalY * offset); +} + +- (void)drawBoltFrom:(NSPoint)start to:(NSPoint)end branches:(NSInteger)branches seed:(NSUInteger)seed alpha:(CGFloat)alpha width:(CGFloat)width { + CGFloat pulse = 0.72 + 0.28 * sin((_phase + (CGFloat)(seed % 17) / 17.0) * 6.28318530718); + CGFloat effectiveAlpha = alpha * pulse; + NSUInteger segments = 10 + (seed % 5); + NSBezierPath *path = [NSBezierPath bezierPath]; + [path moveToPoint:start]; + for (NSUInteger i = 1; i < segments; i++) { + CGFloat t = (CGFloat)i / (CGFloat)segments; + [path lineToPoint:[self pointOnBoltFrom:start to:end t:t seed:seed]]; + } + [path lineToPoint:end]; + + [OpnColor(OPN::kBrandGreen, effectiveAlpha * 0.24) setStroke]; + path.lineWidth = width + 8.0; + [path stroke]; + [OpnColor(0xFFFFFF, effectiveAlpha * 0.72) setStroke]; + path.lineWidth = width + 1.2; + [path stroke]; + [OpnColor(OPN::kBrandGreen, effectiveAlpha) setStroke]; + path.lineWidth = MAX(0.7, width * 0.34); + [path stroke]; + + for (NSInteger b = 0; b < branches; b++) { + CGFloat t = 0.16 + 0.68 * [self unitHashForSeed:seed index:(NSUInteger)b + 41]; + NSPoint branchStart = [self pointOnBoltFrom:start to:end t:t seed:seed]; + CGFloat angle = atan2(end.y - start.y, end.x - start.x); + CGFloat forkDirection = ([self unitHashForSeed:seed index:(NSUInteger)b + 73] > 0.5) ? 1.0 : -1.0; + CGFloat forkAngle = angle + forkDirection * (0.62 + [self unitHashForSeed:seed index:(NSUInteger)b + 97] * 0.55); + CGFloat branchLength = 48.0 + [self unitHashForSeed:seed index:(NSUInteger)b + 113] * 86.0; + NSPoint branchMid = NSMakePoint(branchStart.x + cos(forkAngle) * branchLength * 0.52, + branchStart.y + sin(forkAngle) * branchLength * 0.52); + NSPoint branchEnd = NSMakePoint(branchStart.x + cos(forkAngle) * branchLength, + branchStart.y + sin(forkAngle) * branchLength); + NSBezierPath *branch = [NSBezierPath bezierPath]; + [branch moveToPoint:branchStart]; + [branch lineToPoint:branchMid]; + [branch lineToPoint:branchEnd]; + [OpnColor(0xFFFFFF, effectiveAlpha * 0.34) setStroke]; + branch.lineWidth = MAX(0.7, width * 0.30); + [branch stroke]; + [OpnColor(OPN::kBrandGreen, effectiveAlpha * 0.48) setStroke]; + branch.lineWidth = MAX(0.6, width * 0.35); + [branch stroke]; + } +} + +- (void)drawRect:(NSRect)dirtyRect { + (void)dirtyRect; + NSRect bounds = self.bounds; + NSGradient *base = [[NSGradient alloc] initWithStartingColor:OpnColor(0x05070D, 0.96) + endingColor:OpnColor(0x100817, 0.96)]; + [base drawInRect:bounds angle:90.0]; + + CGFloat width = NSWidth(bounds); + CGFloat height = NSHeight(bounds); + for (NSInteger i = 0; i < 18; i++) { + CGFloat x = fmod((CGFloat)i * 193.0, MAX(1.0, width)); + CGFloat lineAlpha = (i % 3 == 0) ? 0.055 : 0.028; + NSBezierPath *trace = [NSBezierPath bezierPath]; + [trace moveToPoint:NSMakePoint(x, 0.0)]; + [trace lineToPoint:NSMakePoint(x + 120.0, height)]; + [OpnColor(OPN::kBrandGreen, lineAlpha) setStroke]; + trace.lineWidth = 1.0; + [trace stroke]; + } + + for (NSInteger i = 0; i < 8; i++) { + CGFloat y = 34.0 + (CGFloat)i * MAX(46.0, height / 8.5); + NSBezierPath *scan = [NSBezierPath bezierPath]; + [scan moveToPoint:NSMakePoint(0.0, y + sin(_phase * 6.283 + i) * 9.0)]; + [scan lineToPoint:NSMakePoint(width, y + cos(_phase * 6.283 + i) * 9.0)]; + [OpnColor(0xFFFFFF, 0.026) setStroke]; + scan.lineWidth = 1.0; + [scan stroke]; + } + + [self drawBoltFrom:NSMakePoint(width * 0.05, height * 0.78) + to:NSMakePoint(width * 0.42, height * 0.16) + branches:10 seed:11 alpha:0.42 width:2.2]; + [self drawBoltFrom:NSMakePoint(width * 0.52, height * 0.92) + to:NSMakePoint(width * 0.94, height * 0.22) + branches:11 seed:47 alpha:0.34 width:1.8]; + [self drawBoltFrom:NSMakePoint(width * 0.18, height * 0.50) + to:NSMakePoint(width * 0.76, height * 0.58) + branches:7 seed:83 alpha:0.24 width:1.4]; + + for (NSInteger i = 0; i < 90; i++) { + CGFloat x = fmod((CGFloat)(i * 97), MAX(1.0, width)); + CGFloat y = fmod((CGFloat)(i * 43), MAX(1.0, height)); + CGFloat radius = 0.8 + (CGFloat)(i % 4) * 0.45; + NSBezierPath *spark = [NSBezierPath bezierPathWithOvalInRect:NSMakeRect(x, y, radius, radius)]; + CGFloat sparkPulse = 0.52 + 0.48 * sin((_phase + (CGFloat)(i % 29) / 29.0) * 6.28318530718); + [OpnColor(i % 5 == 0 ? 0xFFFFFF : OPN::kBrandGreen, (i % 5 == 0 ? 0.16 : 0.11) * sparkPulse) setFill]; + [spark fill]; + } + + NSGradient *vignette = [[NSGradient alloc] initWithStartingColor:OpnColor(OPN::kBlack, 0.0) + endingColor:OpnColor(OPN::kBlack, 0.48)]; + [vignette drawInRect:bounds angle:-90.0]; +} + +@end + @interface OPNGameCatalogView () @property (nonatomic, strong) NSScrollView *scrollView; @property (nonatomic, strong) NSView *gridContentView; @@ -83,12 +243,7 @@ @interface OPNGameCatalogView () @property (nonatomic, strong) NSTextField *gameCountLabel; @property (nonatomic, strong) NSTextField *statusLabel; @property (nonatomic, strong) OPNLoadingView *loadingView; -@property (nonatomic, strong) NSImageView *controllerAmbientImageView; -@property (nonatomic, strong) NSVisualEffectView *controllerAmbientBlurView; -@property (nonatomic, strong) CAGradientLayer *controllerAmbientShadeLayer; -@property (nonatomic, strong) CALayer *controllerAmbientOrbLayer; -@property (nonatomic, strong) CALayer *controllerAmbientSecondaryOrbLayer; -@property (nonatomic, copy) NSString *controllerAmbientImageKey; +@property (nonatomic, strong) OPNControllerElectricBackgroundView *controllerElectricBackgroundView; @property (nonatomic, strong) NSView *controllerDetailView; @property (nonatomic, strong) NSTextField *controllerDetailTitleLabel; @property (nonatomic, strong) NSTextField *controllerDetailMetaLabel; @@ -123,8 +278,6 @@ - (void)closeGameDetails; - (void)launchFocusedGame; - (void)cycleFocusedVariant; - (void)updateControllerDetailContent; -- (void)updateControllerAmbientForFocusedGame; -- (void)loadControllerAmbientImageCandidates:(NSArray *)candidates key:(NSString *)key index:(NSUInteger)index; - (void)startGamepadNavigationIfNeeded; - (void)controllerDidConnect:(NSNotification *)notification; - (void)controllerDidDisconnect:(NSNotification *)notification; @@ -163,31 +316,6 @@ static BOOL OPNCatalogGamepadNavigationActive(NSView *view) { return items.count > 0 ? [items componentsJoinedByString:@" / "] : fallback; } -static NSArray *OPNCatalogArtworkURLStrings(const OPN::GameInfo &game) { - NSMutableArray *candidates = [NSMutableArray array]; - std::string steamAppId; - for (const OPN::GameVariant &variant : game.variants) { - NSString *store = [NSString stringWithUTF8String:variant.appStore.c_str()]; - BOOL steamStore = [store.uppercaseString containsString:@"STEAM"]; - BOOL numericId = !variant.id.empty() && variant.id.find_first_not_of("0123456789") == std::string::npos; - if (steamStore && numericId) { - steamAppId = variant.id; - break; - } - } - if (steamAppId.empty() && !game.launchAppId.empty() && game.launchAppId.find_first_not_of("0123456789") == std::string::npos) { - steamAppId = game.launchAppId; - } - if (!steamAppId.empty()) { - [candidates addObject:[NSString stringWithFormat:@"https://cdn.cloudflare.steamstatic.com/steam/apps/%s/library_hero.jpg", steamAppId.c_str()]]; - [candidates addObject:[NSString stringWithFormat:@"https://cdn.cloudflare.steamstatic.com/steam/apps/%s/header.jpg", steamAppId.c_str()]]; - [candidates addObject:[NSString stringWithFormat:@"https://cdn.cloudflare.steamstatic.com/steam/apps/%s/capsule_616x353.jpg", steamAppId.c_str()]]; - } - if (!game.heroImageUrl.empty()) [candidates addObject:[NSString stringWithUTF8String:game.heroImageUrl.c_str()]]; - if (!game.imageUrl.empty()) [candidates addObject:[NSString stringWithUTF8String:game.imageUrl.c_str()]]; - return candidates; -} - @implementation OPNGameCatalogView using namespace OPN; @@ -203,51 +331,9 @@ - (instancetype)initWithFrame:(NSRect)frame { self.wantsLayer = YES; self.layer.backgroundColor = [NSColor clearColor].CGColor; - _controllerAmbientImageView = [[NSImageView alloc] initWithFrame:self.bounds]; - _controllerAmbientImageView.imageScaling = NSImageScaleAxesIndependently; - _controllerAmbientImageView.alphaValue = 0.0; - _controllerAmbientImageView.hidden = YES; - _controllerAmbientImageView.wantsLayer = YES; - _controllerAmbientImageView.layer.masksToBounds = YES; - [self addSubview:_controllerAmbientImageView]; - - _controllerAmbientBlurView = [[NSVisualEffectView alloc] initWithFrame:self.bounds]; - _controllerAmbientBlurView.material = NSVisualEffectMaterialHUDWindow; - _controllerAmbientBlurView.blendingMode = NSVisualEffectBlendingModeWithinWindow; - _controllerAmbientBlurView.state = NSVisualEffectStateActive; - _controllerAmbientBlurView.alphaValue = 0.0; - _controllerAmbientBlurView.hidden = YES; - [self addSubview:_controllerAmbientBlurView]; - - _controllerAmbientShadeLayer = [CAGradientLayer layer]; - _controllerAmbientShadeLayer.colors = @[(id)OpnColor(kBlack, 0.20).CGColor, - (id)OpnColor(kBlack, 0.0).CGColor, - (id)OpnColor(kBlack, 0.38).CGColor]; - _controllerAmbientShadeLayer.locations = @[@0.0, @0.42, @1.0]; - _controllerAmbientShadeLayer.startPoint = CGPointMake(0.0, 0.0); - _controllerAmbientShadeLayer.endPoint = CGPointMake(1.0, 1.0); - _controllerAmbientShadeLayer.hidden = YES; - [self.layer addSublayer:_controllerAmbientShadeLayer]; - - _controllerAmbientOrbLayer = [CALayer layer]; - _controllerAmbientOrbLayer.backgroundColor = OpnColor(kBrandGreen, 0.18).CGColor; - _controllerAmbientOrbLayer.cornerRadius = 220.0; - _controllerAmbientOrbLayer.shadowColor = OpnColor(kBrandGreen).CGColor; - _controllerAmbientOrbLayer.shadowOpacity = 0.58; - _controllerAmbientOrbLayer.shadowRadius = 92.0; - _controllerAmbientOrbLayer.shadowOffset = CGSizeZero; - _controllerAmbientOrbLayer.hidden = YES; - [self.layer addSublayer:_controllerAmbientOrbLayer]; - - _controllerAmbientSecondaryOrbLayer = [CALayer layer]; - _controllerAmbientSecondaryOrbLayer.backgroundColor = OpnColor(0xFFFFFF, 0.075).CGColor; - _controllerAmbientSecondaryOrbLayer.cornerRadius = 160.0; - _controllerAmbientSecondaryOrbLayer.shadowColor = OpnColor(0xFFFFFF, 0.38).CGColor; - _controllerAmbientSecondaryOrbLayer.shadowOpacity = 0.35; - _controllerAmbientSecondaryOrbLayer.shadowRadius = 78.0; - _controllerAmbientSecondaryOrbLayer.shadowOffset = CGSizeZero; - _controllerAmbientSecondaryOrbLayer.hidden = YES; - [self.layer addSublayer:_controllerAmbientSecondaryOrbLayer]; + _controllerElectricBackgroundView = [[OPNControllerElectricBackgroundView alloc] initWithFrame:self.bounds]; + _controllerElectricBackgroundView.hidden = YES; + [self addSubview:_controllerElectricBackgroundView]; _libraryIconLabel = OpnLabel(@"", NSMakeRect(30, kNavHeight + 36, 0, 0), 1, OpnColor(kBrandGreen), NSFontWeightBold); @@ -367,8 +453,8 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailView.wantsLayer = YES; _controllerDetailView.layer.cornerRadius = 26.0; _controllerDetailView.layer.borderWidth = 1.0; - _controllerDetailView.layer.borderColor = OpnColor(kBrandGreen, 0.24).CGColor; - _controllerDetailView.layer.backgroundColor = OpnColor(0x07090F, 0.20).CGColor; + _controllerDetailView.layer.borderColor = OpnColor(kBrandGreen, 0.30).CGColor; + _controllerDetailView.layer.backgroundColor = OpnColor(0x07090F, 0.50).CGColor; _controllerDetailView.layer.shadowColor = OpnColor(kBrandGreen).CGColor; _controllerDetailView.layer.shadowOpacity = 0.34; _controllerDetailView.layer.shadowRadius = 48.0; @@ -379,8 +465,8 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailView.layer.transform = detailTransform; _controllerDetailGradientLayer = [CAGradientLayer layer]; - _controllerDetailGradientLayer.colors = @[(id)OpnColor(kBrandGreen, 0.11).CGColor, - (id)OpnColor(0xFFFFFF, 0.028).CGColor, + _controllerDetailGradientLayer.colors = @[(id)OpnColor(kBrandGreen, 0.18).CGColor, + (id)OpnColor(0xFFFFFF, 0.052).CGColor, (id)OpnColor(kBlack, 0.0).CGColor]; _controllerDetailGradientLayer.locations = @[@0.0, @0.44, @1.0]; _controllerDetailGradientLayer.startPoint = CGPointMake(0.0, 0.0); @@ -721,21 +807,11 @@ - (void)layoutCatalogSubviews { CGFloat width = NSWidth(self.bounds); CGFloat height = NSHeight(self.bounds); BOOL controllerMode = OpnControllerModeEnabled(); - self.controllerAmbientImageView.hidden = !controllerMode || self.cardViews.count == 0; - self.controllerAmbientBlurView.hidden = self.controllerAmbientImageView.hidden; - self.controllerAmbientShadeLayer.hidden = self.controllerAmbientImageView.hidden; - self.controllerAmbientOrbLayer.hidden = YES; - self.controllerAmbientSecondaryOrbLayer.hidden = YES; CGFloat controllerNavHeight = 136.0; - NSRect ambientFrame = controllerMode + self.controllerElectricBackgroundView.hidden = !controllerMode || self.cardViews.count == 0; + self.controllerElectricBackgroundView.frame = controllerMode ? NSMakeRect(0.0, controllerNavHeight, width, MAX(0.0, height - controllerNavHeight)) : self.bounds; - CGFloat ambientBleed = 120.0; - self.controllerAmbientImageView.frame = NSInsetRect(ambientFrame, -ambientBleed, -ambientBleed); - self.controllerAmbientBlurView.frame = ambientFrame; - self.controllerAmbientShadeLayer.frame = ambientFrame; - self.controllerAmbientOrbLayer.frame = NSMakeRect(width * 0.58, height * 0.10, 440.0, 440.0); - self.controllerAmbientSecondaryOrbLayer.frame = NSMakeRect(-120.0, height * 0.38, 320.0, 320.0); self.scrollView.hasVerticalScroller = !controllerMode; self.scrollView.hasHorizontalScroller = NO; BOOL compact = width < 900.0; @@ -828,6 +904,7 @@ - (void)focusCardAtIndex:(NSInteger)index scrollIntoView:(BOOL)scrollIntoView { self.focusedCardIndex = -1; return; } + NSInteger previousIndex = self.focusedCardIndex; NSInteger clamped = MAX(0, MIN(index, (NSInteger)self.cardViews.count - 1)); self.focusedCardIndex = clamped; for (NSUInteger i = 0; i < self.cardViews.count; i++) { @@ -835,7 +912,9 @@ - (void)focusCardAtIndex:(NSInteger)index scrollIntoView:(BOOL)scrollIntoView { self.cardViews[i].controllerFocused = selected; self.cardViews[i].alphaValue = OpnControllerModeEnabled() && !selected ? 0.72 : 1.0; } - [self updateControllerAmbientForFocusedGame]; + if (OpnControllerModeEnabled() && scrollIntoView && previousIndex >= 0 && previousIndex != clamped) { + OpnPlayConsoleTone(OPNConsoleToneMove); + } [self updateControllerDetailContent]; if (!scrollIntoView) return; OPNGameCardView *card = self.cardViews[(NSUInteger)clamped]; @@ -863,100 +942,10 @@ - (void)cycleFocusedVariant { if (!card || card.game.variants.size() <= 1) return; int next = (card.selectedVariantIndex + 1) % (int)card.game.variants.size(); [card selectVariantAtIndex:next]; + if (OpnControllerModeEnabled()) OpnPlayConsoleTone(OPNConsoleToneChange); [self updateControllerDetailContent]; } -- (void)startControllerAmbientMotion { - if (!OpnControllerModeEnabled()) return; - if (self.controllerAmbientOrbLayer.hidden && self.controllerAmbientSecondaryOrbLayer.hidden) return; - if ([self.controllerAmbientOrbLayer animationForKey:@"opn.ambient.drift"]) return; - - CABasicAnimation *primaryDrift = [CABasicAnimation animationWithKeyPath:@"transform.translation.x"]; - primaryDrift.fromValue = @(-26.0); - primaryDrift.toValue = @(30.0); - primaryDrift.duration = 7.5; - primaryDrift.autoreverses = YES; - primaryDrift.repeatCount = HUGE_VALF; - primaryDrift.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; - [self.controllerAmbientOrbLayer addAnimation:primaryDrift forKey:@"opn.ambient.drift"]; - - CABasicAnimation *secondaryDrift = [CABasicAnimation animationWithKeyPath:@"transform.translation.y"]; - secondaryDrift.fromValue = @(20.0); - secondaryDrift.toValue = @(-24.0); - secondaryDrift.duration = 9.0; - secondaryDrift.autoreverses = YES; - secondaryDrift.repeatCount = HUGE_VALF; - secondaryDrift.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; - [self.controllerAmbientSecondaryOrbLayer addAnimation:secondaryDrift forKey:@"opn.ambient.secondaryDrift"]; -} - -- (void)updateControllerAmbientForFocusedGame { - if (!OpnControllerModeEnabled()) { - self.controllerAmbientImageKey = nil; - self.controllerAmbientImageView.image = nil; - self.controllerAmbientImageView.alphaValue = 0.0; - return; - } - - OPNGameCardView *card = [self focusedCard]; - if (!card) return; - - NSArray *candidates = OPNCatalogArtworkURLStrings(card.game); - NSString *key = [candidates componentsJoinedByString:@"|"]; - if (key.length == 0 || [key isEqualToString:self.controllerAmbientImageKey]) { - [self startControllerAmbientMotion]; - return; - } - self.controllerAmbientImageKey = key; - self.controllerAmbientImageView.alphaValue = 0.0; - [self loadControllerAmbientImageCandidates:candidates key:key index:0]; -} - -- (void)loadControllerAmbientImageCandidates:(NSArray *)candidates key:(NSString *)key index:(NSUInteger)index { - if (index >= candidates.count || ![key isEqualToString:self.controllerAmbientImageKey]) return; - - NSString *urlString = candidates[index]; - NSURL *url = [NSURL URLWithString:urlString]; - if (!url) { - [self loadControllerAmbientImageCandidates:candidates key:key index:index + 1]; - return; - } - - __weak __typeof__(self) weakSelf = self; - NSURLSessionDataTask *task = [NSURLSession.sharedSession dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { - NSHTTPURLResponse *http = [response isKindOfClass:NSHTTPURLResponse.class] ? (NSHTTPURLResponse *)response : nil; - if (error || !data || (http && http.statusCode >= 400)) { - dispatch_async(dispatch_get_main_queue(), ^{ - __typeof__(self) strongSelf = weakSelf; - if (!strongSelf) return; - [strongSelf loadControllerAmbientImageCandidates:candidates key:key index:index + 1]; - }); - return; - } - NSImage *image = [[NSImage alloc] initWithData:data]; - if (!image) { - dispatch_async(dispatch_get_main_queue(), ^{ - __typeof__(self) strongSelf = weakSelf; - if (!strongSelf) return; - [strongSelf loadControllerAmbientImageCandidates:candidates key:key index:index + 1]; - }); - return; - } - dispatch_async(dispatch_get_main_queue(), ^{ - __typeof__(self) strongSelf = weakSelf; - if (!strongSelf || ![strongSelf.controllerAmbientImageKey isEqualToString:key]) return; - strongSelf.controllerAmbientImageView.image = image; - [NSAnimationContext runAnimationGroup:^(NSAnimationContext *context) { - context.duration = 0.28; - context.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; - strongSelf.controllerAmbientImageView.animator.alphaValue = 0.86; - } completionHandler:nil]; - [strongSelf startControllerAmbientMotion]; - }); - }]; - [task resume]; -} - - (void)updateControllerDetailContent { if (!OpnControllerModeEnabled()) return; OPNGameCardView *card = [self focusedCard]; @@ -1090,6 +1079,7 @@ - (void)closeGameDetails { - (void)launchFocusedGame { OPNGameCardView *card = [self focusedCard]; if (!card || !self.onSelectGame) return; + if (OpnControllerModeEnabled()) OpnPlayConsoleTone(OPNConsoleToneSelect); int variantIdx = card.selectedVariantIndex >= 0 ? card.selectedVariantIndex : 0; self.onSelectGame(card.game, variantIdx); } From 16d45e8fb42e9352c5efd89e3a66f845ba8cfa8b Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Tue, 12 May 2026 00:18:46 -0500 Subject: [PATCH 12/18] Refine controller mode visuals --- src/views/OPNGameCardView.mm | 20 ----------- src/views/OPNGameCatalogView.mm | 59 +++++++++++++++++---------------- 2 files changed, 30 insertions(+), 49 deletions(-) diff --git a/src/views/OPNGameCardView.mm b/src/views/OPNGameCardView.mm index 7c5c46c29..b38a6b7a8 100644 --- a/src/views/OPNGameCardView.mm +++ b/src/views/OPNGameCardView.mm @@ -7,8 +7,6 @@ static const CGFloat gImageHeight = gCardWidth; static const CGFloat gInfoHeight = 0.0; static const CGFloat gCardTotalHeight = gImageHeight + gInfoHeight; -static const CGFloat gGradientOverlayHeight = 132.0; - static CGFloat OPNScaledCardWidth(void) { return floor(gCardWidth * OpnPosterSizeScale()); } @@ -126,7 +124,6 @@ @interface OPNGameCardView () @property (nonatomic, assign) OPN::GameInfo gameData; @property (nonatomic, strong) NSView *contentView; @property (nonatomic, strong) NSImageView *imageView; -@property (nonatomic, strong) NSView *gradientOverlay; @property (nonatomic, strong) NSView *storeChipsContainer; @property (nonatomic, strong) NSTrackingArea *trackingArea; @property (nonatomic, strong) NSButton *playButton; @@ -184,20 +181,6 @@ - (instancetype)initWithFrame:(NSRect)frame game:(const OPN::GameInfo &)game { _imageView.layer.backgroundColor = OpnColor(kBackgroundC).CGColor; [_contentView addSubview:_imageView]; - _gradientOverlay = [[NSView alloc] initWithFrame:NSMakeRect(0, NSHeight(self.bounds) - gGradientOverlayHeight, NSWidth(self.bounds), gGradientOverlayHeight)]; - _gradientOverlay.wantsLayer = YES; - CAGradientLayer *gradient = [CAGradientLayer layer]; - gradient.frame = _gradientOverlay.bounds; - gradient.colors = @[(id)OpnColor(kBlack, 0.0).CGColor, - (id)OpnColor(kBlack, 0.0).CGColor, - (id)OpnColor(kBlack, 0.30).CGColor, - (id)OpnColor(kBlack, 0.86).CGColor]; - gradient.locations = @[@0.0, @0.24, @0.68, @1.0]; - gradient.startPoint = CGPointMake(0.5, 1.0); - gradient.endPoint = CGPointMake(0.5, 0.0); - _gradientOverlay.layer = gradient; - [_contentView addSubview:_gradientOverlay]; - _playButton = [[NSButton alloc] initWithFrame: NSMakeRect((NSWidth(self.bounds) - 46) / 2, (NSHeight(self.bounds) - 46) / 2, 46, 46)]; _playButton.title = @"▶"; @@ -285,12 +268,9 @@ - (void)layout { [super layout]; CGFloat width = NSWidth(self.bounds); CGFloat height = NSHeight(self.bounds); - BOOL controllerMode = OpnControllerModeEnabled(); self.contentView.frame = self.bounds; self.contentView.layer.cornerRadius = 18.0; self.imageView.frame = self.bounds; - self.gradientOverlay.hidden = controllerMode; - self.gradientOverlay.frame = NSMakeRect(0, MAX(0.0, height - gGradientOverlayHeight), width, MIN(gGradientOverlayHeight, height)); self.playButton.frame = NSMakeRect((width - 46.0) / 2.0, (height - 46.0) / 2.0, 46.0, 46.0); self.storeChipsContainer.frame = NSMakeRect(16.0, MAX(0.0, height - 37.0), MAX(40.0, width - 32.0), 24.0); self.reflectionLayer.frame = NSMakeRect(18.0, height - 8.0, MAX(24.0, width - 36.0), 16.0); diff --git a/src/views/OPNGameCatalogView.mm b/src/views/OPNGameCatalogView.mm index 8f6a02109..67aecba6e 100644 --- a/src/views/OPNGameCatalogView.mm +++ b/src/views/OPNGameCatalogView.mm @@ -73,36 +73,18 @@ - (void)selectWithFrame:(NSRect)rect @interface OPNControllerElectricBackgroundView : NSView @end -@implementation OPNControllerElectricBackgroundView { - NSTimer *_animationTimer; - CGFloat _phase; -} +@implementation OPNControllerElectricBackgroundView - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { self.wantsLayer = YES; - _animationTimer = [NSTimer scheduledTimerWithTimeInterval:(1.0 / 24.0) - target:self - selector:@selector(animationTick:) - userInfo:nil - repeats:YES]; } return self; } -- (void)dealloc { - [_animationTimer invalidate]; -} - - (BOOL)isFlipped { return YES; } -- (void)animationTick:(NSTimer *)timer { - (void)timer; - _phase = fmod(_phase + (1.0 / 144.0), 1.0); - self.needsDisplay = YES; -} - - (CGFloat)unitHashForSeed:(NSUInteger)seed index:(NSUInteger)index { uint32_t value = (uint32_t)(seed * 1103515245u + index * 12345u + 0x9E3779B9u); value ^= value >> 16; @@ -127,9 +109,16 @@ - (NSPoint)pointOnBoltFrom:(NSPoint)start to:(NSPoint)end t:(CGFloat)t seed:(NSU start.y + dy * t + normalY * offset); } +- (void)drawStrikeBloomAt:(NSPoint)point radius:(CGFloat)radius intensity:(CGFloat)intensity { + if (intensity <= 0.001) return; + NSRect bloomRect = NSMakeRect(point.x - radius, point.y - radius, radius * 2.0, radius * 2.0); + NSGradient *bloom = [[NSGradient alloc] initWithStartingColor:OpnColor(0xFFFFFF, 0.20 * intensity) + endingColor:OpnColor(OPN::kBrandGreen, 0.0)]; + [bloom drawInBezierPath:[NSBezierPath bezierPathWithOvalInRect:bloomRect] relativeCenterPosition:NSZeroPoint]; +} + - (void)drawBoltFrom:(NSPoint)start to:(NSPoint)end branches:(NSInteger)branches seed:(NSUInteger)seed alpha:(CGFloat)alpha width:(CGFloat)width { - CGFloat pulse = 0.72 + 0.28 * sin((_phase + (CGFloat)(seed % 17) / 17.0) * 6.28318530718); - CGFloat effectiveAlpha = alpha * pulse; + CGFloat effectiveAlpha = alpha; NSUInteger segments = 10 + (seed % 5); NSBezierPath *path = [NSBezierPath bezierPath]; [path moveToPoint:start]; @@ -196,30 +185,42 @@ - (void)drawRect:(NSRect)dirtyRect { for (NSInteger i = 0; i < 8; i++) { CGFloat y = 34.0 + (CGFloat)i * MAX(46.0, height / 8.5); NSBezierPath *scan = [NSBezierPath bezierPath]; - [scan moveToPoint:NSMakePoint(0.0, y + sin(_phase * 6.283 + i) * 9.0)]; - [scan lineToPoint:NSMakePoint(width, y + cos(_phase * 6.283 + i) * 9.0)]; + [scan moveToPoint:NSMakePoint(0.0, y)]; + [scan lineToPoint:NSMakePoint(width, y + ((i % 2 == 0) ? 6.0 : -6.0))]; [OpnColor(0xFFFFFF, 0.026) setStroke]; scan.lineWidth = 1.0; [scan stroke]; } + CGFloat strikeOne = 0.22; + CGFloat strikeTwo = 0.16; + CGFloat strikeThree = 0.12; + CGFloat strikeFour = 0.18; + [self drawBoltFrom:NSMakePoint(width * 0.05, height * 0.78) to:NSMakePoint(width * 0.42, height * 0.16) - branches:10 seed:11 alpha:0.42 width:2.2]; + branches:10 seed:11 alpha:0.055 + strikeOne * 0.88 width:2.2 + strikeOne * 2.8]; [self drawBoltFrom:NSMakePoint(width * 0.52, height * 0.92) to:NSMakePoint(width * 0.94, height * 0.22) - branches:11 seed:47 alpha:0.34 width:1.8]; + branches:11 seed:47 alpha:0.040 + strikeTwo * 0.78 width:1.8 + strikeTwo * 2.6]; [self drawBoltFrom:NSMakePoint(width * 0.18, height * 0.50) to:NSMakePoint(width * 0.76, height * 0.58) - branches:7 seed:83 alpha:0.24 width:1.4]; + branches:7 seed:83 alpha:0.030 + strikeThree * 0.66 width:1.4 + strikeThree * 2.2]; + [self drawBoltFrom:NSMakePoint(width * 0.80, height * 0.12) + to:NSMakePoint(width * 0.34, height * 0.88) + branches:12 seed:131 alpha:0.035 + strikeFour * 0.86 width:1.8 + strikeFour * 2.9]; + + [self drawStrikeBloomAt:NSMakePoint(width * 0.42, height * 0.16) radius:190.0 intensity:strikeOne]; + [self drawStrikeBloomAt:NSMakePoint(width * 0.94, height * 0.22) radius:220.0 intensity:strikeTwo]; + [self drawStrikeBloomAt:NSMakePoint(width * 0.76, height * 0.58) radius:180.0 intensity:strikeThree]; + [self drawStrikeBloomAt:NSMakePoint(width * 0.34, height * 0.88) radius:240.0 intensity:strikeFour]; for (NSInteger i = 0; i < 90; i++) { CGFloat x = fmod((CGFloat)(i * 97), MAX(1.0, width)); CGFloat y = fmod((CGFloat)(i * 43), MAX(1.0, height)); CGFloat radius = 0.8 + (CGFloat)(i % 4) * 0.45; NSBezierPath *spark = [NSBezierPath bezierPathWithOvalInRect:NSMakeRect(x, y, radius, radius)]; - CGFloat sparkPulse = 0.52 + 0.48 * sin((_phase + (CGFloat)(i % 29) / 29.0) * 6.28318530718); - [OpnColor(i % 5 == 0 ? 0xFFFFFF : OPN::kBrandGreen, (i % 5 == 0 ? 0.16 : 0.11) * sparkPulse) setFill]; + [OpnColor(i % 5 == 0 ? 0xFFFFFF : OPN::kBrandGreen, i % 5 == 0 ? 0.12 : 0.08) setFill]; [spark fill]; } @@ -910,7 +911,7 @@ - (void)focusCardAtIndex:(NSInteger)index scrollIntoView:(BOOL)scrollIntoView { for (NSUInteger i = 0; i < self.cardViews.count; i++) { BOOL selected = OpnControllerModeEnabled() && (NSInteger)i == clamped; self.cardViews[i].controllerFocused = selected; - self.cardViews[i].alphaValue = OpnControllerModeEnabled() && !selected ? 0.72 : 1.0; + self.cardViews[i].alphaValue = 1.0; } if (OpnControllerModeEnabled() && scrollIntoView && previousIndex >= 0 && previousIndex != clamped) { OpnPlayConsoleTone(OPNConsoleToneMove); From be454048e079825c9cf2dd95e3c6bce00a1b48c8 Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Tue, 12 May 2026 02:16:27 -0500 Subject: [PATCH 13/18] Refine controller mode library --- src/common/OPNGameTypes.h | 1 + src/games/OPNGameService.mm | 21 + src/views/OPNBackdropView.mm | 99 ++-- src/views/OPNGameCardView.mm | 98 ++-- src/views/OPNGameCatalogView.mm | 777 ++++++++++++++++++++++++-------- src/views/OPNSettingsView.mm | 6 +- 6 files changed, 723 insertions(+), 279 deletions(-) diff --git a/src/common/OPNGameTypes.h b/src/common/OPNGameTypes.h index c5725985c..27274c1fe 100644 --- a/src/common/OPNGameTypes.h +++ b/src/common/OPNGameTypes.h @@ -20,6 +20,7 @@ struct GameInfo { std::string launchAppId; std::string title; std::string shortName; + std::string description; std::string playType; std::string membershipTierLabel; std::string playabilityState; diff --git a/src/games/OPNGameService.mm b/src/games/OPNGameService.mm index 8184fa1bc..379fc1483 100644 --- a/src/games/OPNGameService.mm +++ b/src/games/OPNGameService.mm @@ -177,6 +177,15 @@ static void GetServerVpcId(const std::string &token, return (NSString *)value; } +static NSString *FirstSafeString(NSDictionary *dictionary, NSArray *keys) { + if (![dictionary isKindOfClass:[NSDictionary class]]) return nil; + for (NSString *key in keys) { + NSString *value = SafeStr(dictionary[key]); + if (value.length > 0) return value; + } + return nil; +} + static double SafeMinutesAsHours(id value) { if ([value isKindOfClass:[NSNumber class]]) { return [(NSNumber *)value doubleValue] / 60.0; @@ -199,6 +208,13 @@ static bool HasVisibleVariants(const GameInfo &game) { { NSString *v = SafeStr(app[@"id"]); g.id = v ? [v UTF8String] : ""; g.uuid = g.id; } { NSString *v = SafeStr(app[@"title"]); g.title = v ? [v UTF8String] : ""; } { NSString *v = SafeStr(app[@"shortName"]); g.shortName = v ? [v UTF8String] : ""; } + { NSString *v = FirstSafeString(app, @[@"description", @"longDescription", @"shortDescription", @"summary"]); g.description = v ? [v UTF8String] : ""; } + + NSDictionary *itemMetadata = app[@"itemMetadata"]; + if (g.description.empty() && [itemMetadata isKindOfClass:[NSDictionary class]]) { + NSString *v = FirstSafeString(itemMetadata, @[@"description", @"longDescription", @"shortDescription", @"summary"]); + if (v) g.description = [v UTF8String]; + } NSDictionary *serviceMeta = app[@"gfn"]; if (serviceMeta && [serviceMeta isKindOfClass:[NSDictionary class]]) { @@ -665,6 +681,8 @@ query GetSearchFilterResults( if (![title isKindOfClass:[NSString class]] || title.length == 0) continue; GameInfo g; + g.title = [title UTF8String]; + { NSString *v = FirstSafeString(item, @[@"description", @"longDescription", @"shortDescription", @"summary"]); g.description = v ? [v UTF8String] : ""; } id rawId = item[@"id"]; NSString *sid = [rawId isKindOfClass:[NSNumber class]] ? [(NSNumber *)rawId stringValue] @@ -937,6 +955,8 @@ static void GetServerVpcId(const std::string &token, GameInfo merged = parseGameItem(meta); if (merged.imageUrl.empty() && !g.imageUrl.empty()) merged.imageUrl = g.imageUrl; + if (merged.description.empty() && !g.description.empty()) + merged.description = g.description; if (merged.variants.empty()) merged.variants = g.variants; merged.isInLibrary = g.isInLibrary; @@ -967,6 +987,7 @@ static void GetServerVpcId(const std::string &token, } if (existing.title.empty()) existing.title = g.title; if (existing.imageUrl.empty()) existing.imageUrl = g.imageUrl; + if (existing.description.empty()) existing.description = g.description; } } std::vector finalGames; diff --git a/src/views/OPNBackdropView.mm b/src/views/OPNBackdropView.mm index b706d2656..21f446572 100644 --- a/src/views/OPNBackdropView.mm +++ b/src/views/OPNBackdropView.mm @@ -17,6 +17,10 @@ static BOOL OPNBackdropControllerNavigationActive(NSView *view) { return window.contentView == view || [view isDescendantOf:window.contentView]; } +static const unsigned kControllerConsoleBlue = 0x34C759; +static const unsigned kControllerConsoleBlueSoft = 0xA7F3BF; +static const unsigned kControllerConsoleDeepBlue = 0x06140A; + @implementation OPNBackdropView { NSRect _storeNavFrame; NSRect _libraryNavFrame; @@ -211,8 +215,8 @@ - (void)layout { BOOL controllerMode = OpnControllerModeEnabled(); if (controllerMode && showNavigation) { _storeNavFrame = NSZeroRect; - _libraryNavFrame = NSMakeRect(28.0, 78.0, 86.0, 34.0); - _settingsNavFrame = NSMakeRect(124.0, 78.0, 90.0, 34.0); + _libraryNavFrame = NSMakeRect(30.0, 68.0, 82.0, 34.0); + _settingsNavFrame = NSMakeRect(124.0, 68.0, 92.0, 34.0); _accountFrame = NSMakeRect(NSWidth(self.bounds) - 304.0, 10.0, 284.0, 92.0); } BOOL showStore = showNavigation && !controllerMode; @@ -242,9 +246,9 @@ - (void)drawRect:(NSRect)dirtyRect { NSGradient *edgeWash = controllerMode ? [[NSGradient alloc] initWithColors:@[ - OpnColor(kBackground, 1.0), - OpnColor(kBrandGreen, 0.20), - OpnColor(0x06080E, 1.0), + OpnColor(0x06140A, 1.0), + OpnColor(0x0D3516, 1.0), + OpnColor(0x061C0C, 1.0), ]] : [[NSGradient alloc] initWithColors:@[ OpnColor(kBackgroundB, 0.94), @@ -253,26 +257,21 @@ - (void)drawRect:(NSRect)dirtyRect { ]]; [edgeWash drawInRect:bounds angle:270.0]; - NSGradient *spotlight = [[NSGradient alloc] initWithStartingColor:controllerMode ? OpnColor(kBrandGreen, 0.18) : OpnColor(0xFFFFFF, 0.045) - endingColor:OpnColor(0xFFFFFF, 0.0)]; - NSRect spotlightRect = controllerMode - ? NSMakeRect(NSWidth(bounds) * 0.5 - 520.0, -360.0, 1040.0, 760.0) - : NSMakeRect(NSWidth(bounds) * 0.5 - 360.0, -300.0, 720.0, 720.0); - [spotlight drawInBezierPath:[NSBezierPath bezierPathWithOvalInRect:spotlightRect] angle:90.0]; - - NSBezierPath *lowerGlow = [NSBezierPath bezierPathWithOvalInRect: - NSMakeRect(NSWidth(bounds) - 500.0, NSHeight(bounds) - 360.0, 520.0, 520.0)]; - [controllerMode ? OpnColor(kBrandGreen, 0.10) : OpnColor(kLinkBlue, 0.045) setFill]; - [lowerGlow fill]; + if (!controllerMode) { + NSGradient *spotlight = [[NSGradient alloc] initWithStartingColor:OpnColor(0xFFFFFF, 0.045) + endingColor:OpnColor(0xFFFFFF, 0.0)]; + NSRect spotlightRect = NSMakeRect(NSWidth(bounds) * 0.5 - 360.0, -300.0, 720.0, 720.0); + [spotlight drawInBezierPath:[NSBezierPath bezierPathWithOvalInRect:spotlightRect] angle:90.0]; + + NSBezierPath *lowerGlow = [NSBezierPath bezierPathWithOvalInRect: + NSMakeRect(NSWidth(bounds) - 500.0, NSHeight(bounds) - 360.0, 520.0, 520.0)]; + [OpnColor(kLinkBlue, 0.045) setFill]; + [lowerGlow fill]; + } if (controllerMode) { - NSGradient *depthGlow = [[NSGradient alloc] initWithStartingColor:OpnColor(kBrandGreen, 0.16) - endingColor:OpnColor(kBrandGreen, 0.0)]; - [depthGlow drawInBezierPath:[NSBezierPath bezierPathWithOvalInRect:NSMakeRect(-260.0, 210.0, 760.0, 560.0)] angle:35.0]; - [depthGlow drawInBezierPath:[NSBezierPath bezierPathWithOvalInRect:NSMakeRect(NSWidth(bounds) * 0.48, NSHeight(bounds) - 420.0, 980.0, 520.0)] angle:210.0]; - - NSBezierPath *horizon = [NSBezierPath bezierPathWithRoundedRect:NSMakeRect(24.0, 137.0, NSWidth(bounds) - 48.0, 1.0) xRadius:0.5 yRadius:0.5]; - [OpnColor(kBrandGreen, 0.18) setFill]; + NSBezierPath *horizon = [NSBezierPath bezierPathWithRoundedRect:NSMakeRect(24.0, 117.0, NSWidth(bounds) - 48.0, 1.0) xRadius:0.5 yRadius:0.5]; + [OpnColor(kControllerConsoleBlueSoft, 0.22) setFill]; [horizon fill]; } @@ -280,9 +279,9 @@ - (void)drawRect:(NSRect)dirtyRect { return; } - CGFloat navHeight = controllerMode ? 136.0 : 64.0; + CGFloat navHeight = controllerMode ? 118.0 : 64.0; NSRect navRect = NSMakeRect(0, 0, NSWidth(bounds), navHeight); - [controllerMode ? OpnColor(0x06080D, 0.42) : OpnColor(0x1C1D21, 0.82) setFill]; + [controllerMode ? OpnColor(0x0B2A12, 0.92) : OpnColor(0x1C1D21, 0.82) setFill]; NSRectFill(navRect); [OpnColor(0xFFFFFF, 0.08) setFill]; NSRectFill(NSMakeRect(0, navHeight - 1.0, NSWidth(bounds), 1)); @@ -296,44 +295,44 @@ - (void)drawRect:(NSRect)dirtyRect { NSDateFormatter *timeFormatter = [[NSDateFormatter alloc] init]; timeFormatter.dateFormat = @"h:mm a"; NSString *timeText = [[timeFormatter stringFromDate:NSDate.date] uppercaseString]; - NSBezierPath *timeGlow = [NSBezierPath bezierPathWithRoundedRect:NSMakeRect(20.0, 34.0, 128.0, 30.0) xRadius:15.0 yRadius:15.0]; - [OpnColor(kBrandGreen, 0.075) setFill]; + NSBezierPath *timeGlow = [NSBezierPath bezierPathWithRoundedRect:NSMakeRect(20.0, 32.0, 128.0, 30.0) xRadius:15.0 yRadius:15.0]; + [OpnColor(0xFFFFFF, 0.055) setFill]; [timeGlow fill]; - [timeText drawInRect:NSMakeRect(32.0, 42.0, 112.0, 18.0) + [timeText drawInRect:NSMakeRect(32.0, 40.0, 112.0, 18.0) withAttributes:OpnTextStyle(13.0, OpnColor(kTextSecondary), NSFontWeightSemibold)]; } - NSArray *items = controllerMode ? @[@"Library", @"Settings"] : @[@"Store", @"Library", @"Settings"]; - CGFloat widths[] = {86.0, 90.0, 78.0}; + NSArray *items = controllerMode ? @[@"Games", @"Settings"] : @[@"Store", @"Library", @"Settings"]; + CGFloat widths[] = {82.0, 92.0, 78.0}; CGFloat navWidth = controllerMode ? widths[0] + widths[1] + 10.0 : widths[2] + widths[0] + widths[1] + 8.0; - CGFloat x = controllerMode ? 28.0 : floor((NSWidth(bounds) - navWidth) / 2.0); + CGFloat x = controllerMode ? 30.0 : floor((NSWidth(bounds) - navWidth) / 2.0); _storeNavFrame = controllerMode ? NSZeroRect : _storeNavFrame; - CGFloat navRowY = controllerMode ? 74.0 : 15.0; + CGFloat navRowY = controllerMode ? 64.0 : 15.0; NSRect segmentedRect = NSMakeRect(x - 8.0, navRowY, navWidth + 16.0, controllerMode ? 42.0 : 34.0); NSBezierPath *segmented = [NSBezierPath bezierPathWithRoundedRect:segmentedRect xRadius:controllerMode ? 21.0 : 10.0 yRadius:controllerMode ? 21.0 : 10.0]; [controllerMode ? OpnColor(0xFFFFFF, 0.055) : OpnColor(0xFFFFFF, 0.055) setFill]; [segmented fill]; if (controllerMode) { - [OpnColor(kBrandGreen, 0.18) setStroke]; + [OpnColor(0xFFFFFF, 0.18) setStroke]; segmented.lineWidth = 1.0; [segmented stroke]; } for (NSUInteger i = 0; i < items.count; i++) { NSString *item = items[i]; - CGFloat itemWidth = [item isEqualToString:@"Store"] ? widths[2] : ([item isEqualToString:@"Library"] ? widths[0] : widths[1]); + CGFloat itemWidth = [item isEqualToString:@"Store"] ? widths[2] : (([item isEqualToString:@"Library"] || [item isEqualToString:@"Games"]) ? widths[0] : widths[1]); BOOL active = ([item isEqualToString:@"Store"] && self.mode == OPNBackdropModeStore) || - ([item isEqualToString:@"Library"] && self.mode == OPNBackdropModeLibrary) || + (([item isEqualToString:@"Library"] || [item isEqualToString:@"Games"]) && self.mode == OPNBackdropModeLibrary) || ([item isEqualToString:@"Settings"] && self.mode == OPNBackdropModeSettings); - NSRect itemRect = NSMakeRect(x, controllerMode ? 78.0 : 18.0, itemWidth, controllerMode ? 34.0 : 28.0); + NSRect itemRect = NSMakeRect(x, controllerMode ? 68.0 : 18.0, itemWidth, controllerMode ? 34.0 : 28.0); if ([item isEqualToString:@"Store"]) _storeNavFrame = itemRect; - if ([item isEqualToString:@"Library"]) _libraryNavFrame = itemRect; + if ([item isEqualToString:@"Library"] || [item isEqualToString:@"Games"]) _libraryNavFrame = itemRect; if ([item isEqualToString:@"Settings"]) _settingsNavFrame = itemRect; if (active) { NSBezierPath *pill = [NSBezierPath bezierPathWithRoundedRect:itemRect xRadius:controllerMode ? 17.0 : 8.0 yRadius:controllerMode ? 17.0 : 8.0]; - [controllerMode ? OpnColor(kBrandGreen, 0.26) : OpnColor(0xFFFFFF, 0.14) setFill]; + [controllerMode ? OpnColor(0xFFFFFF, 0.20) : OpnColor(0xFFFFFF, 0.14) setFill]; [pill fill]; if (controllerMode) { - [OpnColor(kBrandGreen, 0.58) setStroke]; + [OpnColor(kControllerConsoleBlueSoft, 0.66) setStroke]; pill.lineWidth = 1.0; [pill stroke]; } @@ -350,12 +349,12 @@ - (void)drawRect:(NSRect)dirtyRect { NSString *remaining = self.remainingPlayTime.length > 0 ? self.remainingPlayTime : @"--"; CGFloat controllerStatsWidth = 292.0; CGFloat controllerStatsX = MAX(NSMaxX(segmentedRect) + 18.0, NSWidth(bounds) - controllerStatsWidth - 28.0); - NSRect planRect = controllerMode ? NSMakeRect(controllerStatsX, 82.0, 132.0, 26.0) : NSMakeRect(NSWidth(bounds) - 294, 11.0, 108, 26); + NSRect planRect = controllerMode ? NSMakeRect(controllerStatsX, 72.0, 132.0, 26.0) : NSMakeRect(NSWidth(bounds) - 294, 11.0, 108, 26); NSBezierPath *planPill = [NSBezierPath bezierPathWithRoundedRect:planRect xRadius:14 yRadius:14]; - [controllerMode ? OpnColor(kBrandGreen, 0.10) : OpnColor(0xFFFFFF, 0.075) setFill]; + [controllerMode ? OpnColor(0xFFFFFF, 0.075) : OpnColor(0xFFFFFF, 0.075) setFill]; [planPill fill]; if (controllerMode) { - [OpnColor(kBrandGreen, 0.24) setStroke]; + [OpnColor(kControllerConsoleBlueSoft, 0.24) setStroke]; planPill.lineWidth = 1.0; [planPill stroke]; } @@ -371,7 +370,7 @@ - (void)drawRect:(NSRect)dirtyRect { gameCountStyle.alignment = controllerMode ? NSTextAlignmentRight : NSTextAlignmentCenter; NSMutableDictionary *gameCountAttrs = [OpnTextStyle(10, OpnColor(kTextMuted), NSFontWeightMedium) mutableCopy]; gameCountAttrs[NSParagraphStyleAttributeName] = gameCountStyle; - [gameCount drawInRect:controllerMode ? NSMakeRect(NSMaxX(planRect) + 14.0, 88.0, 146.0, 14.0) : NSMakeRect(NSMinX(planRect), 40.0, NSWidth(planRect), 14) + [gameCount drawInRect:controllerMode ? NSMakeRect(NSMaxX(planRect) + 14.0, 78.0, 146.0, 14.0) : NSMakeRect(NSMinX(planRect), 40.0, NSWidth(planRect), 14) withAttributes:gameCountAttrs]; NSRect avatarRect = controllerMode ? NSMakeRect(NSWidth(bounds) - 292.0, 18.0, 30.0, 30.0) : NSMakeRect(NSWidth(bounds) - 164, 17.0, 30, 30); @@ -389,10 +388,12 @@ - (void)drawRect:(NSRect)dirtyRect { hints:@{NSImageHintInterpolation: @(NSImageInterpolationHigh)}]; [NSGraphicsContext restoreGraphicsState]; } else { + [OpnColor(kControllerConsoleBlueSoft, 0.90) setFill]; + [avatar fill]; NSString *initial = name.length > 0 ? [[name substringToIndex:1] uppercaseString] : @"U"; NSMutableParagraphStyle *avatarStyle = [[NSMutableParagraphStyle alloc] init]; avatarStyle.alignment = NSTextAlignmentCenter; - NSMutableDictionary *avatarAttrs = [OpnTextStyle(13, OpnColor(kAccentOn), NSFontWeightBold) mutableCopy]; + NSMutableDictionary *avatarAttrs = [OpnTextStyle(13, OpnColor(kControllerConsoleDeepBlue), NSFontWeightBold) mutableCopy]; avatarAttrs[NSParagraphStyleAttributeName] = avatarStyle; [initial drawInRect:NSMakeRect(NSMinX(avatarRect), NSMinY(avatarRect) + 7, 30, 16) withAttributes:avatarAttrs]; } @@ -468,9 +469,9 @@ - (NSButton *)controllerAccountMenuButtonWithTitle:(NSString *)title button.identifier = identifier ?: @""; button.wantsLayer = YES; button.layer.cornerRadius = 14.0; - button.layer.backgroundColor = selected ? OpnColor(OPN::kBrandGreen, 0.22).CGColor : OpnColor(0xFFFFFF, 0.045).CGColor; + button.layer.backgroundColor = selected ? OpnColor(kControllerConsoleBlue, 0.20).CGColor : OpnColor(0xFFFFFF, 0.045).CGColor; button.layer.borderWidth = selected ? 1.0 : 0.0; - button.layer.borderColor = OpnColor(OPN::kBrandGreen, 0.50).CGColor; + button.layer.borderColor = OpnColor(kControllerConsoleBlueSoft, 0.52).CGColor; NSColor *textColor = warning ? OpnColor(0xFF8A8A) : (selected ? OpnColor(OPN::kTextPrimary) : OpnColor(OPN::kTextSecondary)); NSString *displayTitle = selected ? [NSString stringWithFormat:@"%@ Current", title] : title; NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; @@ -509,9 +510,9 @@ - (void)showControllerAccountMenu { menu.wantsLayer = YES; menu.layer.cornerRadius = 24.0; menu.layer.borderWidth = 1.0; - menu.layer.borderColor = OpnColor(OPN::kBrandGreen, 0.28).CGColor; - menu.layer.backgroundColor = OpnColor(0x080A10, 0.94).CGColor; - menu.layer.shadowColor = OpnColor(OPN::kBrandGreen).CGColor; + menu.layer.borderColor = OpnColor(0xFFFFFF, 0.18).CGColor; + menu.layer.backgroundColor = OpnColor(kControllerConsoleDeepBlue, 0.96).CGColor; + menu.layer.shadowColor = OpnColor(kControllerConsoleBlue).CGColor; menu.layer.shadowOpacity = 0.24; menu.layer.shadowRadius = 30.0; menu.layer.shadowOffset = CGSizeZero; diff --git a/src/views/OPNGameCardView.mm b/src/views/OPNGameCardView.mm index b38a6b7a8..bfdf8dde1 100644 --- a/src/views/OPNGameCardView.mm +++ b/src/views/OPNGameCardView.mm @@ -4,14 +4,20 @@ #include static const CGFloat gCardWidth = 220.0; +static const CGFloat gControllerCardWidth = 164.0; static const CGFloat gImageHeight = gCardWidth; static const CGFloat gInfoHeight = 0.0; static const CGFloat gCardTotalHeight = gImageHeight + gInfoHeight; +static const unsigned kConsoleBlue = 0x34C759; +static const unsigned kConsoleBlueSoft = 0xA7F3BF; +static const unsigned kConsoleDeepBlue = 0x06140A; static CGFloat OPNScaledCardWidth(void) { + if (OpnControllerModeEnabled()) return gControllerCardWidth; return floor(gCardWidth * OpnPosterSizeScale()); } static CGFloat OPNScaledCardHeight(void) { + if (OpnControllerModeEnabled()) return gControllerCardWidth; return floor(gCardTotalHeight * OpnPosterSizeScale()); } @@ -93,7 +99,7 @@ static CGFloat OPNScaledCardHeight(void) { static NSColor *OPNStoreIconColor(NSString *name, BOOL selected) { (void)name; CGFloat alpha = selected ? 0.96 : 0.68; - return OpnColor(OPN::kBrandGreen, alpha); + return OpnColor(kConsoleBlueSoft, alpha); } static NSFont *OPNStoreIconFont(NSString *glyph) { @@ -148,51 +154,51 @@ - (instancetype)initWithFrame:(NSRect)frame game:(const OPN::GameInfo &)game { if (self) { _gameData = game; self.wantsLayer = YES; - self.layer.cornerRadius = 18.0; + self.layer.cornerRadius = 20.0; self.layer.masksToBounds = NO; self.layer.backgroundColor = NSColor.clearColor.CGColor; self.layer.borderWidth = 1.0; - self.layer.borderColor = OpnColor(0xFFFFFF, 0.10).CGColor; + self.layer.borderColor = OpnColor(0xFFFFFF, 0.13).CGColor; self.layer.shadowColor = NSColor.blackColor.CGColor; - self.layer.shadowOpacity = 0.34; - self.layer.shadowRadius = 18.0; - self.layer.shadowOffset = CGSizeMake(0.0, 14.0); + self.layer.shadowOpacity = 0.38; + self.layer.shadowRadius = 20.0; + self.layer.shadowOffset = CGSizeMake(0.0, 16.0); _reflectionLayer = [CALayer layer]; - _reflectionLayer.backgroundColor = OpnColor(kBrandGreen, 0.22).CGColor; - _reflectionLayer.cornerRadius = 16.0; + _reflectionLayer.backgroundColor = OpnColor(kConsoleBlueSoft, 0.28).CGColor; + _reflectionLayer.cornerRadius = 18.0; _reflectionLayer.opacity = 0.0; - _reflectionLayer.shadowColor = OpnColor(kBrandGreen).CGColor; - _reflectionLayer.shadowOpacity = 0.72; - _reflectionLayer.shadowRadius = 22.0; + _reflectionLayer.shadowColor = OpnColor(kConsoleBlueSoft).CGColor; + _reflectionLayer.shadowOpacity = 0.68; + _reflectionLayer.shadowRadius = 24.0; _reflectionLayer.shadowOffset = CGSizeZero; [self.layer addSublayer:_reflectionLayer]; _contentView = [[NSView alloc] initWithFrame:self.bounds]; _contentView.wantsLayer = YES; - _contentView.layer.cornerRadius = 18.0; + _contentView.layer.cornerRadius = 20.0; _contentView.layer.masksToBounds = YES; - _contentView.layer.backgroundColor = OpnColor(kSurfaceRaised, 0.82).CGColor; + _contentView.layer.backgroundColor = OpnColor(kConsoleDeepBlue, 0.84).CGColor; [self addSubview:_contentView]; _imageView = [[NSImageView alloc] initWithFrame:self.bounds]; _imageView.imageScaling = NSImageScaleProportionallyUpOrDown; _imageView.wantsLayer = YES; - _imageView.layer.backgroundColor = OpnColor(kBackgroundC).CGColor; + _imageView.layer.backgroundColor = OpnColor(0x101827).CGColor; [_contentView addSubview:_imageView]; _playButton = [[NSButton alloc] initWithFrame: - NSMakeRect((NSWidth(self.bounds) - 46) / 2, (NSHeight(self.bounds) - 46) / 2, 46, 46)]; - _playButton.title = @"▶"; + NSMakeRect((NSWidth(self.bounds) - 76) / 2, NSHeight(self.bounds) - 52, 76, 34)]; + _playButton.title = @"PLAY"; _playButton.bordered = NO; - _playButton.font = [NSFont systemFontOfSize:18 weight:NSFontWeightSemibold]; - _playButton.contentTintColor = OpnColor(kAccentOn); + _playButton.font = [NSFont systemFontOfSize:12 weight:NSFontWeightBold]; + _playButton.contentTintColor = OpnColor(kConsoleDeepBlue); _playButton.wantsLayer = YES; - _playButton.layer.cornerRadius = 23; - _playButton.layer.backgroundColor = OpnColor(kBrandGreen, 0.95).CGColor; - _playButton.layer.shadowColor = OpnColor(kBrandGreen).CGColor; - _playButton.layer.shadowOpacity = 0.22; - _playButton.layer.shadowRadius = 12; + _playButton.layer.cornerRadius = 17; + _playButton.layer.backgroundColor = OpnColor(0xFFFFFF, 0.94).CGColor; + _playButton.layer.shadowColor = OpnColor(kConsoleBlueSoft).CGColor; + _playButton.layer.shadowOpacity = 0.18; + _playButton.layer.shadowRadius = 14; _playButton.layer.shadowOffset = CGSizeZero; _playButton.hidden = YES; _playButton.target = self; @@ -237,28 +243,30 @@ - (void)setControllerFocused:(BOOL)controllerFocused { - (void)applyFocusStyle { BOOL selected = self.controllerFocused; - self.playButton.hidden = !selected; + BOOL controllerMode = OpnControllerModeEnabled(); + self.playButton.hidden = OpnControllerModeEnabled() || !selected; [CATransaction begin]; [CATransaction setAnimationDuration:0.22]; [CATransaction setAnimationTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]]; self.layer.zPosition = selected ? 20.0 : 0.0; - self.layer.borderColor = selected ? OpnColor(kBrandGreen, 0.92).CGColor : OpnColor(0xFFFFFF, 0.10).CGColor; - self.layer.borderWidth = selected ? 2.0 : 1.0; - self.layer.shadowColor = OpnColor(kBrandGreen).CGColor; - self.layer.shadowOpacity = selected ? 0.62 : 0.30; - self.layer.shadowRadius = selected ? 56.0 : 18.0; - self.layer.shadowOffset = selected ? CGSizeMake(0.0, 26.0) : CGSizeMake(0.0, 12.0); + self.layer.borderColor = selected ? OpnColor(0xFFFFFF, 0.94).CGColor : OpnColor(0xFFFFFF, 0.13).CGColor; + self.layer.borderWidth = selected ? 3.0 : 1.0; + self.layer.shadowColor = selected ? OpnColor(kConsoleBlueSoft).CGColor : NSColor.blackColor.CGColor; + self.layer.shadowOpacity = selected ? (controllerMode ? 0.28 : 0.58) : 0.34; + self.layer.shadowRadius = selected ? (controllerMode ? 22.0 : 58.0) : 20.0; + self.layer.shadowOffset = selected ? (controllerMode ? CGSizeMake(0.0, 12.0) : CGSizeMake(0.0, 28.0)) : CGSizeMake(0.0, 14.0); CATransform3D transform = CATransform3DIdentity; transform.m34 = -1.0 / 760.0; if (selected) { - transform = CATransform3DTranslate(transform, 0.0, -10.0, 34.0); - transform = CATransform3DScale(transform, 1.105, 1.105, 1.0); - transform = CATransform3DRotate(transform, -0.052, 1.0, 0.0, 0.0); + transform = CATransform3DTranslate(transform, 0.0, controllerMode ? -10.0 : -14.0, 42.0); + CGFloat selectedScale = controllerMode ? 1.05 : 1.135; + transform = CATransform3DScale(transform, selectedScale, selectedScale, 1.0); + transform = CATransform3DRotate(transform, -0.034, 1.0, 0.0, 0.0); } self.layer.transform = transform; - self.reflectionLayer.opacity = selected ? 0.82 : 0.0; - self.playButton.layer.shadowOpacity = selected ? 0.72 : 0.22; - self.playButton.layer.shadowRadius = selected ? 20.0 : 12.0; + self.reflectionLayer.opacity = selected && !controllerMode ? 0.74 : 0.0; + self.playButton.layer.shadowOpacity = selected ? 0.58 : 0.18; + self.playButton.layer.shadowRadius = selected ? 22.0 : 14.0; [CATransaction commit]; } @@ -269,12 +277,12 @@ - (void)layout { CGFloat width = NSWidth(self.bounds); CGFloat height = NSHeight(self.bounds); self.contentView.frame = self.bounds; - self.contentView.layer.cornerRadius = 18.0; + self.contentView.layer.cornerRadius = 20.0; self.imageView.frame = self.bounds; - self.playButton.frame = NSMakeRect((width - 46.0) / 2.0, (height - 46.0) / 2.0, 46.0, 46.0); + self.playButton.frame = NSMakeRect((width - 76.0) / 2.0, MAX(18.0, height - 52.0), 76.0, 34.0); self.storeChipsContainer.frame = NSMakeRect(16.0, MAX(0.0, height - 37.0), MAX(40.0, width - 32.0), 24.0); - self.reflectionLayer.frame = NSMakeRect(18.0, height - 8.0, MAX(24.0, width - 36.0), 16.0); - self.layer.shadowPath = [NSBezierPath bezierPathWithRoundedRect:self.bounds xRadius:18.0 yRadius:18.0].CGPath; + self.reflectionLayer.frame = NSMakeRect(16.0, height - 10.0, MAX(24.0, width - 32.0), 18.0); + self.layer.shadowPath = [NSBezierPath bezierPathWithRoundedRect:self.bounds xRadius:20.0 yRadius:20.0].CGPath; } - (void)playClicked { @@ -325,12 +333,12 @@ - (void)buildStoreChips { chip.toolTip = OPNStorePrettyName(name ?: @""); if (selected) { - chip.layer.backgroundColor = OpnColor(kBrandGreen, 0.18).CGColor; + chip.layer.backgroundColor = OpnColor(kConsoleBlue, 0.18).CGColor; chip.layer.borderWidth = 1.0; chip.layer.borderColor = OPNStoreIconColor(name, YES).CGColor; } else { - chip.layer.backgroundColor = OpnColor(kBrandGreen, 0.08).CGColor; - chip.layer.borderColor = OpnColor(kBrandGreen, 0.14).CGColor; + chip.layer.backgroundColor = OpnColor(kConsoleBlue, 0.08).CGColor; + chip.layer.borderColor = OpnColor(kConsoleBlue, 0.14).CGColor; chip.layer.borderWidth = 1; } @@ -408,14 +416,16 @@ - (void)loadImageFromCandidates:(NSArray *)urlStrings index:(NSUInte - (void)mouseEntered:(NSEvent *)event { [super mouseEntered:event]; + if (OpnControllerModeEnabled()) return; if (!self.controllerFocused) { self.playButton.hidden = NO; - self.layer.borderColor = OpnColor(0xFFFFFF, 0.22).CGColor; + self.layer.borderColor = OpnColor(0xFFFFFF, 0.28).CGColor; } } - (void)mouseExited:(NSEvent *)event { [super mouseExited:event]; + if (OpnControllerModeEnabled()) return; if (!self.controllerFocused) { self.playButton.hidden = YES; self.layer.borderColor = OpnColor(0xFFFFFF, 0.10).CGColor; diff --git a/src/views/OPNGameCatalogView.mm b/src/views/OPNGameCatalogView.mm index 67aecba6e..89d510a22 100644 --- a/src/views/OPNGameCatalogView.mm +++ b/src/views/OPNGameCatalogView.mm @@ -12,6 +12,10 @@ static const CGFloat kCardSpacing = 18.0; static const CGFloat kNavHeight = 62.0; static const CGFloat kToolbarHeight = 82.0; +static const unsigned kConsoleBlue = 0x34C759; +static const unsigned kConsoleBlueSoft = 0xA7F3BF; +static const unsigned kConsoleDeepBlue = 0x06140A; +static NSString *const OPNFavoriteGameIdsDefaultsKey = @"OpenNOW.Library.FavoriteGameIds"; static NSString *OPNCatalogString(const std::string &value, NSString *fallback = @"") { return value.empty() ? fallback : [NSString stringWithUTF8String:value.c_str()]; @@ -71,6 +75,8 @@ - (void)selectWithFrame:(NSRect)rect @end @interface OPNControllerElectricBackgroundView : NSView +@property (nonatomic, strong) NSTimer *animationTimer; +@property (nonatomic, assign) CFTimeInterval animationStartTime; @end @implementation OPNControllerElectricBackgroundView @@ -79,10 +85,37 @@ - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { self.wantsLayer = YES; + _animationStartTime = CACurrentMediaTime(); } return self; } +- (void)dealloc { + [self.animationTimer invalidate]; +} + +- (void)viewDidMoveToWindow { + [super viewDidMoveToWindow]; + if (self.window) { + if (!self.animationTimer) { + self.animationTimer = [NSTimer timerWithTimeInterval:(1.0 / 30.0) + target:self + selector:@selector(animationTick:) + userInfo:nil + repeats:YES]; + [NSRunLoop.mainRunLoop addTimer:self.animationTimer forMode:NSRunLoopCommonModes]; + } + } else { + [self.animationTimer invalidate]; + self.animationTimer = nil; + } +} + +- (void)animationTick:(NSTimer *)timer { + (void)timer; + if (!self.hidden && self.window) [self setNeedsDisplay:YES]; +} + - (BOOL)isFlipped { return YES; } - (CGFloat)unitHashForSeed:(NSUInteger)seed index:(NSUInteger)index { @@ -109,128 +142,57 @@ - (NSPoint)pointOnBoltFrom:(NSPoint)start to:(NSPoint)end t:(CGFloat)t seed:(NSU start.y + dy * t + normalY * offset); } -- (void)drawStrikeBloomAt:(NSPoint)point radius:(CGFloat)radius intensity:(CGFloat)intensity { - if (intensity <= 0.001) return; - NSRect bloomRect = NSMakeRect(point.x - radius, point.y - radius, radius * 2.0, radius * 2.0); - NSGradient *bloom = [[NSGradient alloc] initWithStartingColor:OpnColor(0xFFFFFF, 0.20 * intensity) - endingColor:OpnColor(OPN::kBrandGreen, 0.0)]; - [bloom drawInBezierPath:[NSBezierPath bezierPathWithOvalInRect:bloomRect] relativeCenterPosition:NSZeroPoint]; -} - -- (void)drawBoltFrom:(NSPoint)start to:(NSPoint)end branches:(NSInteger)branches seed:(NSUInteger)seed alpha:(CGFloat)alpha width:(CGFloat)width { - CGFloat effectiveAlpha = alpha; - NSUInteger segments = 10 + (seed % 5); - NSBezierPath *path = [NSBezierPath bezierPath]; - [path moveToPoint:start]; - for (NSUInteger i = 1; i < segments; i++) { - CGFloat t = (CGFloat)i / (CGFloat)segments; - [path lineToPoint:[self pointOnBoltFrom:start to:end t:t seed:seed]]; - } - [path lineToPoint:end]; - - [OpnColor(OPN::kBrandGreen, effectiveAlpha * 0.24) setStroke]; - path.lineWidth = width + 8.0; - [path stroke]; - [OpnColor(0xFFFFFF, effectiveAlpha * 0.72) setStroke]; - path.lineWidth = width + 1.2; - [path stroke]; - [OpnColor(OPN::kBrandGreen, effectiveAlpha) setStroke]; - path.lineWidth = MAX(0.7, width * 0.34); - [path stroke]; - - for (NSInteger b = 0; b < branches; b++) { - CGFloat t = 0.16 + 0.68 * [self unitHashForSeed:seed index:(NSUInteger)b + 41]; - NSPoint branchStart = [self pointOnBoltFrom:start to:end t:t seed:seed]; - CGFloat angle = atan2(end.y - start.y, end.x - start.x); - CGFloat forkDirection = ([self unitHashForSeed:seed index:(NSUInteger)b + 73] > 0.5) ? 1.0 : -1.0; - CGFloat forkAngle = angle + forkDirection * (0.62 + [self unitHashForSeed:seed index:(NSUInteger)b + 97] * 0.55); - CGFloat branchLength = 48.0 + [self unitHashForSeed:seed index:(NSUInteger)b + 113] * 86.0; - NSPoint branchMid = NSMakePoint(branchStart.x + cos(forkAngle) * branchLength * 0.52, - branchStart.y + sin(forkAngle) * branchLength * 0.52); - NSPoint branchEnd = NSMakePoint(branchStart.x + cos(forkAngle) * branchLength, - branchStart.y + sin(forkAngle) * branchLength); - NSBezierPath *branch = [NSBezierPath bezierPath]; - [branch moveToPoint:branchStart]; - [branch lineToPoint:branchMid]; - [branch lineToPoint:branchEnd]; - [OpnColor(0xFFFFFF, effectiveAlpha * 0.34) setStroke]; - branch.lineWidth = MAX(0.7, width * 0.30); - [branch stroke]; - [OpnColor(OPN::kBrandGreen, effectiveAlpha * 0.48) setStroke]; - branch.lineWidth = MAX(0.6, width * 0.35); - [branch stroke]; - } -} - - (void)drawRect:(NSRect)dirtyRect { (void)dirtyRect; NSRect bounds = self.bounds; - NSGradient *base = [[NSGradient alloc] initWithStartingColor:OpnColor(0x05070D, 0.96) - endingColor:OpnColor(0x100817, 0.96)]; - [base drawInRect:bounds angle:90.0]; + CGFloat phase = (CGFloat)(CACurrentMediaTime() - self.animationStartTime); + NSGradient *base = [[NSGradient alloc] initWithColors:@[ + OpnColor(0x041006, 0.99), + OpnColor(0x0B2610, 0.99), + OpnColor(0x123D1A, 0.98), + ]]; + [base drawInRect:bounds angle:88.0]; CGFloat width = NSWidth(bounds); CGFloat height = NSHeight(bounds); - for (NSInteger i = 0; i < 18; i++) { - CGFloat x = fmod((CGFloat)i * 193.0, MAX(1.0, width)); - CGFloat lineAlpha = (i % 3 == 0) ? 0.055 : 0.028; - NSBezierPath *trace = [NSBezierPath bezierPath]; - [trace moveToPoint:NSMakePoint(x, 0.0)]; - [trace lineToPoint:NSMakePoint(x + 120.0, height)]; - [OpnColor(OPN::kBrandGreen, lineAlpha) setStroke]; - trace.lineWidth = 1.0; - [trace stroke]; - } - for (NSInteger i = 0; i < 8; i++) { - CGFloat y = 34.0 + (CGFloat)i * MAX(46.0, height / 8.5); - NSBezierPath *scan = [NSBezierPath bezierPath]; - [scan moveToPoint:NSMakePoint(0.0, y)]; - [scan lineToPoint:NSMakePoint(width, y + ((i % 2 == 0) ? 6.0 : -6.0))]; - [OpnColor(0xFFFFFF, 0.026) setStroke]; - scan.lineWidth = 1.0; - [scan stroke]; + for (NSInteger band = 0; band < 9; band++) { + CGFloat yBase = height * (0.12 + (CGFloat)band * 0.092); + NSBezierPath *ribbon = [NSBezierPath bezierPath]; + [ribbon moveToPoint:NSMakePoint(-120.0, yBase)]; + for (NSInteger point = 0; point <= 28; point++) { + CGFloat t = (CGFloat)point / 28.0; + CGFloat x = t * (width + 240.0) - 120.0; + CGFloat drift = phase * (0.28 + (CGFloat)band * 0.018); + CGFloat y = yBase + + sin(t * 5.8 + (CGFloat)band * 0.72 + drift) * (20.0 + (CGFloat)band * 1.6) + + sin(t * 13.0 - phase * 0.20 + (CGFloat)band) * 5.0; + [ribbon lineToPoint:NSMakePoint(x, y)]; + } + NSColor *stroke = band % 3 == 0 ? OpnColor(0xFFFFFF, 0.038) : OpnColor(kConsoleBlueSoft, 0.044); + [stroke setStroke]; + ribbon.lineWidth = band == 4 ? 2.4 : 1.1; + [ribbon stroke]; } - CGFloat strikeOne = 0.22; - CGFloat strikeTwo = 0.16; - CGFloat strikeThree = 0.12; - CGFloat strikeFour = 0.18; - - [self drawBoltFrom:NSMakePoint(width * 0.05, height * 0.78) - to:NSMakePoint(width * 0.42, height * 0.16) - branches:10 seed:11 alpha:0.055 + strikeOne * 0.88 width:2.2 + strikeOne * 2.8]; - [self drawBoltFrom:NSMakePoint(width * 0.52, height * 0.92) - to:NSMakePoint(width * 0.94, height * 0.22) - branches:11 seed:47 alpha:0.040 + strikeTwo * 0.78 width:1.8 + strikeTwo * 2.6]; - [self drawBoltFrom:NSMakePoint(width * 0.18, height * 0.50) - to:NSMakePoint(width * 0.76, height * 0.58) - branches:7 seed:83 alpha:0.030 + strikeThree * 0.66 width:1.4 + strikeThree * 2.2]; - [self drawBoltFrom:NSMakePoint(width * 0.80, height * 0.12) - to:NSMakePoint(width * 0.34, height * 0.88) - branches:12 seed:131 alpha:0.035 + strikeFour * 0.86 width:1.8 + strikeFour * 2.9]; - - [self drawStrikeBloomAt:NSMakePoint(width * 0.42, height * 0.16) radius:190.0 intensity:strikeOne]; - [self drawStrikeBloomAt:NSMakePoint(width * 0.94, height * 0.22) radius:220.0 intensity:strikeTwo]; - [self drawStrikeBloomAt:NSMakePoint(width * 0.76, height * 0.58) radius:180.0 intensity:strikeThree]; - [self drawStrikeBloomAt:NSMakePoint(width * 0.34, height * 0.88) radius:240.0 intensity:strikeFour]; - - for (NSInteger i = 0; i < 90; i++) { - CGFloat x = fmod((CGFloat)(i * 97), MAX(1.0, width)); - CGFloat y = fmod((CGFloat)(i * 43), MAX(1.0, height)); - CGFloat radius = 0.8 + (CGFloat)(i % 4) * 0.45; + for (NSInteger i = 0; i < 72; i++) { + CGFloat x = fmod((CGFloat)(i * 97) + phase * (8.0 + (CGFloat)(i % 5)), MAX(1.0, width)); + CGFloat y = fmod((CGFloat)(i * 43) + sin(phase * 0.24 + (CGFloat)i) * 18.0, MAX(1.0, height)); + CGFloat radius = 0.7 + (CGFloat)(i % 3) * 0.32; NSBezierPath *spark = [NSBezierPath bezierPathWithOvalInRect:NSMakeRect(x, y, radius, radius)]; - [OpnColor(i % 5 == 0 ? 0xFFFFFF : OPN::kBrandGreen, i % 5 == 0 ? 0.12 : 0.08) setFill]; + [OpnColor(i % 5 == 0 ? 0xFFFFFF : kConsoleBlueSoft, i % 5 == 0 ? 0.10 : 0.07) setFill]; [spark fill]; } - NSGradient *vignette = [[NSGradient alloc] initWithStartingColor:OpnColor(OPN::kBlack, 0.0) - endingColor:OpnColor(OPN::kBlack, 0.48)]; + NSGradient *vignette = [[NSGradient alloc] initWithStartingColor:OpnColor(0x08220E, 0.0) + endingColor:OpnColor(0x041006, 0.42)]; [vignette drawInRect:bounds angle:-90.0]; } @end +@class OPNControllerPromptBarView; + @interface OPNGameCatalogView () @property (nonatomic, strong) NSScrollView *scrollView; @property (nonatomic, strong) NSView *gridContentView; @@ -244,6 +206,11 @@ @interface OPNGameCatalogView () @property (nonatomic, strong) NSTextField *gameCountLabel; @property (nonatomic, strong) NSTextField *statusLabel; @property (nonatomic, strong) OPNLoadingView *loadingView; +@property (nonatomic, strong) NSView *categoryBarView; +@property (nonatomic, strong) NSMutableArray *categoryButtons; +@property (nonatomic, copy) NSArray *> *categoryItems; +@property (nonatomic, copy) NSString *selectedCategoryId; +@property (nonatomic, strong) NSMutableSet *favoriteGameIds; @property (nonatomic, strong) OPNControllerElectricBackgroundView *controllerElectricBackgroundView; @property (nonatomic, strong) NSView *controllerDetailView; @property (nonatomic, strong) NSTextField *controllerDetailTitleLabel; @@ -251,7 +218,7 @@ @interface OPNGameCatalogView () @property (nonatomic, strong) NSTextField *controllerDetailStoreLabel; @property (nonatomic, strong) NSTextField *controllerDetailStatsLabel; @property (nonatomic, strong) NSTextField *controllerDetailFeaturesLabel; -@property (nonatomic, strong) NSTextField *controllerDetailHintLabel; +@property (nonatomic, strong) OPNControllerPromptBarView *controllerPromptBarView; @property (nonatomic, strong) CAGradientLayer *controllerDetailGradientLayer; @property (nonatomic, strong) CALayer *controllerDetailAccentLayer; @property (nonatomic, strong) NSMutableArray *cardViews; @@ -273,6 +240,13 @@ @interface OPNGameCatalogView () - (void)stopGamepadNavigation; - (void)scrollLibraryToTop; - (void)requestCatalogBrowse; +- (void)rebuildCategoryBar; +- (BOOL)game:(const OPN::GameInfo &)game matchesCategory:(NSString *)categoryId; +- (void)cycleCategoryBy:(NSInteger)delta; +- (NSString *)favoriteIdentifierForGame:(const OPN::GameInfo &)game; +- (BOOL)isFavoriteGame:(const OPN::GameInfo &)game; +- (void)toggleFavoriteForFocusedGame; +- (void)persistFavoriteGameIds; - (void)focusCardAtIndex:(NSInteger)index scrollIntoView:(BOOL)scrollIntoView; - (void)openFocusedGameDetails; - (void)closeGameDetails; @@ -299,6 +273,7 @@ static uint16_t OPNCatalogGamepadButtons(void) { if (pad.dpad.down.value > 0.5 || pad.leftThumbstick.yAxis.value < -0.65) buttons |= 1u << 6; if (pad.dpad.left.value > 0.5 || pad.leftThumbstick.xAxis.value < -0.65) buttons |= 1u << 7; if (pad.dpad.right.value > 0.5 || pad.leftThumbstick.xAxis.value > 0.65) buttons |= 1u << 8; + if (pad.buttonX.value > 0.5) buttons |= 1u << 9; return buttons; } @@ -317,6 +292,208 @@ static BOOL OPNCatalogGamepadNavigationActive(NSView *view) { return items.count > 0 ? [items componentsJoinedByString:@" / "] : fallback; } +static NSString *OPNCategoryId(NSString *prefix, NSString *value) { + NSString *cleanValue = [[value ?: @"" stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet] lowercaseString]; + if (cleanValue.length == 0) return @""; + return [NSString stringWithFormat:@"%@:%@", prefix, cleanValue]; +} + +static NSString *OPNStoreCategoryTitle(NSString *store) { + NSString *upper = store.uppercaseString; + if ([upper containsString:@"STEAM"]) return @"Steam"; + if ([upper containsString:@"EPIC"] || [upper containsString:@"EGS"]) return @"Epic"; + if ([upper containsString:@"UBISOFT"] || [upper containsString:@"UPLAY"]) return @"Ubisoft"; + if ([upper containsString:@"BATTLE"]) return @"Battle.net"; + if ([upper containsString:@"XBOX"] || [upper containsString:@"MICROSOFT"]) return @"Xbox"; + if ([upper containsString:@"EA"] || [upper containsString:@"ORIGIN"]) return @"EA"; + if ([upper containsString:@"GOG"]) return @"GOG"; + return store.capitalizedString; +} + +typedef NS_ENUM(NSInteger, OPNControllerPromptStyle) { + OPNControllerPromptStyleGeneric = 0, + OPNControllerPromptStylePlayStation = 1, + OPNControllerPromptStyleXbox = 2, + OPNControllerPromptStyleNintendo = 3, +}; + +static OPNControllerPromptStyle OPNCurrentControllerPromptStyle(void) { + GCController *controller = GCController.controllers.firstObject; + if (!controller) return OPNControllerPromptStyleGeneric; + NSMutableArray *parts = [NSMutableArray array]; + if (controller.vendorName.length > 0) [parts addObject:controller.vendorName]; + if ([controller respondsToSelector:@selector(productCategory)] && controller.productCategory.length > 0) { + [parts addObject:controller.productCategory]; + } + NSString *descriptor = [[parts componentsJoinedByString:@" "] lowercaseString]; + if ([descriptor containsString:@"dualsense"] || [descriptor containsString:@"dualshock"] || + [descriptor containsString:@"playstation"] || [descriptor containsString:@"sony"]) { + return OPNControllerPromptStylePlayStation; + } + if ([descriptor containsString:@"xbox"] || [descriptor containsString:@"microsoft"]) return OPNControllerPromptStyleXbox; + if ([descriptor containsString:@"nintendo"] || [descriptor containsString:@"switch"] || [descriptor containsString:@"joy-con"]) { + return OPNControllerPromptStyleNintendo; + } + return OPNControllerPromptStyleGeneric; +} + +static NSString *OPNPromptLetter(NSString *button, OPNControllerPromptStyle style) { + if ([button isEqualToString:@"primary"]) return style == OPNControllerPromptStyleNintendo ? @"A" : @"A"; + if ([button isEqualToString:@"favorite"]) return style == OPNControllerPromptStyleNintendo ? @"X" : @"Y"; + if ([button isEqualToString:@"store"]) return style == OPNControllerPromptStyleNintendo ? @"Y" : @"X"; + if ([button isEqualToString:@"back"]) return style == OPNControllerPromptStyleNintendo ? @"B" : @"B"; + return @""; +} + +static void OPNStrokePath(NSBezierPath *path, NSColor *color, CGFloat width) { + [color setStroke]; + path.lineWidth = width; + path.lineCapStyle = NSLineCapStyleRound; + path.lineJoinStyle = NSLineJoinStyleRound; + [path stroke]; +} + +static NSImage *OPNControllerPromptIcon(NSString *button, OPNControllerPromptStyle style) { + const CGFloat size = 24.0; + NSImage *image = [[NSImage alloc] initWithSize:NSMakeSize(size, size)]; + [image lockFocus]; + + NSColor *strokeColor = OpnColor(0xEAFBF0, 0.92); + NSColor *fillColor = style == OPNControllerPromptStylePlayStation ? OpnColor(0xFFFFFF, 0.035) : OpnColor(0xEAFBF0, 0.14); + NSRect iconRect = NSMakeRect(2.5, 2.5, 19.0, 19.0); + + if ([button isEqualToString:@"category"]) { + NSBezierPath *pad = [NSBezierPath bezierPathWithRoundedRect:iconRect xRadius:5.0 yRadius:5.0]; + [OpnColor(0xEAFBF0, 0.08) setFill]; + [pad fill]; + OPNStrokePath(pad, OpnColor(0xEAFBF0, 0.68), 1.4); + NSBezierPath *up = [NSBezierPath bezierPath]; + [up moveToPoint:NSMakePoint(12.0, 6.0)]; + [up lineToPoint:NSMakePoint(8.5, 10.0)]; + [up moveToPoint:NSMakePoint(12.0, 6.0)]; + [up lineToPoint:NSMakePoint(15.5, 10.0)]; + OPNStrokePath(up, strokeColor, 1.6); + NSBezierPath *down = [NSBezierPath bezierPath]; + [down moveToPoint:NSMakePoint(12.0, 18.0)]; + [down lineToPoint:NSMakePoint(8.5, 14.0)]; + [down moveToPoint:NSMakePoint(12.0, 18.0)]; + [down lineToPoint:NSMakePoint(15.5, 14.0)]; + OPNStrokePath(down, strokeColor, 1.6); + } else if (style == OPNControllerPromptStylePlayStation) { + if ([button isEqualToString:@"primary"]) { + NSBezierPath *cross = [NSBezierPath bezierPath]; + [cross moveToPoint:NSMakePoint(7.0, 7.0)]; + [cross lineToPoint:NSMakePoint(17.0, 17.0)]; + [cross moveToPoint:NSMakePoint(17.0, 7.0)]; + [cross lineToPoint:NSMakePoint(7.0, 17.0)]; + OPNStrokePath(cross, strokeColor, 2.2); + } else if ([button isEqualToString:@"favorite"]) { + NSBezierPath *triangle = [NSBezierPath bezierPath]; + [triangle moveToPoint:NSMakePoint(12.0, 4.5)]; + [triangle lineToPoint:NSMakePoint(20.0, 18.5)]; + [triangle lineToPoint:NSMakePoint(4.0, 18.5)]; + [triangle closePath]; + [fillColor setFill]; + [triangle fill]; + OPNStrokePath(triangle, strokeColor, 1.8); + } else if ([button isEqualToString:@"store"]) { + NSBezierPath *square = [NSBezierPath bezierPathWithRoundedRect:NSMakeRect(5.2, 5.2, 13.6, 13.6) xRadius:1.8 yRadius:1.8]; + [fillColor setFill]; + [square fill]; + OPNStrokePath(square, strokeColor, 1.8); + } else if ([button isEqualToString:@"back"]) { + NSBezierPath *circle = [NSBezierPath bezierPathWithOvalInRect:NSMakeRect(5.0, 5.0, 14.0, 14.0)]; + [fillColor setFill]; + [circle fill]; + OPNStrokePath(circle, strokeColor, 1.8); + } + } else { + NSBezierPath *circle = [NSBezierPath bezierPathWithOvalInRect:iconRect]; + [fillColor setFill]; + [circle fill]; + OPNStrokePath(circle, strokeColor, 1.4); + NSString *letter = OPNPromptLetter(button, style); + NSMutableParagraphStyle *styleCenter = [[NSMutableParagraphStyle alloc] init]; + styleCenter.alignment = NSTextAlignmentCenter; + [letter drawInRect:NSMakeRect(2.5, 5.3, 19.0, 14.0) withAttributes:@{ + NSFontAttributeName: [NSFont systemFontOfSize:11.0 weight:NSFontWeightBold], + NSForegroundColorAttributeName: strokeColor, + NSParagraphStyleAttributeName: styleCenter, + }]; + } + + [image unlockFocus]; + return image; +} + +@interface OPNControllerPromptBarView : NSView +@property (nonatomic, assign) BOOL includeStore; +@property (nonatomic, assign) BOOL includeBack; +@end + +@implementation OPNControllerPromptBarView + +- (BOOL)isFlipped { return YES; } + +- (void)setIncludeStore:(BOOL)includeStore { + if (_includeStore == includeStore) return; + _includeStore = includeStore; + [self setNeedsDisplay:YES]; +} + +- (void)setIncludeBack:(BOOL)includeBack { + if (_includeBack == includeBack) return; + _includeBack = includeBack; + [self setNeedsDisplay:YES]; +} + +- (NSArray *> *)promptItems { + NSMutableArray *> *items = [NSMutableArray arrayWithObjects: + @{@"button": @"primary", @"title": @"Play"}, + @{@"button": @"favorite", @"title": @"Favorite"}, nil]; + if (self.includeStore) [items addObject:@{@"button": @"store", @"title": @"Store"}]; + [items addObject:@{@"button": @"category", @"title": @"Categories"}]; + if (self.includeBack) [items addObject:@{@"button": @"back", @"title": @"Back"}]; + return items; +} + +- (void)drawRect:(NSRect)dirtyRect { + (void)dirtyRect; + OPNControllerPromptStyle style = OPNCurrentControllerPromptStyle(); + CGFloat x = 0.0; + CGFloat y = 0.0; + NSDictionary *labelAttributes = @{ + NSFontAttributeName: [NSFont systemFontOfSize:13.0 weight:NSFontWeightSemibold], + NSForegroundColorAttributeName: OpnColor(0xF1FFF5, 0.82), + }; + + for (NSDictionary *item in [self promptItems]) { + NSString *title = item[@"title"] ?: @""; + NSString *button = item[@"button"] ?: @""; + CGFloat titleWidth = ceil([title sizeWithAttributes:labelAttributes].width); + CGFloat chipWidth = MAX(82.0, titleWidth + 50.0); + NSRect chipRect = NSMakeRect(x, y, chipWidth, 34.0); + NSBezierPath *chip = [NSBezierPath bezierPathWithRoundedRect:chipRect xRadius:17.0 yRadius:17.0]; + [OpnColor(0xFFFFFF, 0.070) setFill]; + [chip fill]; + OPNStrokePath(chip, OpnColor(0xFFFFFF, 0.14), 1.0); + + NSImage *icon = OPNControllerPromptIcon(button, style); + [icon drawInRect:NSMakeRect(NSMinX(chipRect) + 9.0, NSMinY(chipRect) + 5.0, 24.0, 24.0) + fromRect:NSZeroRect + operation:NSCompositingOperationSourceOver + fraction:1.0 + respectFlipped:YES + hints:@{NSImageHintInterpolation: @(NSImageInterpolationHigh)}]; + + [title drawInRect:NSMakeRect(NSMinX(chipRect) + 39.0, NSMinY(chipRect) + 8.0, chipWidth - 47.0, 18.0) + withAttributes:labelAttributes]; + x += chipWidth + 12.0; + } +} + +@end + @implementation OPNGameCatalogView using namespace OPN; @@ -325,6 +502,11 @@ - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { _cardViews = [NSMutableArray array]; + _categoryButtons = [NSMutableArray array]; + _categoryItems = @[]; + _selectedCategoryId = @"all"; + NSArray *storedFavorites = [NSUserDefaults.standardUserDefaults arrayForKey:OPNFavoriteGameIdsDefaultsKey]; + _favoriteGameIds = [NSMutableSet setWithArray:[storedFavorites isKindOfClass:NSArray.class] ? storedFavorites : @[]]; _selectedSortId = @"last_played"; _selectedFilterIds = [NSMutableSet set]; _focusedCardIndex = -1; @@ -429,6 +611,11 @@ - (instancetype)initWithFrame:(NSRect)frame { _gameCountLabel.hidden = YES; [self addSubview:_gameCountLabel]; + _categoryBarView = [[NSView alloc] initWithFrame:NSZeroRect]; + _categoryBarView.wantsLayer = YES; + _categoryBarView.hidden = YES; + [self addSubview:_categoryBarView]; + CGFloat gridY = kNavHeight + kToolbarHeight; NSRect scrollFrame = NSMakeRect(0, gridY, frame.size.width, frame.size.height - gridY); _scrollView = [[NSScrollView alloc] initWithFrame:scrollFrame]; @@ -437,11 +624,16 @@ - (instancetype)initWithFrame:(NSRect)frame { _scrollView.autohidesScrollers = YES; _scrollView.drawsBackground = NO; _scrollView.borderType = NSNoBorder; + _scrollView.contentView.drawsBackground = NO; + _scrollView.contentView.backgroundColor = NSColor.clearColor; + _scrollView.contentView.wantsLayer = YES; + _scrollView.contentView.layer.backgroundColor = NSColor.clearColor.CGColor; [self addSubview:_scrollView]; // Grid content _gridContentView = [[OPNFlippedGridDocumentView alloc] initWithFrame:NSMakeRect(0, 0, frame.size.width, 100)]; _gridContentView.wantsLayer = YES; + _gridContentView.layer.backgroundColor = NSColor.clearColor.CGColor; _scrollView.documentView = _gridContentView; _statusLabel = OpnLabel(@"", NSMakeRect(0, gridY + 100, frame.size.width, 24), @@ -452,30 +644,30 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailView = [[OPNFlippedGridDocumentView alloc] initWithFrame:NSZeroRect]; _controllerDetailView.hidden = YES; _controllerDetailView.wantsLayer = YES; - _controllerDetailView.layer.cornerRadius = 26.0; - _controllerDetailView.layer.borderWidth = 1.0; - _controllerDetailView.layer.borderColor = OpnColor(kBrandGreen, 0.30).CGColor; - _controllerDetailView.layer.backgroundColor = OpnColor(0x07090F, 0.50).CGColor; - _controllerDetailView.layer.shadowColor = OpnColor(kBrandGreen).CGColor; - _controllerDetailView.layer.shadowOpacity = 0.34; - _controllerDetailView.layer.shadowRadius = 48.0; - _controllerDetailView.layer.shadowOffset = CGSizeMake(0.0, 24.0); + _controllerDetailView.layer.cornerRadius = 0.0; + _controllerDetailView.layer.borderWidth = 0.0; + _controllerDetailView.layer.borderColor = OpnColor(0xFFFFFF, 0.0).CGColor; + _controllerDetailView.layer.backgroundColor = OpnColor(0x020611, 0.10).CGColor; + _controllerDetailView.layer.shadowColor = OpnColor(kConsoleBlue).CGColor; + _controllerDetailView.layer.shadowOpacity = 0.0; + _controllerDetailView.layer.shadowRadius = 0.0; + _controllerDetailView.layer.shadowOffset = CGSizeZero; CATransform3D detailTransform = CATransform3DIdentity; detailTransform.m34 = -1.0 / 1200.0; detailTransform = CATransform3DRotate(detailTransform, 0.012, 1.0, 0.0, 0.0); _controllerDetailView.layer.transform = detailTransform; _controllerDetailGradientLayer = [CAGradientLayer layer]; - _controllerDetailGradientLayer.colors = @[(id)OpnColor(kBrandGreen, 0.18).CGColor, - (id)OpnColor(0xFFFFFF, 0.052).CGColor, + _controllerDetailGradientLayer.colors = @[(id)OpnColor(kConsoleBlue, 0.16).CGColor, + (id)OpnColor(0xFFFFFF, 0.040).CGColor, (id)OpnColor(kBlack, 0.0).CGColor]; - _controllerDetailGradientLayer.locations = @[@0.0, @0.44, @1.0]; + _controllerDetailGradientLayer.locations = @[@0.0, @0.46, @1.0]; _controllerDetailGradientLayer.startPoint = CGPointMake(0.0, 0.0); _controllerDetailGradientLayer.endPoint = CGPointMake(1.0, 1.0); [_controllerDetailView.layer addSublayer:_controllerDetailGradientLayer]; _controllerDetailAccentLayer = [CALayer layer]; - _controllerDetailAccentLayer.backgroundColor = OpnColor(kBrandGreen, 0.74).CGColor; + _controllerDetailAccentLayer.backgroundColor = OpnColor(kConsoleBlueSoft, 0.86).CGColor; _controllerDetailAccentLayer.cornerRadius = 2.0; [_controllerDetailView.layer addSublayer:_controllerDetailAccentLayer]; [self addSubview:_controllerDetailView]; @@ -487,18 +679,19 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailMetaLabel = OpnLabel(@"", NSZeroRect, 15.0, OpnColor(kTextSecondary), NSFontWeightMedium); [_controllerDetailView addSubview:_controllerDetailMetaLabel]; - _controllerDetailStoreLabel = OpnLabel(@"", NSZeroRect, 16.0, OpnColor(kBrandGreen), NSFontWeightSemibold); + _controllerDetailStoreLabel = OpnLabel(@"", NSZeroRect, 16.0, OpnColor(kConsoleBlueSoft), NSFontWeightSemibold); [_controllerDetailView addSubview:_controllerDetailStoreLabel]; _controllerDetailStatsLabel = OpnLabel(@"", NSZeroRect, 14.0, OpnColor(kTextSecondary), NSFontWeightMedium); [_controllerDetailView addSubview:_controllerDetailStatsLabel]; _controllerDetailFeaturesLabel = OpnLabel(@"", NSZeroRect, 14.0, OpnColor(kTextMuted), NSFontWeightRegular); - _controllerDetailFeaturesLabel.maximumNumberOfLines = 4; + _controllerDetailFeaturesLabel.maximumNumberOfLines = 6; [_controllerDetailView addSubview:_controllerDetailFeaturesLabel]; - _controllerDetailHintLabel = OpnLabel(@"✕ Play △ Change Store L1/R1 Menu Options Account", NSZeroRect, 13.0, OpnColor(kTextMuted), NSFontWeightMedium); - [_controllerDetailView addSubview:_controllerDetailHintLabel]; + _controllerPromptBarView = [[OPNControllerPromptBarView alloc] initWithFrame:NSZeroRect]; + _controllerPromptBarView.wantsLayer = YES; + [_controllerDetailView addSubview:_controllerPromptBarView]; _loadingView = [[OPNLoadingView alloc] initWithFrame:self.bounds message:@"Loading games..."]; @@ -539,7 +732,7 @@ - (void)viewDidMoveToWindow { - (void)interfacePreferencesChanged:(NSNotification *)notification { (void)notification; - self.layer.backgroundColor = OpnControllerModeEnabled() ? OpnColor(kBrandGreen, 0.035).CGColor : [NSColor clearColor].CGColor; + self.layer.backgroundColor = OpnControllerModeEnabled() ? OpnColor(kConsoleDeepBlue, 0.20).CGColor : [NSColor clearColor].CGColor; [self renderGrid]; [self startGamepadNavigationIfNeeded]; } @@ -589,6 +782,7 @@ - (void)setGames:(const std::vector &)games { _allGames = games; self.catalogTotalCount = (NSInteger)games.size(); self.catalogSupportedCount = (NSInteger)games.size(); + [self rebuildCategoryBar]; [self renderGrid]; [self scrollLibraryToTop]; dispatch_async(dispatch_get_main_queue(), ^{ @@ -619,6 +813,182 @@ - (void)setCatalogBrowseResult:(const OPN::CatalogBrowseResult &)result { ? [NSString stringWithFormat:@"Filters (%lu)", (unsigned long)self.selectedFilterIds.count] : @"Filters"; self.searchField.stringValue = OPNCatalogString(result.searchQuery, @""); + [self rebuildCategoryBar]; + [self renderGrid]; + [self scrollLibraryToTop]; +} + +- (void)rebuildCategoryBar { + NSMutableArray *> *items = [NSMutableArray array]; + [items addObject:@{@"id": @"all", @"title": @"All"}]; + [items addObject:@{@"id": @"favorites", @"title": @"Favorites"}]; + + NSInteger libraryCount = 0; + NSMutableDictionary *storeCounts = [NSMutableDictionary dictionary]; + NSMutableDictionary *storeTitles = [NSMutableDictionary dictionary]; + NSMutableDictionary *genreCounts = [NSMutableDictionary dictionary]; + NSMutableDictionary *genreTitles = [NSMutableDictionary dictionary]; + + for (const OPN::GameInfo &game : self.allGames) { + if (game.isInLibrary) libraryCount++; + for (const std::string &storeValue : game.availableStores) { + if (storeValue.empty()) continue; + NSString *store = [NSString stringWithUTF8String:storeValue.c_str()]; + NSString *categoryId = OPNCategoryId(@"store", store); + if (categoryId.length == 0) continue; + storeCounts[categoryId] = @((storeCounts[categoryId] ?: @0).integerValue + 1); + if (!storeTitles[categoryId]) storeTitles[categoryId] = OPNStoreCategoryTitle(store); + } + for (const std::string &genreValue : game.genres) { + if (genreValue.empty()) continue; + NSString *genre = [NSString stringWithUTF8String:genreValue.c_str()]; + NSString *categoryId = OPNCategoryId(@"genre", genre); + if (categoryId.length == 0) continue; + genreCounts[categoryId] = @((genreCounts[categoryId] ?: @0).integerValue + 1); + if (!genreTitles[categoryId]) genreTitles[categoryId] = genre.capitalizedString; + } + } + + if (libraryCount > 0) [items addObject:@{@"id": @"library", @"title": @"Library"}]; + + NSArray *sortedStoreIds = [[storeCounts allKeys] sortedArrayUsingComparator:^NSComparisonResult(NSString *a, NSString *b) { + NSInteger countA = storeCounts[a].integerValue; + NSInteger countB = storeCounts[b].integerValue; + if (countA != countB) return countA > countB ? NSOrderedAscending : NSOrderedDescending; + return [storeTitles[a] localizedCaseInsensitiveCompare:storeTitles[b]]; + }]; + for (NSString *categoryId in sortedStoreIds) { + if (items.count >= 9) break; + NSString *title = storeTitles[categoryId]; + if (title.length > 0) [items addObject:@{@"id": categoryId, @"title": title}]; + } + + NSArray *sortedGenreIds = [[genreCounts allKeys] sortedArrayUsingComparator:^NSComparisonResult(NSString *a, NSString *b) { + NSInteger countA = genreCounts[a].integerValue; + NSInteger countB = genreCounts[b].integerValue; + if (countA != countB) return countA > countB ? NSOrderedAscending : NSOrderedDescending; + return [genreTitles[a] localizedCaseInsensitiveCompare:genreTitles[b]]; + }]; + for (NSString *categoryId in sortedGenreIds) { + if (items.count >= 12) break; + NSString *title = genreTitles[categoryId]; + if (title.length > 0) [items addObject:@{@"id": categoryId, @"title": title}]; + } + + BOOL selectedStillExists = NO; + for (NSDictionary *item in items) { + if ([item[@"id"] isEqualToString:self.selectedCategoryId]) selectedStillExists = YES; + } + if (!selectedStillExists) self.selectedCategoryId = @"all"; + + self.categoryItems = items; + for (NSView *view in self.categoryBarView.subviews) [view removeFromSuperview]; + [self.categoryButtons removeAllObjects]; + + CGFloat x = 0.0; + for (NSDictionary *item in items) { + NSString *title = item[@"title"] ?: @""; + CGFloat buttonWidth = MIN(130.0, MAX(60.0, title.length * 8.4 + 28.0)); + NSButton *button = [[NSButton alloc] initWithFrame:NSMakeRect(x, 0.0, buttonWidth, 30.0)]; + button.title = title; + button.identifier = item[@"id"] ?: @"all"; + button.bordered = NO; + button.font = [NSFont systemFontOfSize:13.0 weight:NSFontWeightSemibold]; + button.target = self; + button.action = @selector(categoryButtonClicked:); + button.wantsLayer = YES; + button.layer.cornerRadius = 15.0; + [self.categoryBarView addSubview:button]; + [self.categoryButtons addObject:button]; + x += buttonWidth + 8.0; + } +} + +- (void)categoryButtonClicked:(NSButton *)sender { + NSString *categoryId = sender.identifier.length > 0 ? sender.identifier : @"all"; + if ([categoryId isEqualToString:self.selectedCategoryId]) return; + self.selectedCategoryId = categoryId; + self.focusedCardIndex = 0; + if (OpnControllerModeEnabled()) OpnPlayConsoleTone(OPNConsoleToneChange); + [self renderGrid]; + [self scrollLibraryToTop]; +} + +- (BOOL)game:(const OPN::GameInfo &)game matchesCategory:(NSString *)categoryId { + if (categoryId.length == 0 || [categoryId isEqualToString:@"all"]) return YES; + if ([categoryId isEqualToString:@"favorites"]) return [self isFavoriteGame:game]; + if ([categoryId isEqualToString:@"library"]) return game.isInLibrary; + if ([categoryId hasPrefix:@"store:"]) { + for (const std::string &storeValue : game.availableStores) { + NSString *store = [NSString stringWithUTF8String:storeValue.c_str()]; + if ([OPNCategoryId(@"store", store) isEqualToString:categoryId]) return YES; + } + return NO; + } + if ([categoryId hasPrefix:@"genre:"]) { + for (const std::string &genreValue : game.genres) { + NSString *genre = [NSString stringWithUTF8String:genreValue.c_str()]; + if ([OPNCategoryId(@"genre", genre) isEqualToString:categoryId]) return YES; + } + return NO; + } + return YES; +} + +- (NSString *)favoriteIdentifierForGame:(const OPN::GameInfo &)game { + if (!game.id.empty()) return [NSString stringWithUTF8String:game.id.c_str()]; + if (!game.uuid.empty()) return [NSString stringWithUTF8String:game.uuid.c_str()]; + if (!game.launchAppId.empty()) return [NSString stringWithUTF8String:game.launchAppId.c_str()]; + return OPNCatalogString(game.title, @""); +} + +- (BOOL)isFavoriteGame:(const OPN::GameInfo &)game { + NSString *identifier = [self favoriteIdentifierForGame:game]; + return identifier.length > 0 && [self.favoriteGameIds containsObject:identifier]; +} + +- (void)persistFavoriteGameIds { + NSArray *sortedIds = [[self.favoriteGameIds allObjects] sortedArrayUsingSelector:@selector(localizedCaseInsensitiveCompare:)]; + [NSUserDefaults.standardUserDefaults setObject:sortedIds forKey:OPNFavoriteGameIdsDefaultsKey]; + [NSUserDefaults.standardUserDefaults synchronize]; +} + +- (void)toggleFavoriteForFocusedGame { + OPNGameCardView *card = [self focusedCard]; + if (!card) return; + NSString *identifier = [self favoriteIdentifierForGame:card.game]; + if (identifier.length == 0) return; + if ([self.favoriteGameIds containsObject:identifier]) { + [self.favoriteGameIds removeObject:identifier]; + } else { + [self.favoriteGameIds addObject:identifier]; + } + [self persistFavoriteGameIds]; + if (OpnControllerModeEnabled()) OpnPlayConsoleTone(OPNConsoleToneChange); + [self rebuildCategoryBar]; + if ([self.selectedCategoryId isEqualToString:@"favorites"] && ![self isFavoriteGame:card.game]) { + self.focusedCardIndex = MIN(self.focusedCardIndex, MAX(0, (NSInteger)self.cardViews.count - 2)); + [self renderGrid]; + } else { + [self updateControllerDetailContent]; + [self layoutCatalogSubviews]; + } +} + +- (void)cycleCategoryBy:(NSInteger)delta { + if (self.categoryItems.count <= 1 || delta == 0) return; + NSInteger currentIndex = 0; + for (NSUInteger i = 0; i < self.categoryItems.count; i++) { + if ([self.categoryItems[i][@"id"] isEqualToString:self.selectedCategoryId]) { + currentIndex = (NSInteger)i; + break; + } + } + NSInteger nextIndex = (currentIndex + delta) % (NSInteger)self.categoryItems.count; + if (nextIndex < 0) nextIndex += (NSInteger)self.categoryItems.count; + self.selectedCategoryId = self.categoryItems[(NSUInteger)nextIndex][@"id"] ?: @"all"; + self.focusedCardIndex = 0; + if (OpnControllerModeEnabled()) OpnPlayConsoleTone(OPNConsoleToneChange); [self renderGrid]; [self scrollLibraryToTop]; } @@ -635,10 +1005,13 @@ - (void)renderGrid { self.gridColumnCount = cols; CGFloat gridSpacing = controllerMode ? 26.0 : (cols > 1 ? floor((availableWidth - cols * cardWidth) / (cols - 1)) : kCardSpacing); gridSpacing = MAX(kCardSpacing, gridSpacing); - CGFloat xStart = controllerMode ? 32.0 : (cols > 1 ? 0.0 : floor(MAX(0.0, (_scrollView.frame.size.width - cardWidth) / 2.0))); - CGFloat yPos = controllerMode ? 24.0 : kGridPadding; + CGFloat xStart = controllerMode ? 64.0 : (cols > 1 ? 0.0 : floor(MAX(0.0, (_scrollView.frame.size.width - cardWidth) / 2.0))); + CGFloat yPos = controllerMode ? 34.0 : kGridPadding; - std::vector displayGames = _allGames; + std::vector displayGames; + for (const OPN::GameInfo &game : _allGames) { + if ([self game:game matchesCategory:self.selectedCategoryId]) displayGames.push_back(game); + } NSInteger col = 0; NSInteger visibleCount = 0; @@ -674,7 +1047,7 @@ - (void)renderGrid { } } - CGFloat totalHeight = controllerMode ? cardHeight + 48.0 : yPos + cardHeight + kGridPadding; + CGFloat totalHeight = controllerMode ? cardHeight + 104.0 : yPos + cardHeight + kGridPadding; if (!controllerMode && col == 0 && visibleCount > 0) totalHeight = yPos + kGridPadding; CGFloat totalWidth = controllerMode ? xStart * 2.0 + visibleCount * cardWidth + MAX(0, visibleCount - 1) * gridSpacing @@ -808,13 +1181,18 @@ - (void)layoutCatalogSubviews { CGFloat width = NSWidth(self.bounds); CGFloat height = NSHeight(self.bounds); BOOL controllerMode = OpnControllerModeEnabled(); - CGFloat controllerNavHeight = 136.0; + CGFloat controllerNavHeight = 118.0; self.controllerElectricBackgroundView.hidden = !controllerMode || self.cardViews.count == 0; self.controllerElectricBackgroundView.frame = controllerMode ? NSMakeRect(0.0, controllerNavHeight, width, MAX(0.0, height - controllerNavHeight)) : self.bounds; self.scrollView.hasVerticalScroller = !controllerMode; self.scrollView.hasHorizontalScroller = NO; + self.scrollView.drawsBackground = NO; + self.scrollView.contentView.drawsBackground = NO; + self.scrollView.contentView.backgroundColor = NSColor.clearColor; + self.scrollView.contentView.layer.backgroundColor = NSColor.clearColor.CGColor; + self.gridContentView.layer.backgroundColor = NSColor.clearColor.CGColor; BOOL compact = width < 900.0; self.searchField.hidden = controllerMode; self.filterButton.hidden = controllerMode || compact; @@ -831,38 +1209,60 @@ - (void)layoutCatalogSubviews { self.gameCountLabel.frame = NSMakeRect(0, 0, 0, 0); self.gameCountLabel.hidden = YES; self.signOutButton.frame = NSMakeRect(width - 116, kNavHeight + 13, 92, 30); + self.categoryBarView.hidden = !controllerMode || self.categoryButtons.count <= 1; CGFloat cardHeight = [OPNGameCardView cardSize].height; - CGFloat minimumDetailHeight = 176.0; - CGFloat desiredCarouselHeight = cardHeight + 86.0; - CGFloat detailY = (controllerMode ? controllerNavHeight : kNavHeight) + 22.0; - CGFloat bottomInset = 56.0; - CGFloat detailGap = 24.0; - CGFloat availableForControllerContent = MAX(0.0, height - detailY - bottomInset); + CGFloat minimumDetailHeight = 220.0; + CGFloat desiredCarouselHeight = cardHeight + 96.0; + CGFloat categoryY = controllerNavHeight + 10.0; + CGFloat railY = controllerMode && self.categoryButtons.count > 1 ? categoryY + 40.0 : controllerNavHeight + 10.0; + CGFloat bottomInset = 36.0; + CGFloat detailGap = 10.0; CGFloat carouselHeight = desiredCarouselHeight; + CGFloat detailY = railY + carouselHeight + detailGap; CGFloat detailHeight = 0.0; CGFloat gridY = kNavHeight + (compact ? 116.0 : kToolbarHeight); if (controllerMode) { - carouselHeight = MIN(desiredCarouselHeight, MAX(168.0, availableForControllerContent - minimumDetailHeight - detailGap)); - CGFloat naturalDetailHeight = availableForControllerContent - carouselHeight - detailGap; - detailHeight = MIN(440.0, MAX(190.0, naturalDetailHeight)); - gridY = MAX(detailY + detailHeight + detailGap, height - carouselHeight - bottomInset); + CGFloat availableContentHeight = MAX(0.0, height - railY - bottomInset); + carouselHeight = MIN(desiredCarouselHeight, MAX(cardHeight + 62.0, availableContentHeight * 0.32)); + detailY = railY + carouselHeight + detailGap; + detailHeight = MAX(minimumDetailHeight, height - detailY - bottomInset); + gridY = railY; + } + if (controllerMode && !self.categoryBarView.hidden) { + self.categoryBarView.frame = NSMakeRect(64.0, categoryY, MAX(240.0, width - 128.0), 30.0); + CGFloat categoryX = 0.0; + for (NSButton *button in self.categoryButtons) { + BOOL selected = [button.identifier isEqualToString:self.selectedCategoryId]; + CGFloat buttonWidth = NSWidth(button.frame); + button.frame = NSMakeRect(categoryX, 0.0, buttonWidth, 30.0); + button.contentTintColor = selected ? OpnColor(0x07101E) : OpnColor(kTextSecondary); + button.font = [NSFont systemFontOfSize:12.0 weight:NSFontWeightSemibold]; + button.layer.cornerRadius = 15.0; + button.layer.backgroundColor = selected ? OpnColor(0xFFFFFF, 0.88).CGColor : OpnColor(0xFFFFFF, 0.080).CGColor; + button.layer.borderWidth = selected ? 0.0 : 1.0; + button.layer.borderColor = OpnColor(0xFFFFFF, 0.12).CGColor; + categoryX += buttonWidth + 8.0; + } } self.controllerDetailView.hidden = !controllerMode || self.cardViews.count == 0; - self.controllerDetailView.frame = NSMakeRect(28.0, detailY, MAX(260.0, width - 56.0), detailHeight); - self.controllerDetailView.layer.shadowPath = [NSBezierPath bezierPathWithRoundedRect:self.controllerDetailView.bounds xRadius:26.0 yRadius:26.0].CGPath; + self.controllerDetailView.frame = NSMakeRect(0.0, detailY, width, detailHeight); + self.controllerDetailView.layer.shadowPath = [NSBezierPath bezierPathWithRoundedRect:self.controllerDetailView.bounds xRadius:30.0 yRadius:30.0].CGPath; CGFloat detailWidth = NSWidth(self.controllerDetailView.frame); self.controllerDetailGradientLayer.frame = self.controllerDetailView.bounds; - self.controllerDetailAccentLayer.frame = NSMakeRect(32.0, 24.0, 78.0, 4.0); - BOOL compactDetail = detailHeight < 210.0; - self.controllerDetailTitleLabel.font = [NSFont systemFontOfSize:compactDetail ? 30.0 : 42.0 weight:NSFontWeightSemibold]; - self.controllerDetailTitleLabel.frame = NSMakeRect(32.0, compactDetail ? 18.0 : 28.0, MAX(220.0, detailWidth - 64.0), compactDetail ? 38.0 : 52.0); - self.controllerDetailMetaLabel.frame = NSMakeRect(34.0, compactDetail ? 64.0 : 88.0, MAX(220.0, detailWidth - 68.0), 22.0); - self.controllerDetailStoreLabel.frame = NSMakeRect(34.0, compactDetail ? 94.0 : 122.0, MAX(220.0, detailWidth - 68.0), 24.0); - self.controllerDetailStatsLabel.frame = NSMakeRect(34.0, compactDetail ? 122.0 : 156.0, MAX(220.0, detailWidth - 68.0), 22.0); - CGFloat featuresY = compactDetail ? 0.0 : 192.0; - self.controllerDetailFeaturesLabel.hidden = compactDetail; - self.controllerDetailFeaturesLabel.frame = NSMakeRect(34.0, featuresY, MAX(220.0, detailWidth - 68.0), MAX(0.0, detailHeight - featuresY - 58.0)); - self.controllerDetailHintLabel.frame = NSMakeRect(34.0, MAX(146.0, detailHeight - 34.0), MAX(220.0, detailWidth - 68.0), 18.0); + self.controllerDetailAccentLayer.frame = NSMakeRect(64.0, 18.0, 74.0, 3.0); + BOOL compactDetail = detailHeight < 260.0; + CGFloat heroX = 64.0; + CGFloat heroWidth = MAX(260.0, detailWidth - 128.0); + self.controllerDetailTitleLabel.font = [NSFont systemFontOfSize:compactDetail ? 40.0 : 58.0 weight:NSFontWeightSemibold]; + self.controllerDetailTitleLabel.frame = NSMakeRect(heroX, compactDetail ? 20.0 : 26.0, heroWidth, compactDetail ? 50.0 : 70.0); + self.controllerDetailMetaLabel.frame = NSMakeRect(heroX + 2.0, compactDetail ? 82.0 : 108.0, heroWidth, 24.0); + self.controllerDetailStoreLabel.frame = NSMakeRect(heroX + 2.0, compactDetail ? 114.0 : 142.0, heroWidth, 26.0); + self.controllerDetailStatsLabel.hidden = YES; + self.controllerDetailStatsLabel.frame = NSZeroRect; + CGFloat featuresY = compactDetail ? 146.0 : 184.0; + self.controllerDetailFeaturesLabel.hidden = NO; + self.controllerDetailFeaturesLabel.frame = NSMakeRect(heroX + 2.0, featuresY, MIN(980.0, heroWidth), MAX(0.0, detailHeight - featuresY - 88.0)); + self.controllerPromptBarView.frame = NSMakeRect(heroX + 2.0, MAX(188.0, detailHeight - 52.0), heroWidth, 36.0); self.scrollView.frame = controllerMode ? NSMakeRect(0, gridY, width, MIN(carouselHeight, MAX(0.0, height - gridY))) : NSMakeRect(0, gridY, width, MAX(0.0, height - gridY)); @@ -921,6 +1321,14 @@ - (void)focusCardAtIndex:(NSInteger)index scrollIntoView:(BOOL)scrollIntoView { OPNGameCardView *card = self.cardViews[(NSUInteger)clamped]; NSRect visibleRect = self.scrollView.contentView.bounds; NSRect targetRect = NSInsetRect(card.frame, -24.0, -24.0); + if (OpnControllerModeEnabled()) { + NSSize contentSize = self.gridContentView.frame.size; + CGFloat targetX = NSMidX(card.frame) - NSWidth(visibleRect) * 0.5; + targetX = MAX(0.0, MIN(targetX, MAX(0.0, contentSize.width - NSWidth(visibleRect)))); + [self.scrollView.contentView scrollToPoint:NSMakePoint(targetX, 0.0)]; + [self.scrollView reflectScrolledClipView:self.scrollView.contentView]; + return; + } if (!NSContainsRect(visibleRect, targetRect)) { [self.gridContentView scrollRectToVisible:targetRect]; [self.scrollView reflectScrolledClipView:self.scrollView.contentView]; @@ -933,7 +1341,10 @@ - (OPNGameCardView *)focusedCard { } - (void)moveFocusByRows:(NSInteger)rows columns:(NSInteger)columns { - if (OpnControllerModeEnabled() && rows != 0) return; + if (OpnControllerModeEnabled() && rows != 0) { + [self cycleCategoryBy:rows > 0 ? 1 : -1]; + return; + } NSInteger next = self.focusedCardIndex + rows * MAX(1, self.gridColumnCount) + columns; [self focusCardAtIndex:next scrollIntoView:YES]; } @@ -961,6 +1372,7 @@ - (void)updateControllerDetailContent { self.controllerDetailStoreLabel.stringValue = @""; self.controllerDetailStatsLabel.stringValue = @""; self.controllerDetailFeaturesLabel.stringValue = @""; + self.controllerPromptBarView.hidden = YES; return; } @@ -971,6 +1383,7 @@ - (void)updateControllerDetailContent { NSString *tier = OPNCatalogString(game.membershipTierLabel, @""); NSString *playability = OPNCatalogString(game.playabilityState, @""); NSMutableArray *meta = [NSMutableArray arrayWithObject:genres]; + if ([self isFavoriteGame:game]) [meta addObject:@"Favorite"]; if (tier.length > 0) [meta addObject:tier]; if (playability.length > 0) [meta addObject:playability.capitalizedString]; self.controllerDetailMetaLabel.stringValue = [meta componentsJoinedByString:@" • "]; @@ -981,26 +1394,17 @@ - (void)updateControllerDetailContent { } else if (!game.availableStores.empty()) { store = OPNCatalogString(game.availableStores.front(), store); } - NSString *storePrefix = game.variants.size() > 1 ? @"Selected store" : @"Store"; + NSString *storePrefix = game.variants.size() > 1 ? @"Selected Store" : @"Store"; self.controllerDetailStoreLabel.stringValue = [NSString stringWithFormat:@"%@: %@", storePrefix, store]; - NSMutableArray *stats = [NSMutableArray array]; - [stats addObject:game.isInLibrary ? @"In Library" : @"Catalog"]; - if (!game.playType.empty()) [stats addObject:OPNCatalogString(game.playType, @"").capitalizedString]; - if (!game.availableStores.empty()) { - [stats addObject:[NSString stringWithFormat:@"%lu %@", (unsigned long)game.availableStores.size(), game.availableStores.size() == 1 ? @"store" : @"stores"]]; - } - if (!game.variants.empty()) { - [stats addObject:[NSString stringWithFormat:@"%lu %@", (unsigned long)game.variants.size(), game.variants.size() == 1 ? @"launch option" : @"launch options"]]; - } - self.controllerDetailStatsLabel.stringValue = [stats componentsJoinedByString:@" • "]; - - NSString *features = OPNCatalogJoinedStrings(game.featureLabels, @""); - if (features.length == 0 && !game.shortName.empty()) features = [NSString stringWithFormat:@"%@ is ready to launch from the carousel.", OPNCatalogString(game.shortName, @"This game")]; - self.controllerDetailFeaturesLabel.stringValue = features.length > 0 ? features : @"Ready to stream. Press Cross / A to launch this game, or use Triangle / Y when multiple stores are available."; - self.controllerDetailHintLabel.stringValue = game.variants.size() > 1 - ? @"✕ Play △ Change Store L1/R1 Menu Options Account" - : @"✕ Play L1/R1 Menu Options Account"; + self.controllerDetailStatsLabel.stringValue = @""; + NSString *description = OPNCatalogString(game.description, @""); + if (description.length == 0) description = OPNCatalogJoinedStrings(game.featureLabels, @""); + if (description.length == 0) description = @"No description available."; + self.controllerDetailFeaturesLabel.stringValue = description; + self.controllerPromptBarView.hidden = NO; + self.controllerPromptBarView.includeStore = game.variants.size() > 1; + self.controllerPromptBarView.includeBack = NO; } - (void)openFocusedGameDetails { @@ -1010,7 +1414,7 @@ - (void)openFocusedGameDetails { NSView *overlay = [[NSView alloc] initWithFrame:self.bounds]; overlay.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; overlay.wantsLayer = YES; - overlay.layer.backgroundColor = OpnColor(kBlack, 0.58).CGColor; + overlay.layer.backgroundColor = OpnColor(kBlack, 0.62).CGColor; CGFloat panelWidth = MIN(760.0, MAX(420.0, NSWidth(self.bounds) - 96.0)); CGFloat panelHeight = 390.0; @@ -1021,11 +1425,11 @@ - (void)openFocusedGameDetails { panel.wantsLayer = YES; panel.layer.cornerRadius = 30.0; panel.layer.borderWidth = 1.5; - panel.layer.borderColor = OpnColor(kBrandGreen, 0.58).CGColor; - panel.layer.backgroundColor = OpnColor(0x080A10, 0.94).CGColor; - panel.layer.shadowColor = OpnColor(kBrandGreen).CGColor; - panel.layer.shadowOpacity = 0.34; - panel.layer.shadowRadius = 42.0; + panel.layer.borderColor = OpnColor(0xFFFFFF, 0.22).CGColor; + panel.layer.backgroundColor = OpnColor(0x07101E, 0.96).CGColor; + panel.layer.shadowColor = OpnColor(kConsoleBlue).CGColor; + panel.layer.shadowOpacity = 0.28; + panel.layer.shadowRadius = 48.0; panel.layer.shadowOffset = CGSizeZero; [overlay addSubview:panel]; @@ -1038,21 +1442,21 @@ - (void)openFocusedGameDetails { if (card.selectedVariantIndex >= 0 && card.selectedVariantIndex < (int)card.game.variants.size()) { store = OPNCatalogString(card.game.variants[(size_t)card.selectedVariantIndex].appStore, store); } - NSTextField *storeLabel = OpnLabel([NSString stringWithFormat:@"Selected store: %@", store], + NSTextField *storeLabel = OpnLabel([NSString stringWithFormat:@"Selected Store: %@", store], NSMakeRect(38.0, 92.0, panelWidth - 76.0, 24.0), 15.0, - OpnColor(kBrandGreen), + OpnColor(kConsoleBlueSoft), NSFontWeightSemibold); [panel addSubview:storeLabel]; NSString *body = card.game.variants.size() > 1 - ? @"Press Triangle / Y to cycle stores. Press Cross / A to launch. Press Circle / B to return to the library." - : @"Press Cross / A to launch. Press Circle / B to return to the library."; + ? @"Use Favorite to save this game. Use Store to cycle available stores, Play to launch, or Back to return to the library." + : @"Use Favorite to save this game, Play to launch, or Back to return to the library."; NSTextField *bodyLabel = OpnLabel(body, NSMakeRect(38.0, 136.0, panelWidth - 76.0, 58.0), 14.0, OpnColor(kTextSecondary), NSFontWeightRegular); bodyLabel.maximumNumberOfLines = 3; [panel addSubview:bodyLabel]; - NSButton *playButton = OpnButton(@"Play", NSMakeRect(38.0, panelHeight - 96.0, 180.0, 52.0), OpnColor(kBrandGreen, 0.96), OpnColor(kAccentOn)); + NSButton *playButton = OpnButton(@"Play", NSMakeRect(38.0, panelHeight - 96.0, 180.0, 52.0), OpnColor(kConsoleBlueSoft, 0.96), OpnColor(0x06101F)); playButton.target = self; playButton.action = @selector(detailsPlayClicked:); playButton.layer.cornerRadius = 18.0; @@ -1064,7 +1468,9 @@ - (void)openFocusedGameDetails { closeButton.layer.cornerRadius = 18.0; [panel addSubview:closeButton]; - NSTextField *hints = OpnLabel(@"✕ Play △ Change Store ○ Back", NSMakeRect(38.0, panelHeight - 34.0, panelWidth - 76.0, 20.0), 12.0, OpnColor(kTextMuted), NSFontWeightMedium); + OPNControllerPromptBarView *hints = [[OPNControllerPromptBarView alloc] initWithFrame:NSMakeRect(38.0, panelHeight - 44.0, panelWidth - 76.0, 36.0)]; + hints.includeStore = card.game.variants.size() > 1; + hints.includeBack = YES; [panel addSubview:hints]; self.detailsOverlayView = overlay; @@ -1115,7 +1521,11 @@ - (void)keyDown:(NSEvent *)event { default: break; } - if ([chars isEqualToString:@"v"] || [chars isEqualToString:@"y"]) { + if ([chars isEqualToString:@"y"] || [chars isEqualToString:@"f"]) { + [self toggleFavoriteForFocusedGame]; + return; + } + if ([chars isEqualToString:@"x"] || [chars isEqualToString:@"s"] || [chars isEqualToString:@"v"]) { [self cycleFocusedVariant]; return; } @@ -1166,11 +1576,12 @@ - (void)pollGamepadNavigation { [self launchFocusedGame]; } if (pressed & (1u << 1)) { } - if (pressed & (1u << 2)) [self cycleFocusedVariant]; + if (pressed & (1u << 2)) [self toggleFavoriteForFocusedGame]; if (pressed & (1u << 5)) [self moveFocusByRows:-1 columns:0]; if (pressed & (1u << 6)) [self moveFocusByRows:1 columns:0]; if (pressed & (1u << 7)) [self moveFocusByRows:0 columns:-1]; if (pressed & (1u << 8)) [self moveFocusByRows:0 columns:1]; + if (pressed & (1u << 9)) [self cycleFocusedVariant]; self.previousGamepadButtons = buttons; } diff --git a/src/views/OPNSettingsView.mm b/src/views/OPNSettingsView.mm index 92792d2fb..8d6a9831e 100644 --- a/src/views/OPNSettingsView.mm +++ b/src/views/OPNSettingsView.mm @@ -742,7 +742,7 @@ - (void)buildInterfaceContent { [panel addSubview:[self rowLabel:@"Controller Mode" y:104.0]]; NSButton *controllerModeToggle = [[NSButton alloc] initWithFrame:NSMakeRect(controlX, 96.0, controlWidth, 28.0)]; controllerModeToggle.buttonType = NSButtonTypeSwitch; - controllerModeToggle.title = @"Use console-style menus optimized for gamepad navigation"; + controllerModeToggle.title = @"Use a green glass console home optimized for gamepad navigation"; controllerModeToggle.font = [NSFont systemFontOfSize:13.0 weight:NSFontWeightMedium]; controllerModeToggle.contentTintColor = OpnColor(kBrandGreen); controllerModeToggle.state = OpnControllerModeEnabled() ? NSControlStateValueOn : NSControlStateValueOff; @@ -750,8 +750,8 @@ - (void)buildInterfaceContent { controllerModeToggle.action = @selector(controllerModeToggleChanged:); [panel addSubview:controllerModeToggle]; - NSTextField *controllerHint = OpnLabel(@"Controller Mode keeps mouse and keyboard support, but makes gamepad focus, details, and launch flow primary.", - NSMakeRect(controlX, 132.0, controlWidth, 38.0), + NSTextField *controllerHint = OpnLabel(@"Controller Mode keeps mouse and keyboard support while making the carousel, focus states, details, and launch flow feel like a TV console home.", + NSMakeRect(controlX, 132.0, controlWidth, 38.0), 12.0, OpnColor(kTextMuted), NSFontWeightRegular); From ef3eac04713b32fd7801a3b4124c055f95896020 Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Tue, 12 May 2026 02:49:09 -0500 Subject: [PATCH 14/18] Use accent color in controller mode --- src/common/OPNUIHelpers.h | 1 + src/common/OPNUIHelpers.mm | 6 +-- src/views/OPNBackdropView.mm | 62 ++++++++++++++------------ src/views/OPNGameCardView.mm | 38 +++++++++------- src/views/OPNGameCatalogView.mm | 78 +++++++++++++++++++++------------ 5 files changed, 110 insertions(+), 75 deletions(-) diff --git a/src/common/OPNUIHelpers.h b/src/common/OPNUIHelpers.h index 05a57cf48..f495d249c 100644 --- a/src/common/OPNUIHelpers.h +++ b/src/common/OPNUIHelpers.h @@ -3,6 +3,7 @@ #import NSColor *OpnColor(unsigned rgb, CGFloat alpha = 1.0); +unsigned OpnBlendRGB(unsigned rgb, unsigned target, CGFloat amount); extern NSString *const OPNInterfacePreferencesDidChangeNotification; diff --git a/src/common/OPNUIHelpers.mm b/src/common/OPNUIHelpers.mm index 301683072..5d1109326 100644 --- a/src/common/OPNUIHelpers.mm +++ b/src/common/OPNUIHelpers.mm @@ -19,7 +19,7 @@ static int OPNClampedColorByte(NSInteger value) { return (int)MAX(0, MIN(value, 255)); } -static unsigned OPNBlendRGB(unsigned rgb, unsigned target, CGFloat amount) { +unsigned OpnBlendRGB(unsigned rgb, unsigned target, CGFloat amount) { amount = MAX(0.0, MIN(amount, 1.0)); int r = (int)std::round(((rgb >> 16) & 0xFF) * (1.0 - amount) + ((target >> 16) & 0xFF) * amount); int g = (int)std::round(((rgb >> 8) & 0xFF) * (1.0 - amount) + ((target >> 8) & 0xFF) * amount); @@ -204,8 +204,8 @@ static unsigned OpnResolvedInterfaceColor(unsigned rgb) { unsigned accent = OpnCurrentAccentRGB(); switch (rgb) { case OPN::kBrandGreen: return accent; - case OPN::kBrandGreenHover: return OPNBlendRGB(accent, 0xFFFFFF, 0.16); - case OPN::kBrandGreenPress: return OPNBlendRGB(accent, 0x000000, 0.18); + case OPN::kBrandGreenHover: return OpnBlendRGB(accent, 0xFFFFFF, 0.16); + case OPN::kBrandGreenPress: return OpnBlendRGB(accent, 0x000000, 0.18); case OPN::kAccentOn: { CGFloat r = ((accent >> 16) & 0xFF) / 255.0; CGFloat g = ((accent >> 8) & 0xFF) / 255.0; diff --git a/src/views/OPNBackdropView.mm b/src/views/OPNBackdropView.mm index 21f446572..6943aa6d3 100644 --- a/src/views/OPNBackdropView.mm +++ b/src/views/OPNBackdropView.mm @@ -17,9 +17,17 @@ static BOOL OPNBackdropControllerNavigationActive(NSView *view) { return window.contentView == view || [view isDescendantOf:window.contentView]; } -static const unsigned kControllerConsoleBlue = 0x34C759; -static const unsigned kControllerConsoleBlueSoft = 0xA7F3BF; -static const unsigned kControllerConsoleDeepBlue = 0x06140A; +static unsigned OPNControllerAccentRGB(void) { + return OpnCurrentAccentRGB(); +} + +static unsigned OPNControllerAccentSoftRGB(void) { + return OpnBlendRGB(OpnCurrentAccentRGB(), 0xFFFFFF, 0.42); +} + +static unsigned OPNControllerAccentBlackRGB(CGFloat blackMix) { + return OpnBlendRGB(OpnCurrentAccentRGB(), 0x000000, blackMix); +} @implementation OPNBackdropView { NSRect _storeNavFrame; @@ -246,9 +254,9 @@ - (void)drawRect:(NSRect)dirtyRect { NSGradient *edgeWash = controllerMode ? [[NSGradient alloc] initWithColors:@[ - OpnColor(0x06140A, 1.0), - OpnColor(0x0D3516, 1.0), - OpnColor(0x061C0C, 1.0), + OpnColor(OPNControllerAccentBlackRGB(0.95), 1.0), + OpnColor(OPNControllerAccentBlackRGB(0.90), 1.0), + OpnColor(OPNControllerAccentBlackRGB(0.97), 1.0), ]] : [[NSGradient alloc] initWithColors:@[ OpnColor(kBackgroundB, 0.94), @@ -265,26 +273,22 @@ - (void)drawRect:(NSRect)dirtyRect { NSBezierPath *lowerGlow = [NSBezierPath bezierPathWithOvalInRect: NSMakeRect(NSWidth(bounds) - 500.0, NSHeight(bounds) - 360.0, 520.0, 520.0)]; - [OpnColor(kLinkBlue, 0.045) setFill]; + [OpnColor(kBrandGreen, 0.045) setFill]; [lowerGlow fill]; } - if (controllerMode) { - NSBezierPath *horizon = [NSBezierPath bezierPathWithRoundedRect:NSMakeRect(24.0, 117.0, NSWidth(bounds) - 48.0, 1.0) xRadius:0.5 yRadius:0.5]; - [OpnColor(kControllerConsoleBlueSoft, 0.22) setFill]; - [horizon fill]; - } - if (self.mode == OPNBackdropModeAuth) { return; } CGFloat navHeight = controllerMode ? 118.0 : 64.0; NSRect navRect = NSMakeRect(0, 0, NSWidth(bounds), navHeight); - [controllerMode ? OpnColor(0x0B2A12, 0.92) : OpnColor(0x1C1D21, 0.82) setFill]; + [controllerMode ? OpnColor(OPNControllerAccentBlackRGB(0.88), 0.92) : OpnColor(0x1C1D21, 0.82) setFill]; NSRectFill(navRect); - [OpnColor(0xFFFFFF, 0.08) setFill]; - NSRectFill(NSMakeRect(0, navHeight - 1.0, NSWidth(bounds), 1)); + if (!controllerMode) { + [OpnColor(0xFFFFFF, 0.08) setFill]; + NSRectFill(NSMakeRect(0, navHeight - 1.0, NSWidth(bounds), 1)); + } if (!controllerMode) { [@"OpenNOW" drawInRect:NSMakeRect(32.0, 21.0, 132, 22) @@ -296,7 +300,7 @@ - (void)drawRect:(NSRect)dirtyRect { timeFormatter.dateFormat = @"h:mm a"; NSString *timeText = [[timeFormatter stringFromDate:NSDate.date] uppercaseString]; NSBezierPath *timeGlow = [NSBezierPath bezierPathWithRoundedRect:NSMakeRect(20.0, 32.0, 128.0, 30.0) xRadius:15.0 yRadius:15.0]; - [OpnColor(0xFFFFFF, 0.055) setFill]; + [OpnColor(OPNControllerAccentRGB(), 0.055) setFill]; [timeGlow fill]; [timeText drawInRect:NSMakeRect(32.0, 40.0, 112.0, 18.0) withAttributes:OpnTextStyle(13.0, OpnColor(kTextSecondary), NSFontWeightSemibold)]; @@ -310,7 +314,7 @@ - (void)drawRect:(NSRect)dirtyRect { CGFloat navRowY = controllerMode ? 64.0 : 15.0; NSRect segmentedRect = NSMakeRect(x - 8.0, navRowY, navWidth + 16.0, controllerMode ? 42.0 : 34.0); NSBezierPath *segmented = [NSBezierPath bezierPathWithRoundedRect:segmentedRect xRadius:controllerMode ? 21.0 : 10.0 yRadius:controllerMode ? 21.0 : 10.0]; - [controllerMode ? OpnColor(0xFFFFFF, 0.055) : OpnColor(0xFFFFFF, 0.055) setFill]; + [controllerMode ? OpnColor(OPNControllerAccentRGB(), 0.055) : OpnColor(0xFFFFFF, 0.055) setFill]; [segmented fill]; if (controllerMode) { [OpnColor(0xFFFFFF, 0.18) setStroke]; @@ -329,10 +333,10 @@ - (void)drawRect:(NSRect)dirtyRect { if ([item isEqualToString:@"Settings"]) _settingsNavFrame = itemRect; if (active) { NSBezierPath *pill = [NSBezierPath bezierPathWithRoundedRect:itemRect xRadius:controllerMode ? 17.0 : 8.0 yRadius:controllerMode ? 17.0 : 8.0]; - [controllerMode ? OpnColor(0xFFFFFF, 0.20) : OpnColor(0xFFFFFF, 0.14) setFill]; + [controllerMode ? OpnColor(OPNControllerAccentRGB(), 0.20) : OpnColor(0xFFFFFF, 0.14) setFill]; [pill fill]; if (controllerMode) { - [OpnColor(kControllerConsoleBlueSoft, 0.66) setStroke]; + [OpnColor(OPNControllerAccentSoftRGB(), 0.66) setStroke]; pill.lineWidth = 1.0; [pill stroke]; } @@ -351,10 +355,10 @@ - (void)drawRect:(NSRect)dirtyRect { CGFloat controllerStatsX = MAX(NSMaxX(segmentedRect) + 18.0, NSWidth(bounds) - controllerStatsWidth - 28.0); NSRect planRect = controllerMode ? NSMakeRect(controllerStatsX, 72.0, 132.0, 26.0) : NSMakeRect(NSWidth(bounds) - 294, 11.0, 108, 26); NSBezierPath *planPill = [NSBezierPath bezierPathWithRoundedRect:planRect xRadius:14 yRadius:14]; - [controllerMode ? OpnColor(0xFFFFFF, 0.075) : OpnColor(0xFFFFFF, 0.075) setFill]; + [controllerMode ? OpnColor(OPNControllerAccentRGB(), 0.075) : OpnColor(0xFFFFFF, 0.075) setFill]; [planPill fill]; if (controllerMode) { - [OpnColor(kControllerConsoleBlueSoft, 0.24) setStroke]; + [OpnColor(OPNControllerAccentSoftRGB(), 0.24) setStroke]; planPill.lineWidth = 1.0; [planPill stroke]; } @@ -388,12 +392,12 @@ - (void)drawRect:(NSRect)dirtyRect { hints:@{NSImageHintInterpolation: @(NSImageInterpolationHigh)}]; [NSGraphicsContext restoreGraphicsState]; } else { - [OpnColor(kControllerConsoleBlueSoft, 0.90) setFill]; + [OpnColor(OPNControllerAccentSoftRGB(), 0.90) setFill]; [avatar fill]; NSString *initial = name.length > 0 ? [[name substringToIndex:1] uppercaseString] : @"U"; NSMutableParagraphStyle *avatarStyle = [[NSMutableParagraphStyle alloc] init]; avatarStyle.alignment = NSTextAlignmentCenter; - NSMutableDictionary *avatarAttrs = [OpnTextStyle(13, OpnColor(kControllerConsoleDeepBlue), NSFontWeightBold) mutableCopy]; + NSMutableDictionary *avatarAttrs = [OpnTextStyle(13, OpnColor(OPNControllerAccentBlackRGB(0.88)), NSFontWeightBold) mutableCopy]; avatarAttrs[NSParagraphStyleAttributeName] = avatarStyle; [initial drawInRect:NSMakeRect(NSMinX(avatarRect), NSMinY(avatarRect) + 7, 30, 16) withAttributes:avatarAttrs]; } @@ -469,9 +473,9 @@ - (NSButton *)controllerAccountMenuButtonWithTitle:(NSString *)title button.identifier = identifier ?: @""; button.wantsLayer = YES; button.layer.cornerRadius = 14.0; - button.layer.backgroundColor = selected ? OpnColor(kControllerConsoleBlue, 0.20).CGColor : OpnColor(0xFFFFFF, 0.045).CGColor; + button.layer.backgroundColor = selected ? OpnColor(OPNControllerAccentRGB(), 0.20).CGColor : OpnColor(OPNControllerAccentRGB(), 0.045).CGColor; button.layer.borderWidth = selected ? 1.0 : 0.0; - button.layer.borderColor = OpnColor(kControllerConsoleBlueSoft, 0.52).CGColor; + button.layer.borderColor = OpnColor(OPNControllerAccentSoftRGB(), 0.52).CGColor; NSColor *textColor = warning ? OpnColor(0xFF8A8A) : (selected ? OpnColor(OPN::kTextPrimary) : OpnColor(OPN::kTextSecondary)); NSString *displayTitle = selected ? [NSString stringWithFormat:@"%@ Current", title] : title; NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; @@ -511,8 +515,8 @@ - (void)showControllerAccountMenu { menu.layer.cornerRadius = 24.0; menu.layer.borderWidth = 1.0; menu.layer.borderColor = OpnColor(0xFFFFFF, 0.18).CGColor; - menu.layer.backgroundColor = OpnColor(kControllerConsoleDeepBlue, 0.96).CGColor; - menu.layer.shadowColor = OpnColor(kControllerConsoleBlue).CGColor; + menu.layer.backgroundColor = OpnColor(OPNControllerAccentBlackRGB(0.88), 0.96).CGColor; + menu.layer.shadowColor = OpnColor(OPNControllerAccentRGB()).CGColor; menu.layer.shadowOpacity = 0.24; menu.layer.shadowRadius = 30.0; menu.layer.shadowOffset = CGSizeZero; @@ -545,7 +549,7 @@ - (void)showControllerAccountMenu { NSView *divider = [[NSView alloc] initWithFrame:NSMakeRect(18.0, y + 8.0, menuWidth - 36.0, 1.0)]; divider.wantsLayer = YES; - divider.layer.backgroundColor = OpnColor(0xFFFFFF, 0.10).CGColor; + divider.layer.backgroundColor = OpnColor(OPNControllerAccentSoftRGB(), 0.10).CGColor; [menu addSubview:divider]; y += 24.0; diff --git a/src/views/OPNGameCardView.mm b/src/views/OPNGameCardView.mm index bfdf8dde1..35a2b610c 100644 --- a/src/views/OPNGameCardView.mm +++ b/src/views/OPNGameCardView.mm @@ -8,9 +8,17 @@ static const CGFloat gImageHeight = gCardWidth; static const CGFloat gInfoHeight = 0.0; static const CGFloat gCardTotalHeight = gImageHeight + gInfoHeight; -static const unsigned kConsoleBlue = 0x34C759; -static const unsigned kConsoleBlueSoft = 0xA7F3BF; -static const unsigned kConsoleDeepBlue = 0x06140A; +static unsigned OPNControllerAccentRGB(void) { + return OpnCurrentAccentRGB(); +} + +static unsigned OPNControllerAccentSoftRGB(void) { + return OpnBlendRGB(OpnCurrentAccentRGB(), 0xFFFFFF, 0.42); +} + +static unsigned OPNControllerAccentBlackRGB(CGFloat blackMix) { + return OpnBlendRGB(OpnCurrentAccentRGB(), 0x000000, blackMix); +} static CGFloat OPNScaledCardWidth(void) { if (OpnControllerModeEnabled()) return gControllerCardWidth; return floor(gCardWidth * OpnPosterSizeScale()); @@ -99,7 +107,7 @@ static CGFloat OPNScaledCardHeight(void) { static NSColor *OPNStoreIconColor(NSString *name, BOOL selected) { (void)name; CGFloat alpha = selected ? 0.96 : 0.68; - return OpnColor(kConsoleBlueSoft, alpha); + return OpnColor(OPNControllerAccentSoftRGB(), alpha); } static NSFont *OPNStoreIconFont(NSString *glyph) { @@ -165,10 +173,10 @@ - (instancetype)initWithFrame:(NSRect)frame game:(const OPN::GameInfo &)game { self.layer.shadowOffset = CGSizeMake(0.0, 16.0); _reflectionLayer = [CALayer layer]; - _reflectionLayer.backgroundColor = OpnColor(kConsoleBlueSoft, 0.28).CGColor; + _reflectionLayer.backgroundColor = OpnColor(OPNControllerAccentSoftRGB(), 0.28).CGColor; _reflectionLayer.cornerRadius = 18.0; _reflectionLayer.opacity = 0.0; - _reflectionLayer.shadowColor = OpnColor(kConsoleBlueSoft).CGColor; + _reflectionLayer.shadowColor = OpnColor(OPNControllerAccentSoftRGB()).CGColor; _reflectionLayer.shadowOpacity = 0.68; _reflectionLayer.shadowRadius = 24.0; _reflectionLayer.shadowOffset = CGSizeZero; @@ -178,13 +186,13 @@ - (instancetype)initWithFrame:(NSRect)frame game:(const OPN::GameInfo &)game { _contentView.wantsLayer = YES; _contentView.layer.cornerRadius = 20.0; _contentView.layer.masksToBounds = YES; - _contentView.layer.backgroundColor = OpnColor(kConsoleDeepBlue, 0.84).CGColor; + _contentView.layer.backgroundColor = OpnColor(OPNControllerAccentBlackRGB(0.88), 0.84).CGColor; [self addSubview:_contentView]; _imageView = [[NSImageView alloc] initWithFrame:self.bounds]; _imageView.imageScaling = NSImageScaleProportionallyUpOrDown; _imageView.wantsLayer = YES; - _imageView.layer.backgroundColor = OpnColor(0x101827).CGColor; + _imageView.layer.backgroundColor = OpnColor(OPNControllerAccentBlackRGB(0.90)).CGColor; [_contentView addSubview:_imageView]; _playButton = [[NSButton alloc] initWithFrame: @@ -192,11 +200,11 @@ - (instancetype)initWithFrame:(NSRect)frame game:(const OPN::GameInfo &)game { _playButton.title = @"PLAY"; _playButton.bordered = NO; _playButton.font = [NSFont systemFontOfSize:12 weight:NSFontWeightBold]; - _playButton.contentTintColor = OpnColor(kConsoleDeepBlue); + _playButton.contentTintColor = OpnColor(OPNControllerAccentBlackRGB(0.88)); _playButton.wantsLayer = YES; _playButton.layer.cornerRadius = 17; - _playButton.layer.backgroundColor = OpnColor(0xFFFFFF, 0.94).CGColor; - _playButton.layer.shadowColor = OpnColor(kConsoleBlueSoft).CGColor; + _playButton.layer.backgroundColor = OpnColor(OPNControllerAccentSoftRGB(), 0.94).CGColor; + _playButton.layer.shadowColor = OpnColor(OPNControllerAccentSoftRGB()).CGColor; _playButton.layer.shadowOpacity = 0.18; _playButton.layer.shadowRadius = 14; _playButton.layer.shadowOffset = CGSizeZero; @@ -251,7 +259,7 @@ - (void)applyFocusStyle { self.layer.zPosition = selected ? 20.0 : 0.0; self.layer.borderColor = selected ? OpnColor(0xFFFFFF, 0.94).CGColor : OpnColor(0xFFFFFF, 0.13).CGColor; self.layer.borderWidth = selected ? 3.0 : 1.0; - self.layer.shadowColor = selected ? OpnColor(kConsoleBlueSoft).CGColor : NSColor.blackColor.CGColor; + self.layer.shadowColor = selected ? OpnColor(OPNControllerAccentSoftRGB()).CGColor : NSColor.blackColor.CGColor; self.layer.shadowOpacity = selected ? (controllerMode ? 0.28 : 0.58) : 0.34; self.layer.shadowRadius = selected ? (controllerMode ? 22.0 : 58.0) : 20.0; self.layer.shadowOffset = selected ? (controllerMode ? CGSizeMake(0.0, 12.0) : CGSizeMake(0.0, 28.0)) : CGSizeMake(0.0, 14.0); @@ -333,12 +341,12 @@ - (void)buildStoreChips { chip.toolTip = OPNStorePrettyName(name ?: @""); if (selected) { - chip.layer.backgroundColor = OpnColor(kConsoleBlue, 0.18).CGColor; + chip.layer.backgroundColor = OpnColor(OPNControllerAccentRGB(), 0.18).CGColor; chip.layer.borderWidth = 1.0; chip.layer.borderColor = OPNStoreIconColor(name, YES).CGColor; } else { - chip.layer.backgroundColor = OpnColor(kConsoleBlue, 0.08).CGColor; - chip.layer.borderColor = OpnColor(kConsoleBlue, 0.14).CGColor; + chip.layer.backgroundColor = OpnColor(OPNControllerAccentRGB(), 0.08).CGColor; + chip.layer.borderColor = OpnColor(OPNControllerAccentRGB(), 0.14).CGColor; chip.layer.borderWidth = 1; } diff --git a/src/views/OPNGameCatalogView.mm b/src/views/OPNGameCatalogView.mm index 89d510a22..33fda0bee 100644 --- a/src/views/OPNGameCatalogView.mm +++ b/src/views/OPNGameCatalogView.mm @@ -12,11 +12,20 @@ static const CGFloat kCardSpacing = 18.0; static const CGFloat kNavHeight = 62.0; static const CGFloat kToolbarHeight = 82.0; -static const unsigned kConsoleBlue = 0x34C759; -static const unsigned kConsoleBlueSoft = 0xA7F3BF; -static const unsigned kConsoleDeepBlue = 0x06140A; static NSString *const OPNFavoriteGameIdsDefaultsKey = @"OpenNOW.Library.FavoriteGameIds"; +static unsigned OPNControllerAccentRGB(void) { + return OpnCurrentAccentRGB(); +} + +static unsigned OPNControllerAccentSoftRGB(void) { + return OpnBlendRGB(OpnCurrentAccentRGB(), 0xFFFFFF, 0.42); +} + +static unsigned OPNControllerAccentBlackRGB(CGFloat blackMix) { + return OpnBlendRGB(OpnCurrentAccentRGB(), 0x000000, blackMix); +} + static NSString *OPNCatalogString(const std::string &value, NSString *fallback = @"") { return value.empty() ? fallback : [NSString stringWithUTF8String:value.c_str()]; } @@ -147,9 +156,9 @@ - (void)drawRect:(NSRect)dirtyRect { NSRect bounds = self.bounds; CGFloat phase = (CGFloat)(CACurrentMediaTime() - self.animationStartTime); NSGradient *base = [[NSGradient alloc] initWithColors:@[ - OpnColor(0x041006, 0.99), - OpnColor(0x0B2610, 0.99), - OpnColor(0x123D1A, 0.98), + OpnColor(OPNControllerAccentBlackRGB(0.95), 1.0), + OpnColor(OPNControllerAccentBlackRGB(0.90), 1.0), + OpnColor(OPNControllerAccentBlackRGB(0.97), 1.0), ]]; [base drawInRect:bounds angle:88.0]; @@ -169,7 +178,7 @@ - (void)drawRect:(NSRect)dirtyRect { + sin(t * 13.0 - phase * 0.20 + (CGFloat)band) * 5.0; [ribbon lineToPoint:NSMakePoint(x, y)]; } - NSColor *stroke = band % 3 == 0 ? OpnColor(0xFFFFFF, 0.038) : OpnColor(kConsoleBlueSoft, 0.044); + NSColor *stroke = band % 3 == 0 ? OpnColor(0xFFFFFF, 0.030) : OpnColor(OPNControllerAccentSoftRGB(), 0.038); [stroke setStroke]; ribbon.lineWidth = band == 4 ? 2.4 : 1.1; [ribbon stroke]; @@ -180,12 +189,12 @@ - (void)drawRect:(NSRect)dirtyRect { CGFloat y = fmod((CGFloat)(i * 43) + sin(phase * 0.24 + (CGFloat)i) * 18.0, MAX(1.0, height)); CGFloat radius = 0.7 + (CGFloat)(i % 3) * 0.32; NSBezierPath *spark = [NSBezierPath bezierPathWithOvalInRect:NSMakeRect(x, y, radius, radius)]; - [OpnColor(i % 5 == 0 ? 0xFFFFFF : kConsoleBlueSoft, i % 5 == 0 ? 0.10 : 0.07) setFill]; + [OpnColor(i % 5 == 0 ? 0xFFFFFF : OPNControllerAccentSoftRGB(), i % 5 == 0 ? 0.10 : 0.07) setFill]; [spark fill]; } - NSGradient *vignette = [[NSGradient alloc] initWithStartingColor:OpnColor(0x08220E, 0.0) - endingColor:OpnColor(0x041006, 0.42)]; + NSGradient *vignette = [[NSGradient alloc] initWithStartingColor:OpnColor(OPNControllerAccentBlackRGB(0.90), 0.0) + endingColor:OpnColor(OPNControllerAccentBlackRGB(0.99), 0.42)]; [vignette drawInRect:bounds angle:-90.0]; } @@ -474,9 +483,9 @@ - (void)drawRect:(NSRect)dirtyRect { CGFloat chipWidth = MAX(82.0, titleWidth + 50.0); NSRect chipRect = NSMakeRect(x, y, chipWidth, 34.0); NSBezierPath *chip = [NSBezierPath bezierPathWithRoundedRect:chipRect xRadius:17.0 yRadius:17.0]; - [OpnColor(0xFFFFFF, 0.070) setFill]; + [OpnColor(OPNControllerAccentRGB(), 0.070) setFill]; [chip fill]; - OPNStrokePath(chip, OpnColor(0xFFFFFF, 0.14), 1.0); + OPNStrokePath(chip, OpnColor(OPNControllerAccentSoftRGB(), 0.14), 1.0); NSImage *icon = OPNControllerPromptIcon(button, style); [icon drawInRect:NSMakeRect(NSMinX(chipRect) + 9.0, NSMinY(chipRect) + 5.0, 24.0, 24.0) @@ -561,7 +570,7 @@ - (instancetype)initWithFrame:(NSRect)frame { _searchField.appearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]; _searchField.wantsLayer = YES; _searchField.layer.cornerRadius = 14; - _searchField.layer.backgroundColor = OpnColor(0x15171C, 0.92).CGColor; + _searchField.layer.backgroundColor = OpnColor(OPNControllerAccentBlackRGB(0.88), 0.92).CGColor; _searchField.layer.borderColor = OpnColor(0xFFFFFF, 0.13).CGColor; _searchField.layer.borderWidth = 1; if ([_searchField.cell respondsToSelector:@selector(setDrawsBackground:)]) { @@ -647,8 +656,8 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailView.layer.cornerRadius = 0.0; _controllerDetailView.layer.borderWidth = 0.0; _controllerDetailView.layer.borderColor = OpnColor(0xFFFFFF, 0.0).CGColor; - _controllerDetailView.layer.backgroundColor = OpnColor(0x020611, 0.10).CGColor; - _controllerDetailView.layer.shadowColor = OpnColor(kConsoleBlue).CGColor; + _controllerDetailView.layer.backgroundColor = OpnColor(OPNControllerAccentBlackRGB(0.90), 0.10).CGColor; + _controllerDetailView.layer.shadowColor = OpnColor(OPNControllerAccentRGB()).CGColor; _controllerDetailView.layer.shadowOpacity = 0.0; _controllerDetailView.layer.shadowRadius = 0.0; _controllerDetailView.layer.shadowOffset = CGSizeZero; @@ -658,16 +667,16 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailView.layer.transform = detailTransform; _controllerDetailGradientLayer = [CAGradientLayer layer]; - _controllerDetailGradientLayer.colors = @[(id)OpnColor(kConsoleBlue, 0.16).CGColor, - (id)OpnColor(0xFFFFFF, 0.040).CGColor, - (id)OpnColor(kBlack, 0.0).CGColor]; + _controllerDetailGradientLayer.colors = @[(id)OpnColor(OPNControllerAccentRGB(), 0.16).CGColor, + (id)OpnColor(0xFFFFFF, 0.040).CGColor, + (id)OpnColor(OPNControllerAccentBlackRGB(0.96), 0.0).CGColor]; _controllerDetailGradientLayer.locations = @[@0.0, @0.46, @1.0]; _controllerDetailGradientLayer.startPoint = CGPointMake(0.0, 0.0); _controllerDetailGradientLayer.endPoint = CGPointMake(1.0, 1.0); [_controllerDetailView.layer addSublayer:_controllerDetailGradientLayer]; _controllerDetailAccentLayer = [CALayer layer]; - _controllerDetailAccentLayer.backgroundColor = OpnColor(kConsoleBlueSoft, 0.86).CGColor; + _controllerDetailAccentLayer.backgroundColor = OpnColor(OPNControllerAccentSoftRGB(), 0.86).CGColor; _controllerDetailAccentLayer.cornerRadius = 2.0; [_controllerDetailView.layer addSublayer:_controllerDetailAccentLayer]; [self addSubview:_controllerDetailView]; @@ -679,7 +688,7 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailMetaLabel = OpnLabel(@"", NSZeroRect, 15.0, OpnColor(kTextSecondary), NSFontWeightMedium); [_controllerDetailView addSubview:_controllerDetailMetaLabel]; - _controllerDetailStoreLabel = OpnLabel(@"", NSZeroRect, 16.0, OpnColor(kConsoleBlueSoft), NSFontWeightSemibold); + _controllerDetailStoreLabel = OpnLabel(@"", NSZeroRect, 16.0, OpnColor(OPNControllerAccentSoftRGB()), NSFontWeightSemibold); [_controllerDetailView addSubview:_controllerDetailStoreLabel]; _controllerDetailStatsLabel = OpnLabel(@"", NSZeroRect, 14.0, OpnColor(kTextSecondary), NSFontWeightMedium); @@ -730,9 +739,22 @@ - (void)viewDidMoveToWindow { } } +- (void)applyControllerAccentColors { + self.searchField.layer.backgroundColor = OpnColor(OPNControllerAccentBlackRGB(0.88), 0.92).CGColor; + self.controllerDetailView.layer.backgroundColor = OpnColor(OPNControllerAccentBlackRGB(0.90), 0.10).CGColor; + self.controllerDetailView.layer.shadowColor = OpnColor(OPNControllerAccentRGB()).CGColor; + self.controllerDetailGradientLayer.colors = @[(id)OpnColor(OPNControllerAccentRGB(), 0.16).CGColor, + (id)OpnColor(0xFFFFFF, 0.040).CGColor, + (id)OpnColor(OPNControllerAccentBlackRGB(0.96), 0.0).CGColor]; + self.controllerDetailAccentLayer.backgroundColor = OpnColor(OPNControllerAccentSoftRGB(), 0.86).CGColor; + self.controllerDetailStoreLabel.textColor = OpnColor(OPNControllerAccentSoftRGB()); + self.layer.backgroundColor = OpnControllerModeEnabled() ? OpnColor(OPNControllerAccentBlackRGB(0.92), 0.20).CGColor : [NSColor clearColor].CGColor; + [self.controllerElectricBackgroundView setNeedsDisplay:YES]; +} + - (void)interfacePreferencesChanged:(NSNotification *)notification { (void)notification; - self.layer.backgroundColor = OpnControllerModeEnabled() ? OpnColor(kConsoleDeepBlue, 0.20).CGColor : [NSColor clearColor].CGColor; + [self applyControllerAccentColors]; [self renderGrid]; [self startGamepadNavigationIfNeeded]; } @@ -1235,10 +1257,10 @@ - (void)layoutCatalogSubviews { BOOL selected = [button.identifier isEqualToString:self.selectedCategoryId]; CGFloat buttonWidth = NSWidth(button.frame); button.frame = NSMakeRect(categoryX, 0.0, buttonWidth, 30.0); - button.contentTintColor = selected ? OpnColor(0x07101E) : OpnColor(kTextSecondary); + button.contentTintColor = selected ? OpnColor(OPNControllerAccentBlackRGB(0.88)) : OpnColor(kTextSecondary); button.font = [NSFont systemFontOfSize:12.0 weight:NSFontWeightSemibold]; button.layer.cornerRadius = 15.0; - button.layer.backgroundColor = selected ? OpnColor(0xFFFFFF, 0.88).CGColor : OpnColor(0xFFFFFF, 0.080).CGColor; + button.layer.backgroundColor = selected ? OpnColor(OPNControllerAccentSoftRGB(), 0.88).CGColor : OpnColor(OPNControllerAccentRGB(), 0.080).CGColor; button.layer.borderWidth = selected ? 0.0 : 1.0; button.layer.borderColor = OpnColor(0xFFFFFF, 0.12).CGColor; categoryX += buttonWidth + 8.0; @@ -1414,7 +1436,7 @@ - (void)openFocusedGameDetails { NSView *overlay = [[NSView alloc] initWithFrame:self.bounds]; overlay.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; overlay.wantsLayer = YES; - overlay.layer.backgroundColor = OpnColor(kBlack, 0.62).CGColor; + overlay.layer.backgroundColor = OpnColor(OPNControllerAccentBlackRGB(0.96), 0.62).CGColor; CGFloat panelWidth = MIN(760.0, MAX(420.0, NSWidth(self.bounds) - 96.0)); CGFloat panelHeight = 390.0; @@ -1426,8 +1448,8 @@ - (void)openFocusedGameDetails { panel.layer.cornerRadius = 30.0; panel.layer.borderWidth = 1.5; panel.layer.borderColor = OpnColor(0xFFFFFF, 0.22).CGColor; - panel.layer.backgroundColor = OpnColor(0x07101E, 0.96).CGColor; - panel.layer.shadowColor = OpnColor(kConsoleBlue).CGColor; + panel.layer.backgroundColor = OpnColor(OPNControllerAccentBlackRGB(0.88), 0.96).CGColor; + panel.layer.shadowColor = OpnColor(OPNControllerAccentRGB()).CGColor; panel.layer.shadowOpacity = 0.28; panel.layer.shadowRadius = 48.0; panel.layer.shadowOffset = CGSizeZero; @@ -1445,7 +1467,7 @@ - (void)openFocusedGameDetails { NSTextField *storeLabel = OpnLabel([NSString stringWithFormat:@"Selected Store: %@", store], NSMakeRect(38.0, 92.0, panelWidth - 76.0, 24.0), 15.0, - OpnColor(kConsoleBlueSoft), + OpnColor(OPNControllerAccentSoftRGB()), NSFontWeightSemibold); [panel addSubview:storeLabel]; @@ -1456,7 +1478,7 @@ - (void)openFocusedGameDetails { bodyLabel.maximumNumberOfLines = 3; [panel addSubview:bodyLabel]; - NSButton *playButton = OpnButton(@"Play", NSMakeRect(38.0, panelHeight - 96.0, 180.0, 52.0), OpnColor(kConsoleBlueSoft, 0.96), OpnColor(0x06101F)); + NSButton *playButton = OpnButton(@"Play", NSMakeRect(38.0, panelHeight - 96.0, 180.0, 52.0), OpnColor(OPNControllerAccentSoftRGB(), 0.96), OpnColor(OPNControllerAccentBlackRGB(0.88))); playButton.target = self; playButton.action = @selector(detailsPlayClicked:); playButton.layer.cornerRadius = 18.0; From e32ec0a557d672e9b621ff961228111fdc213ea6 Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Tue, 12 May 2026 04:20:33 -0500 Subject: [PATCH 15/18] Refine controller mode background and animations --- Makefile | 2 +- src/OPNAppDelegate.mm | 9 + src/common/OPNCoreAnimationCoordinator.h | 35 +++ src/common/OPNCoreAnimationCoordinator.mm | 298 ++++++++++++++++++++++ src/common/OPNUIHelpers.h | 2 + src/common/OPNUIHelpers.mm | 13 + src/shaders/OPNPremiumBackdrop.metal | 97 +++++++ src/views/OPNBackdropView.h | 1 + src/views/OPNBackdropView.mm | 174 +++++++++++-- src/views/OPNCarouselFocusLayout.h | 14 + src/views/OPNCarouselFocusLayout.mm | 132 ++++++++++ src/views/OPNGameCardView.h | 2 + src/views/OPNGameCardView.mm | 40 +-- src/views/OPNGameCatalogView.h | 1 + src/views/OPNGameCatalogView.mm | 173 +++++++++++-- src/views/OPNSettingsView.mm | 53 ++-- 16 files changed, 968 insertions(+), 78 deletions(-) create mode 100644 src/common/OPNCoreAnimationCoordinator.h create mode 100644 src/common/OPNCoreAnimationCoordinator.mm create mode 100644 src/shaders/OPNPremiumBackdrop.metal create mode 100644 src/views/OPNCarouselFocusLayout.h create mode 100644 src/views/OPNCarouselFocusLayout.mm diff --git a/Makefile b/Makefile index 9a860b743..5c914992f 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ WEBRTC_LIBS := endif CXXFLAGS := -std=c++20 -Wall -Wextra -Wpedantic -Wno-deprecated-declarations -Wno-gnu-conditional-omitted-operand -fobjc-arc -Isrc $(WEBRTC_CFLAGS) -LDFLAGS := -framework Cocoa -framework QuartzCore -framework AuthenticationServices -framework AVFoundation -framework AVKit -framework CoreMedia -framework OpenGL -framework GameController -framework ApplicationServices -framework CoreAudio $(WEBRTC_LIBS) +LDFLAGS := -framework Cocoa -framework QuartzCore -framework Metal -framework MetalKit -framework CoreImage -framework AuthenticationServices -framework AVFoundation -framework AVKit -framework CoreMedia -framework OpenGL -framework GameController -framework ApplicationServices -framework CoreAudio $(WEBRTC_LIBS) .PHONY: all run clean diff --git a/src/OPNAppDelegate.mm b/src/OPNAppDelegate.mm index d0739ed2f..274482145 100644 --- a/src/OPNAppDelegate.mm +++ b/src/OPNAppDelegate.mm @@ -406,6 +406,7 @@ - (void)installLibraryRootIfNeeded { self.window.contentViewController = nil; self.rootView = [[OPNBackdropView alloc] initWithFrame:self.window.contentView.bounds]; self.rootView.wantsLayer = YES; + self.rootView.layer.opaque = NO; self.rootView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; __weak __typeof__(self) weakSelf = self; self.rootView.onStoreSelected = ^{ @@ -450,6 +451,8 @@ - (void)installLibraryRootIfNeeded { if (!self.contentContainer || self.contentContainer.superview != self.rootView) { self.contentContainer = [[NSView alloc] initWithFrame:self.rootView.bounds]; self.contentContainer.wantsLayer = YES; + self.contentContainer.layer.opaque = NO; + self.contentContainer.layer.backgroundColor = NSColor.clearColor.CGColor; self.contentContainer.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; [self.rootView addSubview:self.contentContainer]; } @@ -616,6 +619,12 @@ - (void)transitionToScreen:(OPN::AuthScreen)screen { strongSelf.rootView.gameCountText = [NSString stringWithFormat:@"%ld %@", (long)count, count == 1 ? @"game" : @"games"]; }; + catalog.onFocusedArtworkAccentChanged = ^(unsigned accentRGB) { + __typeof__(self) strongSelf = weakSelf; + if (!strongSelf || !strongSelf.rootView) return; + strongSelf.rootView.controllerAccentRGB = accentRGB; + }; + catalog.onSelectGame = ^(const GameInfo &game, int variantIndex) { __typeof__(self) strongSelf = weakSelf; if (!strongSelf) return; diff --git a/src/common/OPNCoreAnimationCoordinator.h b/src/common/OPNCoreAnimationCoordinator.h new file mode 100644 index 000000000..ae2d54ad8 --- /dev/null +++ b/src/common/OPNCoreAnimationCoordinator.h @@ -0,0 +1,35 @@ +#pragma once + +#import +#import + +@class MTKView; + +@interface OPNCoreAnimationCoordinator : NSObject + ++ (instancetype)sharedCoordinator; ++ (CAMediaTimingFunction *)appleQuinticTimingFunction; + +- (void)animateFocusForCardLayer:(CALayer *)cardLayer + glowLayer:(CALayer *)glowLayer + focused:(BOOL)focused + prominence:(CGFloat)prominence + accentColor:(NSColor *)accentColor; + +- (void)animateCardLayer:(CALayer *)cardLayer + metadataContainer:(NSView *)metadataContainer + backgroundLayer:(CALayer *)backgroundLayer + expanded:(BOOL)expanded + accentColor:(NSColor *)accentColor; + +- (void)springScrollClipView:(NSClipView *)clipView + toX:(CGFloat)targetX + velocity:(CGFloat)velocity; + +- (void)configureMetalViewForProMotion:(MTKView *)metalView; + +- (void)extractDominantColorFromImage:(CGImageRef)image + cacheKey:(NSString *)cacheKey + completion:(void (^)(NSColor *color))completion; + +@end diff --git a/src/common/OPNCoreAnimationCoordinator.mm b/src/common/OPNCoreAnimationCoordinator.mm new file mode 100644 index 000000000..8fcaa3fcf --- /dev/null +++ b/src/common/OPNCoreAnimationCoordinator.mm @@ -0,0 +1,298 @@ +#import "OPNCoreAnimationCoordinator.h" + +#import +#import + +static CASpringAnimation *OPNSpringAnimation(NSString *keyPath, + id fromValue, + id toValue, + CGFloat mass, + CGFloat stiffness, + CGFloat damping, + CGFloat velocity) { + CASpringAnimation *animation = [CASpringAnimation animationWithKeyPath:keyPath]; + animation.fromValue = fromValue; + animation.toValue = toValue; + animation.mass = mass; + animation.stiffness = stiffness; + animation.damping = damping; + animation.initialVelocity = velocity; + animation.duration = MIN(0.82, animation.settlingDuration); + animation.removedOnCompletion = YES; + return animation; +} + +static NSValue *OPNCurrentTransformValue(CALayer *layer) { + CALayer *presentationLayer = layer.presentationLayer; + return [NSValue valueWithCATransform3D:(presentationLayer ? presentationLayer.transform : layer.transform)]; +} + +@interface OPNCoreAnimationCoordinator () +@property (nonatomic, strong) NSCache *colorCache; +@property (nonatomic, strong) dispatch_queue_t colorQueue; +@end + +@implementation OPNCoreAnimationCoordinator + ++ (instancetype)sharedCoordinator { + static OPNCoreAnimationCoordinator *coordinator; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + coordinator = [[OPNCoreAnimationCoordinator alloc] init]; + }); + return coordinator; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _colorCache = [[NSCache alloc] init]; + _colorCache.countLimit = 256; + _colorQueue = dispatch_queue_create("com.opennow.artwork-color-extraction", DISPATCH_QUEUE_CONCURRENT); + } + return self; +} + ++ (CAMediaTimingFunction *)appleQuinticTimingFunction { + return [CAMediaTimingFunction functionWithControlPoints:0.22 :1.0 :0.36 :1.0]; +} + +- (void)animateFocusForCardLayer:(CALayer *)cardLayer + glowLayer:(CALayer *)glowLayer + focused:(BOOL)focused + prominence:(CGFloat)prominence + accentColor:(NSColor *)accentColor { + if (!cardLayer) return; + + NSColor *resolvedAccent = accentColor ?: NSColor.whiteColor; + CGFloat focusAmount = focused ? 1.0 : MAX(0.0, MIN(1.0, prominence)); + CGFloat scale = 1.0 + 0.075 * focusAmount; + + CATransform3D targetTransform = CATransform3DIdentity; + targetTransform.m34 = -1.0 / 760.0; + targetTransform = CATransform3DTranslate(targetTransform, 0.0, -10.0 * focusAmount, 42.0 * focusAmount); + targetTransform = CATransform3DScale(targetTransform, scale, scale, 1.0); + targetTransform = CATransform3DRotate(targetTransform, -0.030 * focusAmount, 1.0, 0.0, 0.0); + + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + + cardLayer.transform = targetTransform; + cardLayer.zPosition = 100.0 * focusAmount; + cardLayer.shadowColor = resolvedAccent.CGColor; + cardLayer.shadowOpacity = 0.24 + 0.34 * focusAmount; + cardLayer.shadowRadius = 18.0 + 34.0 * focusAmount; + cardLayer.shadowOffset = CGSizeMake(0.0, 12.0 + 16.0 * focusAmount); + + CASpringAnimation *transformSpring = OPNSpringAnimation(@"transform", + OPNCurrentTransformValue(cardLayer), + [NSValue valueWithCATransform3D:targetTransform], + 0.78, + 560.0, + 40.0, + 0.0); + [cardLayer addAnimation:transformSpring forKey:@"opn.focus.transform"]; + + if (glowLayer) { + CALayer *presentationGlow = glowLayer.presentationLayer; + CGFloat targetOpacity = focused ? 0.74 : 0.0; + NSNumber *fromOpacity = @((presentationGlow ? presentationGlow.opacity : glowLayer.opacity)); + glowLayer.backgroundColor = resolvedAccent.CGColor; + glowLayer.opacity = targetOpacity; + glowLayer.shadowColor = resolvedAccent.CGColor; + glowLayer.shadowOpacity = targetOpacity; + glowLayer.shadowRadius = 24.0 + 18.0 * focusAmount; + + CASpringAnimation *opacitySpring = OPNSpringAnimation(@"opacity", + fromOpacity, + @(targetOpacity), + 0.70, + 500.0, + 38.0, + 0.0); + [glowLayer addAnimation:opacitySpring forKey:@"opn.focus.glow"]; + } + + [CATransaction commit]; +} + +- (void)animateCardLayer:(CALayer *)cardLayer + metadataContainer:(NSView *)metadataContainer + backgroundLayer:(CALayer *)backgroundLayer + expanded:(BOOL)expanded + accentColor:(NSColor *)accentColor { + if (!cardLayer || !metadataContainer.layer || !backgroundLayer) return; + + NSColor *resolvedAccent = accentColor ?: NSColor.whiteColor; + CGFloat scale = expanded ? 1.18 : 1.0; + CGFloat blurRadius = expanded ? 22.0 : 0.0; + CGFloat metadataOpacity = expanded ? 0.28 : 1.0; + + CATransform3D targetTransform = CATransform3DIdentity; + targetTransform.m34 = -1.0 / 900.0; + targetTransform = CATransform3DTranslate(targetTransform, 0.0, expanded ? -18.0 : 0.0, expanded ? 80.0 : 0.0); + targetTransform = CATransform3DScale(targetTransform, scale, scale, 1.0); + + CIFilter *blurFilter = [CIFilter filterWithName:@"CIGaussianBlur"]; + if (!blurFilter) return; + blurFilter.name = @"opnMetadataBlur"; + [blurFilter setDefaults]; + [blurFilter setValue:@(blurRadius) forKey:kCIInputRadiusKey]; + + [CATransaction begin]; + [CATransaction setAnimationDuration:0.42]; + [CATransaction setAnimationTimingFunction:[OPNCoreAnimationCoordinator appleQuinticTimingFunction]]; + + cardLayer.transform = targetTransform; + cardLayer.shadowColor = resolvedAccent.CGColor; + cardLayer.shadowOpacity = expanded ? 0.62 : 0.34; + cardLayer.shadowRadius = expanded ? 64.0 : 22.0; + cardLayer.shadowOffset = CGSizeMake(0.0, expanded ? 34.0 : 14.0); + metadataContainer.layer.opacity = metadataOpacity; + backgroundLayer.filters = @[blurFilter]; + + CABasicAnimation *blurAnimation = [CABasicAnimation animationWithKeyPath:@"filters.opnMetadataBlur.inputRadius"]; + blurAnimation.fromValue = @(!expanded ? 22.0 : 0.0); + blurAnimation.toValue = @(blurRadius); + blurAnimation.duration = 0.42; + blurAnimation.timingFunction = [OPNCoreAnimationCoordinator appleQuinticTimingFunction]; + [backgroundLayer addAnimation:blurAnimation forKey:@"opn.metadata.blur"]; + + CASpringAnimation *transformSpring = OPNSpringAnimation(@"transform", + OPNCurrentTransformValue(cardLayer), + [NSValue valueWithCATransform3D:targetTransform], + 0.85, + 360.0, + 34.0, + 0.0); + [cardLayer addAnimation:transformSpring forKey:@"opn.expand.transform"]; + + [CATransaction commit]; +} + +- (void)springScrollClipView:(NSClipView *)clipView + toX:(CGFloat)targetX + velocity:(CGFloat)velocity { + if (!clipView) return; + + clipView.wantsLayer = YES; + NSRect currentBounds = clipView.bounds; + CGFloat currentX = currentBounds.origin.x; + CGFloat distance = targetX - currentX; + CGFloat normalizedVelocity = fabs(distance) > 1.0 ? velocity / distance : 0.0; + NSRect targetBounds = currentBounds; + targetBounds.origin.x = targetX; + + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + clipView.bounds = targetBounds; + + CASpringAnimation *spring = OPNSpringAnimation(@"bounds.origin.x", + @(currentX), + @(targetX), + 1.0, + 220.0, + 29.0, + normalizedVelocity); + [clipView.layer addAnimation:spring forKey:@"opn.carousel.snap"]; + [CATransaction commit]; + + [clipView scrollToPoint:NSMakePoint(targetX, currentBounds.origin.y)]; + [clipView.enclosingScrollView reflectScrolledClipView:clipView]; +} + +- (void)configureMetalViewForProMotion:(MTKView *)metalView { + if (!metalView) return; + + NSInteger maximumFramesPerSecond = metalView.window.screen.maximumFramesPerSecond; + if (maximumFramesPerSecond <= 0) maximumFramesPerSecond = 60; + metalView.preferredFramesPerSecond = MIN(120, maximumFramesPerSecond); + metalView.enableSetNeedsDisplay = NO; + metalView.paused = NO; + metalView.framebufferOnly = YES; +} + +- (void)extractDominantColorFromImage:(CGImageRef)image + cacheKey:(NSString *)cacheKey + completion:(void (^)(NSColor *color))completion { + if (!image || cacheKey.length == 0 || !completion) return; + + NSColor *cachedColor = [self.colorCache objectForKey:cacheKey]; + if (cachedColor) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(cachedColor); + }); + return; + } + + CGImageRef retainedImage = CGImageRetain(image); + dispatch_async(self.colorQueue, ^{ + const size_t width = 32; + const size_t height = 32; + const size_t bytesPerPixel = 4; + const size_t bytesPerRow = width * bytesPerPixel; + NSMutableData *pixelData = [NSMutableData dataWithLength:height * bytesPerRow]; + CGColorSpaceRef colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + CGBitmapInfo bitmapInfo = (CGBitmapInfo)kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big; + CGContextRef context = CGBitmapContextCreate(pixelData.mutableBytes, + width, + height, + 8, + bytesPerRow, + colorSpace, + bitmapInfo); + + if (!context || !colorSpace) { + if (context) CGContextRelease(context); + if (colorSpace) CGColorSpaceRelease(colorSpace); + CGImageRelease(retainedImage); + return; + } + + CGContextSetInterpolationQuality(context, kCGInterpolationMedium); + CGContextDrawImage(context, CGRectMake(0.0, 0.0, width, height), retainedImage); + + const uint8_t *pixels = (const uint8_t *)pixelData.bytes; + CGFloat redTotal = 0.0; + CGFloat greenTotal = 0.0; + CGFloat blueTotal = 0.0; + CGFloat weightTotal = 0.0; + + for (size_t index = 0; index < width * height; index++) { + const uint8_t *pixel = pixels + index * bytesPerPixel; + CGFloat red = pixel[0] / 255.0; + CGFloat green = pixel[1] / 255.0; + CGFloat blue = pixel[2] / 255.0; + CGFloat alpha = pixel[3] / 255.0; + CGFloat maxChannel = MAX(red, MAX(green, blue)); + CGFloat minChannel = MIN(red, MIN(green, blue)); + CGFloat saturation = maxChannel <= 0.0 ? 0.0 : (maxChannel - minChannel) / maxChannel; + CGFloat luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + CGFloat highlightPenalty = 1.0 - MAX(0.0, luminance - 0.92); + CGFloat weight = alpha * (0.25 + saturation) * (0.35 + luminance) * highlightPenalty; + + redTotal += red * weight; + greenTotal += green * weight; + blueTotal += blue * weight; + weightTotal += weight; + } + + CGContextRelease(context); + CGColorSpaceRelease(colorSpace); + CGImageRelease(retainedImage); + + if (weightTotal <= 0.001) weightTotal = 1.0; + + NSColor *color = [NSColor colorWithCalibratedRed:redTotal / weightTotal + green:greenTotal / weightTotal + blue:blueTotal / weightTotal + alpha:1.0]; + [self.colorCache setObject:color forKey:cacheKey]; + + dispatch_async(dispatch_get_main_queue(), ^{ + completion(color); + }); + }); +} + +@end diff --git a/src/common/OPNUIHelpers.h b/src/common/OPNUIHelpers.h index f495d249c..6b3f335d7 100644 --- a/src/common/OPNUIHelpers.h +++ b/src/common/OPNUIHelpers.h @@ -15,6 +15,8 @@ BOOL OpnAutoFullScreenEnabled(void); void OpnSetAutoFullScreenEnabled(BOOL enabled); BOOL OpnControllerModeEnabled(void); void OpnSetControllerModeEnabled(BOOL enabled); +BOOL OpnBackgroundAnimationEnabled(void); +void OpnSetBackgroundAnimationEnabled(BOOL enabled); typedef NS_ENUM(NSInteger, OPNConsoleTone) { OPNConsoleToneMove = 0, diff --git a/src/common/OPNUIHelpers.mm b/src/common/OPNUIHelpers.mm index 5d1109326..3878f15ec 100644 --- a/src/common/OPNUIHelpers.mm +++ b/src/common/OPNUIHelpers.mm @@ -11,6 +11,7 @@ static NSString *const OPNPosterSizeScaleDefaultsKey = @"OpenNOW.Interface.PosterSizeScale"; static NSString *const OPNAutoFullScreenDefaultsKey = @"OpenNOW.Interface.AutoFullScreen"; static NSString *const OPNControllerModeDefaultsKey = @"OpenNOW.Interface.ControllerMode"; +static NSString *const OPNBackgroundAnimationDefaultsKey = @"OpenNOW.Interface.BackgroundAnimation"; static const CGFloat OPNMinimumPosterSizeScale = 0.80; static const CGFloat OPNMaximumPosterSizeScale = 1.30; static const unsigned OPNDefaultAccentRGB = 0x7CF1B1; @@ -89,6 +90,18 @@ void OpnSetControllerModeEnabled(BOOL enabled) { [NSNotificationCenter.defaultCenter postNotificationName:OPNInterfacePreferencesDidChangeNotification object:nil]; } +BOOL OpnBackgroundAnimationEnabled(void) { + id stored = [NSUserDefaults.standardUserDefaults objectForKey:OPNBackgroundAnimationDefaultsKey]; + return stored ? [NSUserDefaults.standardUserDefaults boolForKey:OPNBackgroundAnimationDefaultsKey] : YES; +} + +void OpnSetBackgroundAnimationEnabled(BOOL enabled) { + if (enabled == OpnBackgroundAnimationEnabled()) return; + [NSUserDefaults.standardUserDefaults setBool:enabled forKey:OPNBackgroundAnimationDefaultsKey]; + [NSUserDefaults.standardUserDefaults synchronize]; + [NSNotificationCenter.defaultCenter postNotificationName:OPNInterfacePreferencesDidChangeNotification object:nil]; +} + static void OPNAppendLittleEndianUInt16(NSMutableData *data, uint16_t value) { uint16_t little = CFSwapInt16HostToLittle(value); [data appendBytes:&little length:sizeof(little)]; diff --git a/src/shaders/OPNPremiumBackdrop.metal b/src/shaders/OPNPremiumBackdrop.metal new file mode 100644 index 000000000..6f732ec84 --- /dev/null +++ b/src/shaders/OPNPremiumBackdrop.metal @@ -0,0 +1,97 @@ +#include +using namespace metal; + +struct OPNBackdropVertex { + float2 position; + float2 texCoord; +}; + +struct OPNBackdropUniforms { + float2 viewportSize; + float2 focusCenter; + float focusRadius; + float focusStrength; + float time; + float3 accentColor; + float bloomIntensity; +}; + +struct OPNBackdropVarying { + float4 position [[position]]; + float2 texCoord; + float2 screenPosition; +}; + +vertex OPNBackdropVarying opnMeshWarpVertex(uint vertexID [[vertex_id]], + constant OPNBackdropVertex *vertices [[buffer(0)]], + constant OPNBackdropUniforms &uniforms [[buffer(1)]]) { + OPNBackdropVertex input = vertices[vertexID]; + float2 pixelPosition = (input.position * 0.5 + 0.5) * uniforms.viewportSize; + float2 delta = pixelPosition - uniforms.focusCenter; + float distanceToFocus = length(delta); + float normalized = clamp(1.0 - distanceToFocus / max(uniforms.focusRadius, 1.0), 0.0, 1.0); + float falloff = normalized * normalized * (3.0 - 2.0 * normalized); + float wave = sin(delta.x * 0.018 + delta.y * 0.012 + uniforms.time * 1.35) * 0.006; + float pull = falloff * uniforms.focusStrength * 0.026; + float2 direction = distanceToFocus > 0.001 ? normalize(delta) : float2(0.0); + float2 warpedPosition = input.position - direction * pull + direction.yx * wave * falloff; + + OPNBackdropVarying output; + output.position = float4(warpedPosition, 0.0, 1.0); + output.texCoord = input.texCoord; + output.screenPosition = pixelPosition; + return output; +} + +fragment float4 opnVariableBloomFragment(OPNBackdropVarying input [[stage_in]], + texture2d sourceTexture [[texture(0)]], + sampler linearSampler [[sampler(0)]], + constant OPNBackdropUniforms &uniforms [[buffer(0)]]) { + float2 delta = input.screenPosition - uniforms.focusCenter; + float distanceToFocus = length(delta); + float focus = clamp(1.0 - distanceToFocus / max(uniforms.focusRadius, 1.0), 0.0, 1.0); + focus = focus * focus * (3.0 - 2.0 * focus); + + float2 pixel = 1.0 / max(uniforms.viewportSize, float2(1.0)); + float blurRadius = mix(1.0, 16.0, focus * uniforms.focusStrength); + + constexpr int sampleCount = 12; + float2 offsets[sampleCount] = { + float2(0.000, 0.000), + float2(0.866, 0.500), + float2(-0.866, 0.500), + float2(0.000, -1.000), + float2(1.414, 1.414), + float2(-1.414, 1.414), + float2(1.414, -1.414), + float2(-1.414, -1.414), + float2(2.500, 0.000), + float2(-2.500, 0.000), + float2(0.000, 2.500), + float2(0.000, -2.500) + }; + float weights[sampleCount] = { + 0.180, 0.090, 0.090, 0.090, + 0.070, 0.070, 0.070, 0.070, + 0.055, 0.055, 0.055, 0.055 + }; + + float4 color = float4(0.0); + float totalWeight = 0.0; + for (int i = 0; i < sampleCount; i++) { + float2 uv = input.texCoord + offsets[i] * pixel * blurRadius; + float weight = weights[i]; + color += sourceTexture.sample(linearSampler, uv) * weight; + totalWeight += weight; + } + color /= max(totalWeight, 0.001); + + float luminance = dot(color.rgb, float3(0.2126, 0.7152, 0.0722)); + float bloomMask = smoothstep(0.42, 0.95, luminance) * focus; + float3 bloom = uniforms.accentColor * bloomMask * uniforms.bloomIntensity; + float vignette = smoothstep(1.15, 0.15, distanceToFocus / max(uniforms.focusRadius, 1.0)); + + color.rgb = mix(color.rgb, color.rgb + bloom, uniforms.focusStrength); + color.rgb += uniforms.accentColor * vignette * 0.055 * uniforms.focusStrength; + return float4(color.rgb, color.a); +} diff --git a/src/views/OPNBackdropView.h b/src/views/OPNBackdropView.h index f7c94d507..bc9f256d9 100644 --- a/src/views/OPNBackdropView.h +++ b/src/views/OPNBackdropView.h @@ -17,6 +17,7 @@ typedef NS_ENUM(NSInteger, OPNBackdropMode) { @property (nonatomic, copy) NSString *gameCountText; @property (nonatomic, copy) NSArray *> *accountMenuItems; @property (nonatomic, copy) NSString *currentAccountIdentifier; +@property (nonatomic, assign) unsigned controllerAccentRGB; @property (nonatomic, copy) void (^onStoreSelected)(void); @property (nonatomic, copy) void (^onLibrarySelected)(void); @property (nonatomic, copy) void (^onSettingsSelected)(void); diff --git a/src/views/OPNBackdropView.mm b/src/views/OPNBackdropView.mm index 6943aa6d3..686947f84 100644 --- a/src/views/OPNBackdropView.mm +++ b/src/views/OPNBackdropView.mm @@ -3,6 +3,7 @@ #import "../common/OPNUIHelpers.h" #import "../common/OPNAuthTypes.h" #import +#include @interface OPNBackdropControllerMenuView : NSView @end @@ -40,6 +41,9 @@ @implementation OPNBackdropView { NSButton *_accountButton; NSView *_controllerAccountMenuView; NSTimer *_controllerNavigationTimer; + NSTimer *_backgroundAnimationTimer; + CFTimeInterval _backgroundAnimationStartTime; + unsigned _controllerAccentRGB; uint16_t _previousControllerButtons; } @@ -64,6 +68,8 @@ - (instancetype)initWithFrame:(NSRect)frame { _libraryButton = [self navigationHitButtonWithAction:@selector(libraryButtonPressed:)]; _settingsButton = [self navigationHitButtonWithAction:@selector(settingsButtonPressed:)]; _accountButton = [self navigationHitButtonWithAction:@selector(accountButtonPressed:)]; + _controllerAccentRGB = OPNControllerAccentRGB(); + _backgroundAnimationStartTime = CACurrentMediaTime(); [self addSubview:_storeButton]; [self addSubview:_libraryButton]; [self addSubview:_settingsButton]; @@ -82,23 +88,71 @@ - (instancetype)initWithFrame:(NSRect)frame { - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; [_controllerNavigationTimer invalidate]; + [_backgroundAnimationTimer invalidate]; } - (void)viewDidMoveToWindow { [super viewDidMoveToWindow]; if (self.window) { [self startControllerNavigationIfNeeded]; + [self startControllerBackgroundAnimationIfNeeded]; } else { [_controllerNavigationTimer invalidate]; _controllerNavigationTimer = nil; + [_backgroundAnimationTimer invalidate]; + _backgroundAnimationTimer = nil; _previousControllerButtons = 0; } } - (void)interfacePreferencesChanged:(NSNotification *)notification { (void)notification; + if (!OpnBackgroundAnimationEnabled()) { + [_backgroundAnimationTimer invalidate]; + _backgroundAnimationTimer = nil; + } [self setNeedsDisplay:YES]; [self startControllerNavigationIfNeeded]; + [self startControllerBackgroundAnimationIfNeeded]; +} + +- (void)setControllerAccentRGB:(unsigned)controllerAccentRGB { + controllerAccentRGB &= 0xFFFFFF; + if (_controllerAccentRGB == controllerAccentRGB) return; + _controllerAccentRGB = controllerAccentRGB; + [self setNeedsDisplay:YES]; +} + +- (unsigned)resolvedControllerAccentRGB { + return _controllerAccentRGB ? _controllerAccentRGB : OPNControllerAccentRGB(); +} + +- (unsigned)resolvedControllerAccentSoftRGB { + return OpnBlendRGB([self resolvedControllerAccentRGB], 0xFFFFFF, 0.42); +} + +- (unsigned)resolvedControllerAccentBlackRGB:(CGFloat)blackMix { + return OpnBlendRGB([self resolvedControllerAccentRGB], 0x000000, blackMix); +} + +- (void)startControllerBackgroundAnimationIfNeeded { + if (!OpnControllerModeEnabled() || !OpnBackgroundAnimationEnabled() || _backgroundAnimationTimer || !self.window) return; + _backgroundAnimationTimer = [NSTimer timerWithTimeInterval:(1.0 / 60.0) + target:self + selector:@selector(backgroundAnimationTick:) + userInfo:nil + repeats:YES]; + [NSRunLoop.mainRunLoop addTimer:_backgroundAnimationTimer forMode:NSRunLoopCommonModes]; +} + +- (void)backgroundAnimationTick:(NSTimer *)timer { + (void)timer; + if (!OpnControllerModeEnabled() || !OpnBackgroundAnimationEnabled() || !self.window) { + [_backgroundAnimationTimer invalidate]; + _backgroundAnimationTimer = nil; + return; + } + [self setNeedsDisplay:YES]; } - (void)startControllerNavigationIfNeeded { @@ -176,9 +230,95 @@ - (NSButton *)navigationHitButtonWithAction:(SEL)action { - (BOOL)isFlipped { return YES; } +- (CGFloat)unitHashForSeed:(NSUInteger)seed index:(NSUInteger)index { + uint32_t value = (uint32_t)(seed * 1103515245u + index * 12345u + 0x9E3779B9u); + value ^= value >> 16; + value *= 0x7FEB352Du; + value ^= value >> 15; + return (CGFloat)(value % 10000u) / 10000.0; +} + +- (void)drawSparkleAtPoint:(NSPoint)point radius:(CGFloat)radius alpha:(CGFloat)alpha color:(NSColor *)color { + NSBezierPath *cross = [NSBezierPath bezierPath]; + [cross moveToPoint:NSMakePoint(point.x - radius, point.y)]; + [cross lineToPoint:NSMakePoint(point.x + radius, point.y)]; + [cross moveToPoint:NSMakePoint(point.x, point.y - radius)]; + [cross lineToPoint:NSMakePoint(point.x, point.y + radius)]; + [cross moveToPoint:NSMakePoint(point.x - radius * 0.55, point.y - radius * 0.55)]; + [cross lineToPoint:NSMakePoint(point.x + radius * 0.55, point.y + radius * 0.55)]; + [cross moveToPoint:NSMakePoint(point.x - radius * 0.55, point.y + radius * 0.55)]; + [cross lineToPoint:NSMakePoint(point.x + radius * 0.55, point.y - radius * 0.55)]; + cross.lineCapStyle = NSLineCapStyleRound; + cross.lineWidth = MAX(0.7, radius * 0.16); + [[color colorWithAlphaComponent:alpha] setStroke]; + [cross stroke]; + + NSBezierPath *core = [NSBezierPath bezierPathWithOvalInRect:NSMakeRect(point.x - radius * 0.16, + point.y - radius * 0.16, + radius * 0.32, + radius * 0.32)]; + [[NSColor.whiteColor colorWithAlphaComponent:alpha * 0.72] setFill]; + [core fill]; +} + +- (void)drawControllerElectricBackgroundInRect:(NSRect)bounds { + BOOL animationEnabled = OpnBackgroundAnimationEnabled(); + CGFloat phase = animationEnabled ? (CGFloat)(CACurrentMediaTime() - _backgroundAnimationStartTime) : 0.0; + NSGradient *base = [[NSGradient alloc] initWithColors:@[ + OpnColor([self resolvedControllerAccentBlackRGB:0.89], 1.0), + OpnColor([self resolvedControllerAccentBlackRGB:0.81], 1.0), + OpnColor([self resolvedControllerAccentBlackRGB:0.92], 1.0), + ]]; + [base drawInRect:bounds angle:88.0]; + + CGFloat width = NSWidth(bounds); + CGFloat height = NSHeight(bounds); + unsigned accentRGB = [self resolvedControllerAccentRGB]; + unsigned accentSoftRGB = [self resolvedControllerAccentSoftRGB]; + + for (NSInteger band = 0; band < 9; band++) { + CGFloat yBase = height * (0.12 + (CGFloat)band * 0.092); + NSBezierPath *ribbon = [NSBezierPath bezierPath]; + [ribbon moveToPoint:NSMakePoint(-120.0, yBase)]; + for (NSInteger point = 0; point <= 28; point++) { + CGFloat t = (CGFloat)point / 28.0; + CGFloat x = t * (width + 240.0) - 120.0; + CGFloat drift = phase * (0.28 + (CGFloat)band * 0.018); + CGFloat y = yBase + + sin(t * 5.8 + (CGFloat)band * 0.72 + drift) * (20.0 + (CGFloat)band * 1.6) + + sin(t * 13.0 - phase * 0.20 + (CGFloat)band) * 5.0; + [ribbon lineToPoint:NSMakePoint(x, y)]; + } + NSColor *stroke = band % 3 == 0 ? OpnColor(accentSoftRGB, 0.032) : OpnColor(accentRGB, 0.035); + [stroke setStroke]; + ribbon.lineWidth = band == 4 ? 2.4 : 1.1; + [ribbon stroke]; + } + + CGFloat streamY = height * 0.46; + CGFloat travelWidth = width + 180.0; + for (NSInteger i = 0; i < 54; i++) { + CGFloat seed = (CGFloat)i; + CGFloat lane = ((NSInteger)i % 7) - 3.0; + CGFloat speed = 38.0 + (CGFloat)(i % 5) * 8.0; + CGFloat x = fmod(seed * 131.0 + phase * speed, MAX(1.0, travelWidth)) - 90.0; + CGFloat y = streamY + lane * 9.0 + sin(phase * (1.0 + seed * 0.017) + seed * 0.71) * 8.0; + CGFloat shimmer = 0.5 + 0.5 * sin(phase * (2.2 + (CGFloat)(i % 4) * 0.28) + seed); + CGFloat radius = 2.0 + (CGFloat)(i % 4) * 0.75 + shimmer * 1.6; + CGFloat alpha = 0.047 + shimmer * 0.142; + NSColor *sparkleColor = i % 6 == 0 ? NSColor.whiteColor : OpnColor(accentSoftRGB); + [self drawSparkleAtPoint:NSMakePoint(x, y) radius:radius alpha:alpha color:sparkleColor]; + } + + NSGradient *vignette = [[NSGradient alloc] initWithStartingColor:OpnColor([self resolvedControllerAccentBlackRGB:0.88], 0.0) + endingColor:OpnColor([self resolvedControllerAccentBlackRGB:0.97], 0.30)]; + [vignette drawInRect:bounds angle:-90.0]; +} + - (void)setMode:(OPNBackdropMode)mode { _mode = mode; [self dismissControllerAccountMenu]; + [self startControllerBackgroundAnimationIfNeeded]; [self setNeedsDisplay:YES]; } @@ -252,18 +392,16 @@ - (void)drawRect:(NSRect)dirtyRect { [OpnColor(kBackground) setFill]; NSRectFill(bounds); - NSGradient *edgeWash = controllerMode - ? [[NSGradient alloc] initWithColors:@[ - OpnColor(OPNControllerAccentBlackRGB(0.95), 1.0), - OpnColor(OPNControllerAccentBlackRGB(0.90), 1.0), - OpnColor(OPNControllerAccentBlackRGB(0.97), 1.0), - ]] - : [[NSGradient alloc] initWithColors:@[ + if (controllerMode) { + [self drawControllerElectricBackgroundInRect:bounds]; + } else { + NSGradient *edgeWash = [[NSGradient alloc] initWithColors:@[ OpnColor(kBackgroundB, 0.94), OpnColor(kBackground, 1.0), OpnColor(0x0C0D10, 1.0), ]]; - [edgeWash drawInRect:bounds angle:270.0]; + [edgeWash drawInRect:bounds angle:270.0]; + } if (!controllerMode) { NSGradient *spotlight = [[NSGradient alloc] initWithStartingColor:OpnColor(0xFFFFFF, 0.045) @@ -283,9 +421,9 @@ - (void)drawRect:(NSRect)dirtyRect { CGFloat navHeight = controllerMode ? 118.0 : 64.0; NSRect navRect = NSMakeRect(0, 0, NSWidth(bounds), navHeight); - [controllerMode ? OpnColor(OPNControllerAccentBlackRGB(0.88), 0.92) : OpnColor(0x1C1D21, 0.82) setFill]; - NSRectFill(navRect); if (!controllerMode) { + [OpnColor(0x1C1D21, 0.82) setFill]; + NSRectFill(navRect); [OpnColor(0xFFFFFF, 0.08) setFill]; NSRectFill(NSMakeRect(0, navHeight - 1.0, NSWidth(bounds), 1)); } @@ -300,7 +438,7 @@ - (void)drawRect:(NSRect)dirtyRect { timeFormatter.dateFormat = @"h:mm a"; NSString *timeText = [[timeFormatter stringFromDate:NSDate.date] uppercaseString]; NSBezierPath *timeGlow = [NSBezierPath bezierPathWithRoundedRect:NSMakeRect(20.0, 32.0, 128.0, 30.0) xRadius:15.0 yRadius:15.0]; - [OpnColor(OPNControllerAccentRGB(), 0.055) setFill]; + [OpnColor([self resolvedControllerAccentRGB], 0.055) setFill]; [timeGlow fill]; [timeText drawInRect:NSMakeRect(32.0, 40.0, 112.0, 18.0) withAttributes:OpnTextStyle(13.0, OpnColor(kTextSecondary), NSFontWeightSemibold)]; @@ -314,7 +452,7 @@ - (void)drawRect:(NSRect)dirtyRect { CGFloat navRowY = controllerMode ? 64.0 : 15.0; NSRect segmentedRect = NSMakeRect(x - 8.0, navRowY, navWidth + 16.0, controllerMode ? 42.0 : 34.0); NSBezierPath *segmented = [NSBezierPath bezierPathWithRoundedRect:segmentedRect xRadius:controllerMode ? 21.0 : 10.0 yRadius:controllerMode ? 21.0 : 10.0]; - [controllerMode ? OpnColor(OPNControllerAccentRGB(), 0.055) : OpnColor(0xFFFFFF, 0.055) setFill]; + [controllerMode ? OpnColor([self resolvedControllerAccentRGB], 0.055) : OpnColor(0xFFFFFF, 0.055) setFill]; [segmented fill]; if (controllerMode) { [OpnColor(0xFFFFFF, 0.18) setStroke]; @@ -333,10 +471,10 @@ - (void)drawRect:(NSRect)dirtyRect { if ([item isEqualToString:@"Settings"]) _settingsNavFrame = itemRect; if (active) { NSBezierPath *pill = [NSBezierPath bezierPathWithRoundedRect:itemRect xRadius:controllerMode ? 17.0 : 8.0 yRadius:controllerMode ? 17.0 : 8.0]; - [controllerMode ? OpnColor(OPNControllerAccentRGB(), 0.20) : OpnColor(0xFFFFFF, 0.14) setFill]; + [controllerMode ? OpnColor([self resolvedControllerAccentRGB], 0.20) : OpnColor(0xFFFFFF, 0.14) setFill]; [pill fill]; if (controllerMode) { - [OpnColor(OPNControllerAccentSoftRGB(), 0.66) setStroke]; + [OpnColor([self resolvedControllerAccentSoftRGB], 0.66) setStroke]; pill.lineWidth = 1.0; [pill stroke]; } @@ -355,10 +493,10 @@ - (void)drawRect:(NSRect)dirtyRect { CGFloat controllerStatsX = MAX(NSMaxX(segmentedRect) + 18.0, NSWidth(bounds) - controllerStatsWidth - 28.0); NSRect planRect = controllerMode ? NSMakeRect(controllerStatsX, 72.0, 132.0, 26.0) : NSMakeRect(NSWidth(bounds) - 294, 11.0, 108, 26); NSBezierPath *planPill = [NSBezierPath bezierPathWithRoundedRect:planRect xRadius:14 yRadius:14]; - [controllerMode ? OpnColor(OPNControllerAccentRGB(), 0.075) : OpnColor(0xFFFFFF, 0.075) setFill]; + [controllerMode ? OpnColor([self resolvedControllerAccentRGB], 0.075) : OpnColor(0xFFFFFF, 0.075) setFill]; [planPill fill]; if (controllerMode) { - [OpnColor(OPNControllerAccentSoftRGB(), 0.24) setStroke]; + [OpnColor([self resolvedControllerAccentSoftRGB], 0.24) setStroke]; planPill.lineWidth = 1.0; [planPill stroke]; } @@ -392,12 +530,12 @@ - (void)drawRect:(NSRect)dirtyRect { hints:@{NSImageHintInterpolation: @(NSImageInterpolationHigh)}]; [NSGraphicsContext restoreGraphicsState]; } else { - [OpnColor(OPNControllerAccentSoftRGB(), 0.90) setFill]; + [OpnColor([self resolvedControllerAccentSoftRGB], 0.90) setFill]; [avatar fill]; NSString *initial = name.length > 0 ? [[name substringToIndex:1] uppercaseString] : @"U"; NSMutableParagraphStyle *avatarStyle = [[NSMutableParagraphStyle alloc] init]; avatarStyle.alignment = NSTextAlignmentCenter; - NSMutableDictionary *avatarAttrs = [OpnTextStyle(13, OpnColor(OPNControllerAccentBlackRGB(0.88)), NSFontWeightBold) mutableCopy]; + NSMutableDictionary *avatarAttrs = [OpnTextStyle(13, OpnColor([self resolvedControllerAccentBlackRGB:0.88]), NSFontWeightBold) mutableCopy]; avatarAttrs[NSParagraphStyleAttributeName] = avatarStyle; [initial drawInRect:NSMakeRect(NSMinX(avatarRect), NSMinY(avatarRect) + 7, 30, 16) withAttributes:avatarAttrs]; } diff --git a/src/views/OPNCarouselFocusLayout.h b/src/views/OPNCarouselFocusLayout.h new file mode 100644 index 000000000..34cd9673a --- /dev/null +++ b/src/views/OPNCarouselFocusLayout.h @@ -0,0 +1,14 @@ +#pragma once + +#import + +@interface OPNCarouselFocusLayout : NSCollectionViewLayout + +@property (nonatomic, assign) NSSize itemSize; +@property (nonatomic, assign) CGFloat itemSpacing; +@property (nonatomic, assign) CGFloat sideInset; +@property (nonatomic, assign) CGFloat focusScale; +@property (nonatomic, assign) CGFloat minimumScale; +@property (nonatomic, assign) CGFloat minimumAlpha; + +@end diff --git a/src/views/OPNCarouselFocusLayout.mm b/src/views/OPNCarouselFocusLayout.mm new file mode 100644 index 000000000..a0e24f93e --- /dev/null +++ b/src/views/OPNCarouselFocusLayout.mm @@ -0,0 +1,132 @@ +#import "OPNCarouselFocusLayout.h" + +static CGFloat OPNSmoothstep(CGFloat value) { + CGFloat x = MAX(0.0, MIN(1.0, value)); + return x * x * (3.0 - 2.0 * x); +} + +@interface OPNCarouselFocusLayout () +@property (nonatomic, strong) NSMutableArray *cachedAttributes; +@property (nonatomic, assign) NSSize cachedContentSize; +@end + +@implementation OPNCarouselFocusLayout + +- (instancetype)init { + self = [super init]; + if (self) { + _itemSize = NSMakeSize(164.0, 164.0); + _itemSpacing = 26.0; + _sideInset = 64.0; + _focusScale = 1.14; + _minimumScale = 0.88; + _minimumAlpha = 0.48; + _cachedAttributes = [NSMutableArray array]; + _cachedContentSize = NSZeroSize; + } + return self; +} + +- (void)prepareLayout { + [super prepareLayout]; + + NSCollectionView *collectionView = self.collectionView; + [self.cachedAttributes removeAllObjects]; + + if (!collectionView) { + self.cachedContentSize = NSZeroSize; + return; + } + + NSInteger sectionCount = collectionView.numberOfSections; + NSInteger itemCount = sectionCount > 0 ? [collectionView numberOfItemsInSection:0] : 0; + NSRect bounds = collectionView.bounds; + CGFloat centerY = floor(NSMidY(bounds)); + CGFloat x = self.sideInset; + + for (NSInteger item = 0; item < itemCount; item++) { + NSIndexPath *indexPath = [NSIndexPath indexPathForItem:item inSection:0]; + NSCollectionViewLayoutAttributes *attributes = [NSCollectionViewLayoutAttributes layoutAttributesForItemWithIndexPath:indexPath]; + attributes.frame = NSMakeRect(x, centerY - self.itemSize.height * 0.5, self.itemSize.width, self.itemSize.height); + attributes.zIndex = item; + [self.cachedAttributes addObject:attributes]; + x += self.itemSize.width + self.itemSpacing; + } + + CGFloat contentWidth = self.sideInset + itemCount * self.itemSize.width + MAX(0, itemCount - 1) * self.itemSpacing + self.sideInset; + self.cachedContentSize = NSMakeSize(MAX(NSWidth(bounds), contentWidth), MAX(NSHeight(bounds), self.itemSize.height)); +} + +- (NSSize)collectionViewContentSize { + return self.cachedContentSize; +} + +- (NSArray *)layoutAttributesForElementsInRect:(NSRect)rect { + NSCollectionView *collectionView = self.collectionView; + if (!collectionView) return @[]; + + NSClipView *clipView = collectionView.enclosingScrollView.contentView; + NSRect visibleRect = clipView ? clipView.bounds : collectionView.bounds; + CGFloat viewportCenterX = NSMidX(visibleRect); + CGFloat influenceRadius = MAX(1.0, NSWidth(visibleRect) * 0.48); + NSRect expandedRect = NSInsetRect(rect, -self.itemSize.width, -self.itemSize.height); + + NSMutableArray *visibleAttributes = [NSMutableArray array]; + + for (NSCollectionViewLayoutAttributes *baseAttributes in self.cachedAttributes) { + if (!NSIntersectsRect(baseAttributes.frame, expandedRect)) continue; + + NSCollectionViewLayoutAttributes *attributes = [baseAttributes copy]; + CGFloat distance = fabs(NSMidX(baseAttributes.frame) - viewportCenterX); + CGFloat proximity = 1.0 - MIN(1.0, distance / influenceRadius); + CGFloat prominence = OPNSmoothstep(proximity); + CGFloat scale = self.minimumScale + (self.focusScale - self.minimumScale) * prominence; + CGFloat width = self.itemSize.width * scale; + CGFloat height = self.itemSize.height * scale; + CGFloat midX = NSMidX(baseAttributes.frame); + CGFloat midY = NSMidY(baseAttributes.frame); + + attributes.frame = NSMakeRect(floor(midX - width * 0.5), floor(midY - height * 0.5), width, height); + attributes.alpha = self.minimumAlpha + (1.0 - self.minimumAlpha) * prominence; + attributes.zIndex = (NSInteger)lrint(prominence * 1000.0); + [visibleAttributes addObject:attributes]; + } + + return visibleAttributes; +} + +- (NSCollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath { + if (indexPath.item < 0 || indexPath.item >= (NSInteger)self.cachedAttributes.count) return nil; + return [self.cachedAttributes[(NSUInteger)indexPath.item] copy]; +} + +- (BOOL)shouldInvalidateLayoutForBoundsChange:(NSRect)newBounds { + (void)newBounds; + return YES; +} + +- (NSPoint)targetContentOffsetForProposedContentOffset:(NSPoint)proposedContentOffset + withScrollingVelocity:(NSPoint)velocity { + (void)velocity; + NSCollectionView *collectionView = self.collectionView; + if (!collectionView || self.cachedAttributes.count == 0) return proposedContentOffset; + + NSRect bounds = collectionView.bounds; + CGFloat proposedCenterX = proposedContentOffset.x + NSWidth(bounds) * 0.5; + CGFloat nearestDistance = CGFLOAT_MAX; + CGFloat nearestCenterX = proposedCenterX; + + for (NSCollectionViewLayoutAttributes *attributes in self.cachedAttributes) { + CGFloat distance = fabs(NSMidX(attributes.frame) - proposedCenterX); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestCenterX = NSMidX(attributes.frame); + } + } + + CGFloat targetX = nearestCenterX - NSWidth(bounds) * 0.5; + targetX = MAX(0.0, MIN(targetX, MAX(0.0, self.cachedContentSize.width - NSWidth(bounds)))); + return NSMakePoint(targetX, proposedContentOffset.y); +} + +@end diff --git a/src/views/OPNGameCardView.h b/src/views/OPNGameCardView.h index a3b4c3983..42441e8cd 100644 --- a/src/views/OPNGameCardView.h +++ b/src/views/OPNGameCardView.h @@ -4,9 +4,11 @@ @interface OPNGameCardView : NSView @property (nonatomic, readonly) OPN::GameInfo game; +@property (nonatomic, strong, readonly) NSColor *artworkAccentColor; @property (nonatomic, assign) int selectedVariantIndex; @property (nonatomic, assign, getter=isControllerFocused) BOOL controllerFocused; @property (nonatomic, copy) void (^onPlay)(); +@property (nonatomic, copy) void (^onArtworkAccentColorChanged)(NSColor *color); - (instancetype)initWithFrame:(NSRect)frame game:(const OPN::GameInfo &)game; - (void)selectVariantAtIndex:(int)index; diff --git a/src/views/OPNGameCardView.mm b/src/views/OPNGameCardView.mm index 35a2b610c..5b31f1751 100644 --- a/src/views/OPNGameCardView.mm +++ b/src/views/OPNGameCardView.mm @@ -1,5 +1,6 @@ #import "OPNGameCardView.h" #import "../common/OPNColorTokens.h" +#import "../common/OPNCoreAnimationCoordinator.h" #import "../common/OPNUIHelpers.h" #include @@ -143,6 +144,7 @@ @interface OPNGameCardView () @property (nonatomic, strong) NSButton *playButton; @property (nonatomic, strong) CALayer *reflectionLayer; @property (nonatomic, strong) NSMutableArray *storeChipButtons; +@property (nonatomic, strong, readwrite) NSColor *artworkAccentColor; - (void)loadImageFromCandidates:(NSArray *)urlStrings index:(NSUInteger)index; - (void)applyFocusStyle; @end @@ -251,31 +253,24 @@ - (void)setControllerFocused:(BOOL)controllerFocused { - (void)applyFocusStyle { BOOL selected = self.controllerFocused; - BOOL controllerMode = OpnControllerModeEnabled(); + NSColor *accentColor = self.artworkAccentColor ?: OpnColor(OPNControllerAccentSoftRGB()); self.playButton.hidden = OpnControllerModeEnabled() || !selected; [CATransaction begin]; [CATransaction setAnimationDuration:0.22]; - [CATransaction setAnimationTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]]; + [CATransaction setAnimationTimingFunction:[OPNCoreAnimationCoordinator appleQuinticTimingFunction]]; self.layer.zPosition = selected ? 20.0 : 0.0; self.layer.borderColor = selected ? OpnColor(0xFFFFFF, 0.94).CGColor : OpnColor(0xFFFFFF, 0.13).CGColor; self.layer.borderWidth = selected ? 3.0 : 1.0; - self.layer.shadowColor = selected ? OpnColor(OPNControllerAccentSoftRGB()).CGColor : NSColor.blackColor.CGColor; - self.layer.shadowOpacity = selected ? (controllerMode ? 0.28 : 0.58) : 0.34; - self.layer.shadowRadius = selected ? (controllerMode ? 22.0 : 58.0) : 20.0; - self.layer.shadowOffset = selected ? (controllerMode ? CGSizeMake(0.0, 12.0) : CGSizeMake(0.0, 28.0)) : CGSizeMake(0.0, 14.0); - CATransform3D transform = CATransform3DIdentity; - transform.m34 = -1.0 / 760.0; - if (selected) { - transform = CATransform3DTranslate(transform, 0.0, controllerMode ? -10.0 : -14.0, 42.0); - CGFloat selectedScale = controllerMode ? 1.05 : 1.135; - transform = CATransform3DScale(transform, selectedScale, selectedScale, 1.0); - transform = CATransform3DRotate(transform, -0.034, 1.0, 0.0, 0.0); - } - self.layer.transform = transform; - self.reflectionLayer.opacity = selected && !controllerMode ? 0.74 : 0.0; self.playButton.layer.shadowOpacity = selected ? 0.58 : 0.18; self.playButton.layer.shadowRadius = selected ? 22.0 : 14.0; [CATransaction commit]; + + CGFloat prominence = selected ? 1.0 : 0.0; + [[OPNCoreAnimationCoordinator sharedCoordinator] animateFocusForCardLayer:self.layer + glowLayer:self.reflectionLayer + focused:selected + prominence:prominence + accentColor:accentColor]; } - (BOOL)isFlipped { return YES; } @@ -417,6 +412,19 @@ - (void)loadImageFromCandidates:(NSArray *)urlStrings index:(NSUInte __typeof__(self) strongSelf = weakSelf; if (!strongSelf) return; strongSelf.imageView.image = img; + NSRect imageRect = NSMakeRect(0.0, 0.0, img.size.width, img.size.height); + CGImageRef cgImage = [img CGImageForProposedRect:&imageRect context:nil hints:nil]; + if (cgImage) { + [[OPNCoreAnimationCoordinator sharedCoordinator] extractDominantColorFromImage:cgImage + cacheKey:urlStr + completion:^(NSColor *color) { + __typeof__(self) completedSelf = weakSelf; + if (!completedSelf || !color) return; + completedSelf.artworkAccentColor = color; + if (completedSelf.controllerFocused) [completedSelf applyFocusStyle]; + if (completedSelf.onArtworkAccentColorChanged) completedSelf.onArtworkAccentColorChanged(color); + }]; + } }); }]; [task resume]; diff --git a/src/views/OPNGameCatalogView.h b/src/views/OPNGameCatalogView.h index ecc3f06c2..0c76eccb8 100644 --- a/src/views/OPNGameCatalogView.h +++ b/src/views/OPNGameCatalogView.h @@ -8,6 +8,7 @@ @property (nonatomic, copy) void (^onSignOut)(); @property (nonatomic, copy) void (^onGameCountChanged)(NSInteger count); @property (nonatomic, copy) void (^onCatalogBrowseRequested)(NSString *searchQuery, NSString *sortId, const std::vector &filterIds); +@property (nonatomic, copy) void (^onFocusedArtworkAccentChanged)(unsigned accentRGB); - (instancetype)initWithFrame:(NSRect)frame; - (void)setGames:(const std::vector &)games; diff --git a/src/views/OPNGameCatalogView.mm b/src/views/OPNGameCatalogView.mm index 33fda0bee..b79d9af9a 100644 --- a/src/views/OPNGameCatalogView.mm +++ b/src/views/OPNGameCatalogView.mm @@ -2,6 +2,7 @@ #import "OPNGameCardView.h" #import "OPNLoadingView.h" #import "../common/OPNColorTokens.h" +#import "../common/OPNCoreAnimationCoordinator.h" #import "../common/OPNUIHelpers.h" #import #include @@ -26,6 +27,16 @@ static unsigned OPNControllerAccentBlackRGB(CGFloat blackMix) { return OpnBlendRGB(OpnCurrentAccentRGB(), 0x000000, blackMix); } +static unsigned OPNRGBFromColor(NSColor *color, unsigned fallbackRGB) { + if (!color) return fallbackRGB; + NSColor *rgbColor = [color colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; + if (!rgbColor) return fallbackRGB; + NSInteger red = (NSInteger)lrint(MAX(0.0, MIN(1.0, rgbColor.redComponent)) * 255.0); + NSInteger green = (NSInteger)lrint(MAX(0.0, MIN(1.0, rgbColor.greenComponent)) * 255.0); + NSInteger blue = (NSInteger)lrint(MAX(0.0, MIN(1.0, rgbColor.blueComponent)) * 255.0); + return ((unsigned)red << 16) | ((unsigned)green << 8) | (unsigned)blue; +} + static NSString *OPNCatalogString(const std::string &value, NSString *fallback = @"") { return value.empty() ? fallback : [NSString stringWithUTF8String:value.c_str()]; } @@ -83,9 +94,43 @@ - (void)selectWithFrame:(NSRect)rect @end +@interface OPNRailEdgeFadeView : NSView +@end + +@implementation OPNRailEdgeFadeView + +- (instancetype)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + self.wantsLayer = YES; + self.layer.backgroundColor = NSColor.clearColor.CGColor; + } + return self; +} + +- (BOOL)isFlipped { return YES; } + +- (NSView *)hitTest:(NSPoint)point { + (void)point; + return nil; +} + +- (void)drawRect:(NSRect)dirtyRect { + (void)dirtyRect; + NSGradient *fade = [[NSGradient alloc] initWithColors:@[ + OpnColor(0x000000, 0.0), + OpnColor(0x000000, 0.18), + OpnColor(0x000000, 0.0), + ]]; + [fade drawInRect:self.bounds angle:90.0]; +} + +@end + @interface OPNControllerElectricBackgroundView : NSView @property (nonatomic, strong) NSTimer *animationTimer; @property (nonatomic, assign) CFTimeInterval animationStartTime; +@property (nonatomic, assign) unsigned accentRGB; @end @implementation OPNControllerElectricBackgroundView @@ -94,11 +139,27 @@ - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { self.wantsLayer = YES; + _accentRGB = OPNControllerAccentRGB(); _animationStartTime = CACurrentMediaTime(); } return self; } +- (void)setAccentRGB:(unsigned)accentRGB { + accentRGB &= 0xFFFFFF; + if (_accentRGB == accentRGB) return; + _accentRGB = accentRGB; + [self setNeedsDisplay:YES]; +} + +- (unsigned)accentSoftRGB { + return OpnBlendRGB(self.accentRGB, 0xFFFFFF, 0.42); +} + +- (unsigned)accentBlackRGB:(CGFloat)blackMix { + return OpnBlendRGB(self.accentRGB, 0x000000, blackMix); +} + - (void)dealloc { [self.animationTimer invalidate]; } @@ -156,9 +217,9 @@ - (void)drawRect:(NSRect)dirtyRect { NSRect bounds = self.bounds; CGFloat phase = (CGFloat)(CACurrentMediaTime() - self.animationStartTime); NSGradient *base = [[NSGradient alloc] initWithColors:@[ - OpnColor(OPNControllerAccentBlackRGB(0.95), 1.0), - OpnColor(OPNControllerAccentBlackRGB(0.90), 1.0), - OpnColor(OPNControllerAccentBlackRGB(0.97), 1.0), + OpnColor([self accentBlackRGB:0.95], 1.0), + OpnColor([self accentBlackRGB:0.90], 1.0), + OpnColor([self accentBlackRGB:0.97], 1.0), ]]; [base drawInRect:bounds angle:88.0]; @@ -178,7 +239,7 @@ - (void)drawRect:(NSRect)dirtyRect { + sin(t * 13.0 - phase * 0.20 + (CGFloat)band) * 5.0; [ribbon lineToPoint:NSMakePoint(x, y)]; } - NSColor *stroke = band % 3 == 0 ? OpnColor(0xFFFFFF, 0.030) : OpnColor(OPNControllerAccentSoftRGB(), 0.038); + NSColor *stroke = band % 3 == 0 ? OpnColor(0xFFFFFF, 0.030) : OpnColor([self accentSoftRGB], 0.038); [stroke setStroke]; ribbon.lineWidth = band == 4 ? 2.4 : 1.1; [ribbon stroke]; @@ -189,12 +250,12 @@ - (void)drawRect:(NSRect)dirtyRect { CGFloat y = fmod((CGFloat)(i * 43) + sin(phase * 0.24 + (CGFloat)i) * 18.0, MAX(1.0, height)); CGFloat radius = 0.7 + (CGFloat)(i % 3) * 0.32; NSBezierPath *spark = [NSBezierPath bezierPathWithOvalInRect:NSMakeRect(x, y, radius, radius)]; - [OpnColor(i % 5 == 0 ? 0xFFFFFF : OPNControllerAccentSoftRGB(), i % 5 == 0 ? 0.10 : 0.07) setFill]; + [OpnColor(i % 5 == 0 ? 0xFFFFFF : [self accentSoftRGB], i % 5 == 0 ? 0.10 : 0.07) setFill]; [spark fill]; } - NSGradient *vignette = [[NSGradient alloc] initWithStartingColor:OpnColor(OPNControllerAccentBlackRGB(0.90), 0.0) - endingColor:OpnColor(OPNControllerAccentBlackRGB(0.99), 0.42)]; + NSGradient *vignette = [[NSGradient alloc] initWithStartingColor:OpnColor([self accentBlackRGB:0.90], 0.0) + endingColor:OpnColor([self accentBlackRGB:0.99], 0.42)]; [vignette drawInRect:bounds angle:-90.0]; } @@ -228,6 +289,8 @@ @interface OPNGameCatalogView () @property (nonatomic, strong) NSTextField *controllerDetailStatsLabel; @property (nonatomic, strong) NSTextField *controllerDetailFeaturesLabel; @property (nonatomic, strong) OPNControllerPromptBarView *controllerPromptBarView; +@property (nonatomic, strong) OPNRailEdgeFadeView *railTopFadeView; +@property (nonatomic, strong) OPNRailEdgeFadeView *railBottomFadeView; @property (nonatomic, strong) CAGradientLayer *controllerDetailGradientLayer; @property (nonatomic, strong) CALayer *controllerDetailAccentLayer; @property (nonatomic, strong) NSMutableArray *cardViews; @@ -521,6 +584,7 @@ - (instancetype)initWithFrame:(NSRect)frame { _focusedCardIndex = -1; _gridColumnCount = 1; self.wantsLayer = YES; + self.layer.opaque = NO; self.layer.backgroundColor = [NSColor clearColor].CGColor; _controllerElectricBackgroundView = [[OPNControllerElectricBackgroundView alloc] initWithFrame:self.bounds]; @@ -628,20 +692,25 @@ - (instancetype)initWithFrame:(NSRect)frame { CGFloat gridY = kNavHeight + kToolbarHeight; NSRect scrollFrame = NSMakeRect(0, gridY, frame.size.width, frame.size.height - gridY); _scrollView = [[NSScrollView alloc] initWithFrame:scrollFrame]; + _scrollView.wantsLayer = YES; _scrollView.hasVerticalScroller = YES; _scrollView.hasHorizontalScroller = NO; _scrollView.autohidesScrollers = YES; _scrollView.drawsBackground = NO; _scrollView.borderType = NSNoBorder; + _scrollView.layer.opaque = NO; + _scrollView.layer.backgroundColor = NSColor.clearColor.CGColor; _scrollView.contentView.drawsBackground = NO; _scrollView.contentView.backgroundColor = NSColor.clearColor; _scrollView.contentView.wantsLayer = YES; + _scrollView.contentView.layer.opaque = NO; _scrollView.contentView.layer.backgroundColor = NSColor.clearColor.CGColor; [self addSubview:_scrollView]; // Grid content _gridContentView = [[OPNFlippedGridDocumentView alloc] initWithFrame:NSMakeRect(0, 0, frame.size.width, 100)]; _gridContentView.wantsLayer = YES; + _gridContentView.layer.opaque = NO; _gridContentView.layer.backgroundColor = NSColor.clearColor.CGColor; _scrollView.documentView = _gridContentView; @@ -656,7 +725,7 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailView.layer.cornerRadius = 0.0; _controllerDetailView.layer.borderWidth = 0.0; _controllerDetailView.layer.borderColor = OpnColor(0xFFFFFF, 0.0).CGColor; - _controllerDetailView.layer.backgroundColor = OpnColor(OPNControllerAccentBlackRGB(0.90), 0.10).CGColor; + _controllerDetailView.layer.backgroundColor = NSColor.clearColor.CGColor; _controllerDetailView.layer.shadowColor = OpnColor(OPNControllerAccentRGB()).CGColor; _controllerDetailView.layer.shadowOpacity = 0.0; _controllerDetailView.layer.shadowRadius = 0.0; @@ -667,12 +736,13 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailView.layer.transform = detailTransform; _controllerDetailGradientLayer = [CAGradientLayer layer]; - _controllerDetailGradientLayer.colors = @[(id)OpnColor(OPNControllerAccentRGB(), 0.16).CGColor, - (id)OpnColor(0xFFFFFF, 0.040).CGColor, - (id)OpnColor(OPNControllerAccentBlackRGB(0.96), 0.0).CGColor]; + _controllerDetailGradientLayer.colors = @[(id)NSColor.clearColor.CGColor, + (id)NSColor.clearColor.CGColor, + (id)NSColor.clearColor.CGColor]; _controllerDetailGradientLayer.locations = @[@0.0, @0.46, @1.0]; _controllerDetailGradientLayer.startPoint = CGPointMake(0.0, 0.0); _controllerDetailGradientLayer.endPoint = CGPointMake(1.0, 1.0); + _controllerDetailGradientLayer.opacity = 0.0; [_controllerDetailView.layer addSublayer:_controllerDetailGradientLayer]; _controllerDetailAccentLayer = [CALayer layer]; @@ -702,8 +772,16 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerPromptBarView.wantsLayer = YES; [_controllerDetailView addSubview:_controllerPromptBarView]; + _railTopFadeView = [[OPNRailEdgeFadeView alloc] initWithFrame:NSZeroRect]; + _railTopFadeView.hidden = YES; + [self addSubview:_railTopFadeView]; + + _railBottomFadeView = [[OPNRailEdgeFadeView alloc] initWithFrame:NSZeroRect]; + _railBottomFadeView.hidden = YES; + [self addSubview:_railBottomFadeView]; + _loadingView = [[OPNLoadingView alloc] initWithFrame:self.bounds - message:@"Loading games..."]; + message:@"Loading games..."]; _loadingView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; _loadingView.hidden = YES; [self addSubview:_loadingView]; @@ -741,14 +819,15 @@ - (void)viewDidMoveToWindow { - (void)applyControllerAccentColors { self.searchField.layer.backgroundColor = OpnColor(OPNControllerAccentBlackRGB(0.88), 0.92).CGColor; - self.controllerDetailView.layer.backgroundColor = OpnColor(OPNControllerAccentBlackRGB(0.90), 0.10).CGColor; + self.controllerDetailView.layer.backgroundColor = NSColor.clearColor.CGColor; self.controllerDetailView.layer.shadowColor = OpnColor(OPNControllerAccentRGB()).CGColor; - self.controllerDetailGradientLayer.colors = @[(id)OpnColor(OPNControllerAccentRGB(), 0.16).CGColor, - (id)OpnColor(0xFFFFFF, 0.040).CGColor, - (id)OpnColor(OPNControllerAccentBlackRGB(0.96), 0.0).CGColor]; + self.controllerDetailGradientLayer.colors = @[(id)NSColor.clearColor.CGColor, + (id)NSColor.clearColor.CGColor, + (id)NSColor.clearColor.CGColor]; + self.controllerDetailGradientLayer.opacity = 0.0; self.controllerDetailAccentLayer.backgroundColor = OpnColor(OPNControllerAccentSoftRGB(), 0.86).CGColor; self.controllerDetailStoreLabel.textColor = OpnColor(OPNControllerAccentSoftRGB()); - self.layer.backgroundColor = OpnControllerModeEnabled() ? OpnColor(OPNControllerAccentBlackRGB(0.92), 0.20).CGColor : [NSColor clearColor].CGColor; + self.layer.backgroundColor = NSColor.clearColor.CGColor; [self.controllerElectricBackgroundView setNeedsDisplay:YES]; } @@ -1058,6 +1137,15 @@ - (void)renderGrid { s.onSelectGame(gameCopy, variantIdx >= 0 ? variantIdx : 0); } }; + card.onArtworkAccentColorChanged = ^(NSColor *color) { + (void)color; + __typeof__(self) s = weakSelf; + OPNGameCardView *c = weakCard; + if (!s || !c) return; + NSUInteger cardIndex = [s.cardViews indexOfObject:c]; + if (cardIndex == NSNotFound || (NSInteger)cardIndex != s.focusedCardIndex) return; + [s updateControllerDetailContent]; + }; [_gridContentView addSubview:card]; [_cardViews addObject:card]; @@ -1204,16 +1292,18 @@ - (void)layoutCatalogSubviews { CGFloat height = NSHeight(self.bounds); BOOL controllerMode = OpnControllerModeEnabled(); CGFloat controllerNavHeight = 118.0; - self.controllerElectricBackgroundView.hidden = !controllerMode || self.cardViews.count == 0; - self.controllerElectricBackgroundView.frame = controllerMode - ? NSMakeRect(0.0, controllerNavHeight, width, MAX(0.0, height - controllerNavHeight)) - : self.bounds; + self.controllerElectricBackgroundView.hidden = YES; + self.controllerElectricBackgroundView.frame = self.bounds; self.scrollView.hasVerticalScroller = !controllerMode; self.scrollView.hasHorizontalScroller = NO; self.scrollView.drawsBackground = NO; + self.scrollView.layer.opaque = NO; + self.scrollView.layer.backgroundColor = NSColor.clearColor.CGColor; self.scrollView.contentView.drawsBackground = NO; self.scrollView.contentView.backgroundColor = NSColor.clearColor; + self.scrollView.contentView.layer.opaque = NO; self.scrollView.contentView.layer.backgroundColor = NSColor.clearColor.CGColor; + self.gridContentView.layer.opaque = NO; self.gridContentView.layer.backgroundColor = NSColor.clearColor.CGColor; BOOL compact = width < 900.0; self.searchField.hidden = controllerMode; @@ -1238,7 +1328,7 @@ - (void)layoutCatalogSubviews { CGFloat categoryY = controllerNavHeight + 10.0; CGFloat railY = controllerMode && self.categoryButtons.count > 1 ? categoryY + 40.0 : controllerNavHeight + 10.0; CGFloat bottomInset = 36.0; - CGFloat detailGap = 10.0; + CGFloat detailGap = 0.0; CGFloat carouselHeight = desiredCarouselHeight; CGFloat detailY = railY + carouselHeight + detailGap; CGFloat detailHeight = 0.0; @@ -1285,9 +1375,14 @@ - (void)layoutCatalogSubviews { self.controllerDetailFeaturesLabel.hidden = NO; self.controllerDetailFeaturesLabel.frame = NSMakeRect(heroX + 2.0, featuresY, MIN(980.0, heroWidth), MAX(0.0, detailHeight - featuresY - 88.0)); self.controllerPromptBarView.frame = NSMakeRect(heroX + 2.0, MAX(188.0, detailHeight - 52.0), heroWidth, 36.0); - self.scrollView.frame = controllerMode - ? NSMakeRect(0, gridY, width, MIN(carouselHeight, MAX(0.0, height - gridY))) - : NSMakeRect(0, gridY, width, MAX(0.0, height - gridY)); + CGFloat railHeight = controllerMode ? MIN(carouselHeight, MAX(0.0, height - gridY)) : MAX(0.0, height - gridY); + self.scrollView.frame = NSMakeRect(0, gridY, width, railHeight); + BOOL showRailFade = controllerMode && self.cardViews.count > 0; + CGFloat railFadeHeight = 56.0; + self.railTopFadeView.hidden = !showRailFade; + self.railBottomFadeView.hidden = !showRailFade; + self.railTopFadeView.frame = showRailFade ? NSMakeRect(0.0, MAX(0.0, gridY - railFadeHeight * 0.50), width, railFadeHeight) : NSZeroRect; + self.railBottomFadeView.frame = showRailFade ? NSMakeRect(0.0, MAX(0.0, gridY + railHeight - railFadeHeight * 0.50), width, railFadeHeight) : NSZeroRect; self.statusLabel.frame = controllerMode ? NSMakeRect(28.0, MAX(kNavHeight + 30.0, gridY - 42.0), width - 56.0, 24.0) : NSMakeRect(0, gridY + 100, width, 24); if (controllerMode && self.cardViews.count > 0) { self.statusLabel.stringValue = @""; @@ -1347,8 +1442,9 @@ - (void)focusCardAtIndex:(NSInteger)index scrollIntoView:(BOOL)scrollIntoView { NSSize contentSize = self.gridContentView.frame.size; CGFloat targetX = NSMidX(card.frame) - NSWidth(visibleRect) * 0.5; targetX = MAX(0.0, MIN(targetX, MAX(0.0, contentSize.width - NSWidth(visibleRect)))); - [self.scrollView.contentView scrollToPoint:NSMakePoint(targetX, 0.0)]; - [self.scrollView reflectScrolledClipView:self.scrollView.contentView]; + [[OPNCoreAnimationCoordinator sharedCoordinator] springScrollClipView:self.scrollView.contentView + toX:targetX + velocity:0.0]; return; } if (!NSContainsRect(visibleRect, targetRect)) { @@ -1383,11 +1479,27 @@ - (void)cycleFocusedVariant { - (void)updateControllerDetailContent { if (!OpnControllerModeEnabled()) return; OPNGameCardView *card = [self focusedCard]; + NSColor *cardAccentColor = card.artworkAccentColor; + NSColor *detailAccentColor = cardAccentColor ?: OpnColor(OPNControllerAccentRGB()); + NSColor *detailAccentSoftColor = cardAccentColor ?: OpnColor(OPNControllerAccentSoftRGB()); + self.controllerElectricBackgroundView.accentRGB = OPNRGBFromColor(cardAccentColor, OPNControllerAccentRGB()); + if (self.onFocusedArtworkAccentChanged) self.onFocusedArtworkAccentChanged(self.controllerElectricBackgroundView.accentRGB); CATransition *fade = [CATransition animation]; fade.type = kCATransitionFade; fade.duration = 0.18; - fade.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + fade.timingFunction = [OPNCoreAnimationCoordinator appleQuinticTimingFunction]; [self.controllerDetailView.layer addAnimation:fade forKey:@"opn.detail.fade"]; + [CATransaction begin]; + [CATransaction setAnimationDuration:0.32]; + [CATransaction setAnimationTimingFunction:[OPNCoreAnimationCoordinator appleQuinticTimingFunction]]; + self.controllerDetailGradientLayer.colors = @[(id)NSColor.clearColor.CGColor, + (id)NSColor.clearColor.CGColor, + (id)NSColor.clearColor.CGColor]; + self.controllerDetailGradientLayer.opacity = 0.0; + self.controllerDetailAccentLayer.backgroundColor = [detailAccentSoftColor colorWithAlphaComponent:0.90].CGColor; + self.controllerDetailView.layer.shadowColor = detailAccentColor.CGColor; + self.controllerDetailStoreLabel.textColor = detailAccentSoftColor; + [CATransaction commit]; if (!card) { self.controllerDetailTitleLabel.stringValue = @"Select a game"; self.controllerDetailMetaLabel.stringValue = @""; @@ -1497,6 +1609,11 @@ - (void)openFocusedGameDetails { self.detailsOverlayView = overlay; [self addSubview:overlay]; + [[OPNCoreAnimationCoordinator sharedCoordinator] animateCardLayer:panel.layer + metadataContainer:self.controllerDetailView + backgroundLayer:self.controllerElectricBackgroundView.layer + expanded:YES + accentColor:card.artworkAccentColor ?: OpnColor(OPNControllerAccentRGB())]; } - (void)closeGameDetails { diff --git a/src/views/OPNSettingsView.mm b/src/views/OPNSettingsView.mm index 8d6a9831e..5185605c5 100644 --- a/src/views/OPNSettingsView.mm +++ b/src/views/OPNSettingsView.mm @@ -729,7 +729,7 @@ - (void)buildInputContent { } - (void)buildInterfaceContent { - NSView *panel = [self panelWithTitle:@"Interface" height:512.0]; + NSView *panel = [self panelWithTitle:@"Interface" height:596.0]; CGFloat panelWidth = MAX(320.0, NSWidth(panel.frame)); CGFloat controlX = [self controlXForPanelWidth:panelWidth]; CGFloat controlWidth = [self controlWidthForPanelWidth:panelWidth]; @@ -742,7 +742,7 @@ - (void)buildInterfaceContent { [panel addSubview:[self rowLabel:@"Controller Mode" y:104.0]]; NSButton *controllerModeToggle = [[NSButton alloc] initWithFrame:NSMakeRect(controlX, 96.0, controlWidth, 28.0)]; controllerModeToggle.buttonType = NSButtonTypeSwitch; - controllerModeToggle.title = @"Use a green glass console home optimized for gamepad navigation"; + controllerModeToggle.title = @"Use a console-style library optimized for gamepad navigation"; controllerModeToggle.font = [NSFont systemFontOfSize:13.0 weight:NSFontWeightMedium]; controllerModeToggle.contentTintColor = OpnColor(kBrandGreen); controllerModeToggle.state = OpnControllerModeEnabled() ? NSControlStateValueOn : NSControlStateValueOff; @@ -750,7 +750,7 @@ - (void)buildInterfaceContent { controllerModeToggle.action = @selector(controllerModeToggleChanged:); [panel addSubview:controllerModeToggle]; - NSTextField *controllerHint = OpnLabel(@"Controller Mode keeps mouse and keyboard support while making the carousel, focus states, details, and launch flow feel like a TV console home.", + NSTextField *controllerHint = OpnLabel(@"Controller Mode keeps mouse and keyboard support while giving the library larger focus states, smoother carousel navigation, and a lean-back launch flow.", NSMakeRect(controlX, 132.0, controlWidth, 38.0), 12.0, OpnColor(kTextMuted), @@ -758,15 +758,34 @@ - (void)buildInterfaceContent { controllerHint.maximumNumberOfLines = 2; [panel addSubview:controllerHint]; - [panel addSubview:[self rowLabel:@"Accent Color" y:198.0]]; + [panel addSubview:[self rowLabel:@"Background" y:198.0]]; + NSButton *backgroundAnimationToggle = [[NSButton alloc] initWithFrame:NSMakeRect(controlX, 190.0, controlWidth, 28.0)]; + backgroundAnimationToggle.buttonType = NSButtonTypeSwitch; + backgroundAnimationToggle.title = @"Animate the game-accent background"; + backgroundAnimationToggle.font = [NSFont systemFontOfSize:13.0 weight:NSFontWeightMedium]; + backgroundAnimationToggle.contentTintColor = OpnColor(kBrandGreen); + backgroundAnimationToggle.state = OpnBackgroundAnimationEnabled() ? NSControlStateValueOn : NSControlStateValueOff; + backgroundAnimationToggle.target = self; + backgroundAnimationToggle.action = @selector(backgroundAnimationToggleChanged:); + [panel addSubview:backgroundAnimationToggle]; + + NSTextField *backgroundHint = OpnLabel(@"When off, the animated ribbons and sparkles pause while keeping the static artwork-tinted background.", + NSMakeRect(controlX, 226.0, controlWidth, 38.0), + 12.0, + OpnColor(kTextMuted), + NSFontWeightRegular); + backgroundHint.maximumNumberOfLines = 2; + [panel addSubview:backgroundHint]; + + [panel addSubview:[self rowLabel:@"Accent Color" y:282.0]]; NSTextField *accentSummary = OpnLabel([NSString stringWithFormat:@"RGB %ld, %ld, %ld", (long)red, (long)green, (long)blue], - NSMakeRect(controlX, 198.0, controlWidth, 20.0), - 13.0, - OpnColor(kTextPrimary), - NSFontWeightSemibold); + NSMakeRect(controlX, 282.0, controlWidth, 20.0), + 13.0, + OpnColor(kTextPrimary), + NSFontWeightSemibold); [panel addSubview:accentSummary]; - NSView *swatch = [[NSView alloc] initWithFrame:NSMakeRect(controlX + MIN(162.0, controlWidth - 36.0), 195.0, 34.0, 24.0)]; + NSView *swatch = [[NSView alloc] initWithFrame:NSMakeRect(controlX + MIN(162.0, controlWidth - 36.0), 279.0, 34.0, 24.0)]; swatch.wantsLayer = YES; swatch.layer.cornerRadius = 8.0; swatch.layer.backgroundColor = OpnColor(kBrandGreen).CGColor; @@ -777,7 +796,7 @@ - (void)buildInterfaceContent { NSArray *channelNames = @[@"Red", @"Green", @"Blue"]; NSArray *channelValues = @[@(red), @(green), @(blue)]; for (NSInteger i = 0; i < 3; i++) { - CGFloat y = 234.0 + i * 42.0; + CGFloat y = 318.0 + i * 42.0; NSTextField *label = OpnLabel(channelNames[(NSUInteger)i], NSMakeRect(controlX, y + 3.0, 62.0, 20.0), 12.0, OpnColor(kTextSecondary), NSFontWeightMedium); [panel addSubview:label]; @@ -803,8 +822,8 @@ - (void)buildInterfaceContent { if (i == 2) self.accentBlueValueLabel = valueLabel; } - [panel addSubview:[self rowLabel:@"Poster Size" y:368.0]]; - NSSlider *posterSlider = [[NSSlider alloc] initWithFrame:NSMakeRect(controlX, 362.0, MIN(300.0, controlWidth - 72.0), 28.0)]; + [panel addSubview:[self rowLabel:@"Poster Size" y:452.0]]; + NSSlider *posterSlider = [[NSSlider alloc] initWithFrame:NSMakeRect(controlX, 446.0, MIN(300.0, controlWidth - 72.0), 28.0)]; posterSlider.minValue = 80.0; posterSlider.maxValue = 130.0; posterSlider.doubleValue = OpnPosterSizeScale() * 100.0; @@ -814,15 +833,15 @@ - (void)buildInterfaceContent { [panel addSubview:posterSlider]; self.posterSizeValueLabel = OpnLabel([NSString stringWithFormat:@"%.0f%%", posterSlider.doubleValue], - NSMakeRect(controlX + MIN(312.0, controlWidth - 60.0), 366.0, 60.0, 22.0), + NSMakeRect(controlX + MIN(312.0, controlWidth - 60.0), 450.0, 60.0, 22.0), 12.0, OpnColor(kTextSecondary), NSFontWeightSemibold, NSTextAlignmentRight); [panel addSubview:self.posterSizeValueLabel]; - [panel addSubview:[self rowLabel:@"Auto Full Screen" y:440.0]]; - NSButton *autoFullScreenToggle = [[NSButton alloc] initWithFrame:NSMakeRect(controlX, 432.0, controlWidth, 28.0)]; + [panel addSubview:[self rowLabel:@"Auto Full Screen" y:524.0]]; + NSButton *autoFullScreenToggle = [[NSButton alloc] initWithFrame:NSMakeRect(controlX, 516.0, controlWidth, 28.0)]; autoFullScreenToggle.buttonType = NSButtonTypeSwitch; autoFullScreenToggle.title = @"Enter full screen automatically when a stream starts"; autoFullScreenToggle.font = [NSFont systemFontOfSize:13.0 weight:NSFontWeightMedium]; @@ -1086,6 +1105,10 @@ - (void)autoFullScreenToggleChanged:(NSButton *)sender { OpnSetAutoFullScreenEnabled(sender.state == NSControlStateValueOn); } +- (void)backgroundAnimationToggleChanged:(NSButton *)sender { + OpnSetBackgroundAnimationEnabled(sender.state == NSControlStateValueOn); +} + - (void)controllerModeToggleChanged:(NSButton *)sender { OpnSetControllerModeEnabled(sender.state == NSControlStateValueOn); [self rebuildContent]; From accbc4ddda38f1c6daf0707d18409b2d22a65844 Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Tue, 12 May 2026 04:38:23 -0500 Subject: [PATCH 16/18] Refine controller mode rail layout --- src/views/OPNGameCatalogView.mm | 67 +++++---------------------------- 1 file changed, 10 insertions(+), 57 deletions(-) diff --git a/src/views/OPNGameCatalogView.mm b/src/views/OPNGameCatalogView.mm index b79d9af9a..c7146e9e4 100644 --- a/src/views/OPNGameCatalogView.mm +++ b/src/views/OPNGameCatalogView.mm @@ -13,6 +13,8 @@ static const CGFloat kCardSpacing = 18.0; static const CGFloat kNavHeight = 62.0; static const CGFloat kToolbarHeight = 82.0; +static const CGFloat kControllerRailSelectorOverlap = 22.0; +static const CGFloat kControllerRailDetailOverlap = 22.0; static NSString *const OPNFavoriteGameIdsDefaultsKey = @"OpenNOW.Library.FavoriteGameIds"; static unsigned OPNControllerAccentRGB(void) { @@ -94,39 +96,6 @@ - (void)selectWithFrame:(NSRect)rect @end -@interface OPNRailEdgeFadeView : NSView -@end - -@implementation OPNRailEdgeFadeView - -- (instancetype)initWithFrame:(NSRect)frame { - self = [super initWithFrame:frame]; - if (self) { - self.wantsLayer = YES; - self.layer.backgroundColor = NSColor.clearColor.CGColor; - } - return self; -} - -- (BOOL)isFlipped { return YES; } - -- (NSView *)hitTest:(NSPoint)point { - (void)point; - return nil; -} - -- (void)drawRect:(NSRect)dirtyRect { - (void)dirtyRect; - NSGradient *fade = [[NSGradient alloc] initWithColors:@[ - OpnColor(0x000000, 0.0), - OpnColor(0x000000, 0.18), - OpnColor(0x000000, 0.0), - ]]; - [fade drawInRect:self.bounds angle:90.0]; -} - -@end - @interface OPNControllerElectricBackgroundView : NSView @property (nonatomic, strong) NSTimer *animationTimer; @property (nonatomic, assign) CFTimeInterval animationStartTime; @@ -289,8 +258,6 @@ @interface OPNGameCatalogView () @property (nonatomic, strong) NSTextField *controllerDetailStatsLabel; @property (nonatomic, strong) NSTextField *controllerDetailFeaturesLabel; @property (nonatomic, strong) OPNControllerPromptBarView *controllerPromptBarView; -@property (nonatomic, strong) OPNRailEdgeFadeView *railTopFadeView; -@property (nonatomic, strong) OPNRailEdgeFadeView *railBottomFadeView; @property (nonatomic, strong) CAGradientLayer *controllerDetailGradientLayer; @property (nonatomic, strong) CALayer *controllerDetailAccentLayer; @property (nonatomic, strong) NSMutableArray *cardViews; @@ -730,10 +697,7 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailView.layer.shadowOpacity = 0.0; _controllerDetailView.layer.shadowRadius = 0.0; _controllerDetailView.layer.shadowOffset = CGSizeZero; - CATransform3D detailTransform = CATransform3DIdentity; - detailTransform.m34 = -1.0 / 1200.0; - detailTransform = CATransform3DRotate(detailTransform, 0.012, 1.0, 0.0, 0.0); - _controllerDetailView.layer.transform = detailTransform; + _controllerDetailView.layer.transform = CATransform3DIdentity; _controllerDetailGradientLayer = [CAGradientLayer layer]; _controllerDetailGradientLayer.colors = @[(id)NSColor.clearColor.CGColor, @@ -772,14 +736,6 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerPromptBarView.wantsLayer = YES; [_controllerDetailView addSubview:_controllerPromptBarView]; - _railTopFadeView = [[OPNRailEdgeFadeView alloc] initWithFrame:NSZeroRect]; - _railTopFadeView.hidden = YES; - [self addSubview:_railTopFadeView]; - - _railBottomFadeView = [[OPNRailEdgeFadeView alloc] initWithFrame:NSZeroRect]; - _railBottomFadeView.hidden = YES; - [self addSubview:_railBottomFadeView]; - _loadingView = [[OPNLoadingView alloc] initWithFrame:self.bounds message:@"Loading games..."]; _loadingView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; @@ -1107,7 +1063,7 @@ - (void)renderGrid { CGFloat gridSpacing = controllerMode ? 26.0 : (cols > 1 ? floor((availableWidth - cols * cardWidth) / (cols - 1)) : kCardSpacing); gridSpacing = MAX(kCardSpacing, gridSpacing); CGFloat xStart = controllerMode ? 64.0 : (cols > 1 ? 0.0 : floor(MAX(0.0, (_scrollView.frame.size.width - cardWidth) / 2.0))); - CGFloat yPos = controllerMode ? 34.0 : kGridPadding; + CGFloat yPos = controllerMode ? 34.0 + kControllerRailSelectorOverlap : kGridPadding; std::vector displayGames; for (const OPN::GameInfo &game : _allGames) { @@ -1328,7 +1284,9 @@ - (void)layoutCatalogSubviews { CGFloat categoryY = controllerNavHeight + 10.0; CGFloat railY = controllerMode && self.categoryButtons.count > 1 ? categoryY + 40.0 : controllerNavHeight + 10.0; CGFloat bottomInset = 36.0; - CGFloat detailGap = 0.0; + CGFloat selectorOverlap = controllerMode ? kControllerRailSelectorOverlap : 0.0; + CGFloat detailOverlap = controllerMode ? kControllerRailDetailOverlap : 0.0; + CGFloat detailGap = -detailOverlap; CGFloat carouselHeight = desiredCarouselHeight; CGFloat detailY = railY + carouselHeight + detailGap; CGFloat detailHeight = 0.0; @@ -1375,14 +1333,9 @@ - (void)layoutCatalogSubviews { self.controllerDetailFeaturesLabel.hidden = NO; self.controllerDetailFeaturesLabel.frame = NSMakeRect(heroX + 2.0, featuresY, MIN(980.0, heroWidth), MAX(0.0, detailHeight - featuresY - 88.0)); self.controllerPromptBarView.frame = NSMakeRect(heroX + 2.0, MAX(188.0, detailHeight - 52.0), heroWidth, 36.0); - CGFloat railHeight = controllerMode ? MIN(carouselHeight, MAX(0.0, height - gridY)) : MAX(0.0, height - gridY); - self.scrollView.frame = NSMakeRect(0, gridY, width, railHeight); - BOOL showRailFade = controllerMode && self.cardViews.count > 0; - CGFloat railFadeHeight = 56.0; - self.railTopFadeView.hidden = !showRailFade; - self.railBottomFadeView.hidden = !showRailFade; - self.railTopFadeView.frame = showRailFade ? NSMakeRect(0.0, MAX(0.0, gridY - railFadeHeight * 0.50), width, railFadeHeight) : NSZeroRect; - self.railBottomFadeView.frame = showRailFade ? NSMakeRect(0.0, MAX(0.0, gridY + railHeight - railFadeHeight * 0.50), width, railFadeHeight) : NSZeroRect; + CGFloat railFrameY = controllerMode ? MAX(0.0, gridY - selectorOverlap) : gridY; + CGFloat railHeight = controllerMode ? MIN(carouselHeight + selectorOverlap, MAX(0.0, height - railFrameY)) : MAX(0.0, height - gridY); + self.scrollView.frame = NSMakeRect(0, railFrameY, width, railHeight); self.statusLabel.frame = controllerMode ? NSMakeRect(28.0, MAX(kNavHeight + 30.0, gridY - 42.0), width - 56.0, 24.0) : NSMakeRect(0, gridY + 100, width, 24); if (controllerMode && self.cardViews.count > 0) { self.statusLabel.stringValue = @""; From 304d251bfaa71ad36a3cf2c0a78d374fa5c498ae Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Tue, 12 May 2026 09:03:26 -0500 Subject: [PATCH 17/18] Refine controller mode stream overlay --- src/OPNAppDelegate.mm | 67 +++++- src/common/OPNUIHelpers.h | 7 + src/common/OPNUIHelpers.mm | 47 ++++ src/streaming/OPNStreamView.h | 3 + src/streaming/OPNStreamView.mm | 97 ++++++++- src/streaming/OPNStreamViewController.h | 4 + src/streaming/OPNStreamViewController.mm | 24 +++ src/views/OPNBackdropView.mm | 66 ++++-- src/views/OPNEmailEntryView.mm | 30 +-- src/views/OPNGameCatalogView.h | 2 + src/views/OPNGameCatalogView.mm | 146 ++++++++++++- src/views/OPNSettingsView.mm | 260 ++++++++++++++++++++++- 12 files changed, 682 insertions(+), 71 deletions(-) diff --git a/src/OPNAppDelegate.mm b/src/OPNAppDelegate.mm index 274482145..ff71db982 100644 --- a/src/OPNAppDelegate.mm +++ b/src/OPNAppDelegate.mm @@ -25,6 +25,9 @@ @interface AppDelegate () @property (nonatomic, strong) OPNSettingsView *settingsView; @property (nonatomic, strong) OPNStoreView *storeView; @property (nonatomic, strong) OPNStreamViewController *streamingController; +@property (nonatomic, copy) NSString *currentStreamTitle; +@property (nonatomic, assign) OPN::AuthScreen activeStreamReturnScreen; +@property (nonatomic, assign) BOOL streamLibraryOverlayActive; @property (nonatomic, strong) NSTimer *gameLibraryRefreshTimer; @property (nonatomic, assign) std::vector cachedGameLibrary; @property (nonatomic, assign) std::string cachedGameLibraryFingerprint; @@ -44,6 +47,8 @@ - (void)saveWindowPresentation; - (void)startGameLibraryRefreshTimer; - (void)stopGameLibraryRefreshTimer; - (void)launchGame:(const OPN::GameInfo &)game variantIndex:(int)variantIndex returnScreen:(OPN::AuthScreen)returnScreen; +- (void)showLibraryOverlayForActiveStream; +- (void)returnToActiveStreamFromLibraryOverlay; - (void)loadStorePanelsWithRetry:(BOOL)canRetry; - (void)refreshGameLibraryInBackground; - (void)fetchGameLibraryWithRetry:(BOOL)canRetry @@ -242,6 +247,10 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification { selector:@selector(windowFullScreenStateChanged:) name:NSWindowDidExitFullScreenNotification object:self.window]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(interfacePreferencesChanged:) + name:OPNInterfacePreferencesDidChangeNotification + object:nil]; { OPN::AuthCredentials creds = self.pendingCredentials; creds.stayLoggedIn = AuthService::Shared().GetStayLoggedIn(); @@ -318,6 +327,12 @@ - (void)windowFullScreenStateChanged:(NSNotification *)notification { [self saveWindowPresentation]; } +- (void)interfacePreferencesChanged:(NSNotification *)notification { + (void)notification; + if (!self.rootView || OpnDerivedAccentColorsEnabled()) return; + self.rootView.controllerAccentRGB = OpnCurrentAccentRGB(); +} + - (void)launchGame:(const OPN::GameInfo &)game variantIndex:(int)variantIndex returnScreen:(OPN::AuthScreen)returnScreen { using namespace OPN; @@ -357,7 +372,10 @@ - (void)launchGame:(const OPN::GameInfo &)game variantIndex:(int)variantIndex re appId:effectiveAppId apiToken:apiToken accountLinked:accountLinked - selectedStore:selectedStore]; + selectedStore:selectedStore]; + self.currentStreamTitle = game.title.empty() ? @"Current Stream" : [NSString stringWithUTF8String:game.title.c_str()]; + self.activeStreamReturnScreen = returnScreen; + self.streamLibraryOverlayActive = NO; __weak __typeof__(self) weakSelf = self; streamVC.onStreamEnd = ^(BOOL success, const std::string &error) { @@ -366,13 +384,22 @@ - (void)launchGame:(const OPN::GameInfo &)game variantIndex:(int)variantIndex re std::string errorCopy = error; dispatch_async(dispatch_get_main_queue(), ^{ NSLog(@"[AppDelegate] Stream ended, restoring previous screen. Success=%d", success); + strongSelf.streamLibraryOverlayActive = NO; [strongSelf transitionToScreen:returnScreen]; strongSelf.streamingController = nil; + strongSelf.currentStreamTitle = nil; if (!success && !errorCopy.empty()) { [strongSelf showError:errorCopy canRetry:YES]; } }); }; + streamVC.onControllerLibraryRequested = ^{ + __typeof__(self) strongSelf = weakSelf; + if (!strongSelf) return; + dispatch_async(dispatch_get_main_queue(), ^{ + [strongSelf showLibraryOverlayForActiveStream]; + }); + }; NSRect preservedFrame = self.window.frame; BOOL preserveFrame = !OPNWindowIsFullScreen(self.window); @@ -396,6 +423,32 @@ - (void)launchGame:(const OPN::GameInfo &)game variantIndex:(int)variantIndex re NSLog(@"[AppDelegate] Window setup complete"); } +- (void)showLibraryOverlayForActiveStream { + if (!self.streamingController || self.streamLibraryOverlayActive) return; + self.streamLibraryOverlayActive = YES; + [self.streamingController prepareForLibraryPictureInPicture]; + [self transitionToScreen:OPN::AuthScreen::Catalog]; +} + +- (void)returnToActiveStreamFromLibraryOverlay { + if (!self.streamingController) return; + NSRect preservedFrame = self.window.frame; + BOOL preserveFrame = !OPNWindowIsFullScreen(self.window); + NSView *streamView = [self.streamingController streamPictureInPictureView]; + [streamView removeFromSuperview]; + [self.streamingController setInitialViewFrame:self.window.contentView.bounds]; + self.streamingController.view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + OPNConfigureStreamWindow(self.window); + self.window.contentViewController = self.streamingController; + OpnDisableFocusHighlights(self.streamingController.view); + if (preserveFrame) { + [self.window setFrame:preservedFrame display:YES animate:NO]; + } + self.streamLibraryOverlayActive = NO; + [self.streamingController restoreFromLibraryPictureInPicture]; + [self.window makeKeyAndOrderFront:nil]; +} + #pragma mark - Screen Transitions - (void)installLibraryRootIfNeeded { @@ -622,7 +675,7 @@ - (void)transitionToScreen:(OPN::AuthScreen)screen { catalog.onFocusedArtworkAccentChanged = ^(unsigned accentRGB) { __typeof__(self) strongSelf = weakSelf; if (!strongSelf || !strongSelf.rootView) return; - strongSelf.rootView.controllerAccentRGB = accentRGB; + strongSelf.rootView.controllerAccentRGB = OpnDerivedAccentColorsEnabled() ? accentRGB : OpnCurrentAccentRGB(); }; catalog.onSelectGame = ^(const GameInfo &game, int variantIndex) { @@ -637,6 +690,16 @@ - (void)transitionToScreen:(OPN::AuthScreen)screen { [strongSelf browseCatalogWithSearch:searchQuery sortId:sortId filterIds:filterIds canRetry:YES]; }; + if (self.streamingController && self.streamLibraryOverlayActive) { + [catalog setStreamPictureInPictureView:[self.streamingController streamPictureInPictureView] + title:self.currentStreamTitle ?: @"Current Stream"]; + catalog.onStreamPictureInPictureSelected = ^{ + __typeof__(self) strongSelf = weakSelf; + if (!strongSelf) return; + [strongSelf returnToActiveStreamFromLibraryOverlay]; + }; + } + [self.contentContainer addSubview:catalog]; OpnDisableFocusHighlights(catalog); self.window.title = @"OpenNOW"; diff --git a/src/common/OPNUIHelpers.h b/src/common/OPNUIHelpers.h index 6b3f335d7..3267d19b5 100644 --- a/src/common/OPNUIHelpers.h +++ b/src/common/OPNUIHelpers.h @@ -1,6 +1,7 @@ #pragma once #import +#include NSColor *OpnColor(unsigned rgb, CGFloat alpha = 1.0); unsigned OpnBlendRGB(unsigned rgb, unsigned target, CGFloat amount); @@ -17,6 +18,12 @@ BOOL OpnControllerModeEnabled(void); void OpnSetControllerModeEnabled(BOOL enabled); BOOL OpnBackgroundAnimationEnabled(void); void OpnSetBackgroundAnimationEnabled(BOOL enabled); +BOOL OpnDerivedAccentColorsEnabled(void); +void OpnSetDerivedAccentColorsEnabled(BOOL enabled); +CGFloat OpnBackgroundTintStrength(void); +void OpnSetBackgroundTintStrength(CGFloat strength); +uint16_t OpnControllerLibraryShortcutMask(void); +void OpnSetControllerLibraryShortcutMask(uint16_t mask); typedef NS_ENUM(NSInteger, OPNConsoleTone) { OPNConsoleToneMove = 0, diff --git a/src/common/OPNUIHelpers.mm b/src/common/OPNUIHelpers.mm index 3878f15ec..62d1a7e66 100644 --- a/src/common/OPNUIHelpers.mm +++ b/src/common/OPNUIHelpers.mm @@ -12,9 +12,14 @@ static NSString *const OPNAutoFullScreenDefaultsKey = @"OpenNOW.Interface.AutoFullScreen"; static NSString *const OPNControllerModeDefaultsKey = @"OpenNOW.Interface.ControllerMode"; static NSString *const OPNBackgroundAnimationDefaultsKey = @"OpenNOW.Interface.BackgroundAnimation"; +static NSString *const OPNDerivedAccentColorsDefaultsKey = @"OpenNOW.Interface.DerivedAccentColors"; +static NSString *const OPNBackgroundTintStrengthDefaultsKey = @"OpenNOW.Interface.BackgroundTintStrength"; +static NSString *const OPNControllerLibraryShortcutDefaultsKey = @"OpenNOW.Interface.ControllerLibraryShortcut"; static const CGFloat OPNMinimumPosterSizeScale = 0.80; static const CGFloat OPNMaximumPosterSizeScale = 1.30; static const unsigned OPNDefaultAccentRGB = 0x7CF1B1; +static const CGFloat OPNDefaultBackgroundTintStrength = 0.32; +static const uint16_t OPNDefaultControllerLibraryShortcutMask = 0x0010 | 0x0020; static int OPNClampedColorByte(NSInteger value) { return (int)MAX(0, MIN(value, 255)); @@ -102,6 +107,48 @@ void OpnSetBackgroundAnimationEnabled(BOOL enabled) { [NSNotificationCenter.defaultCenter postNotificationName:OPNInterfacePreferencesDidChangeNotification object:nil]; } +BOOL OpnDerivedAccentColorsEnabled(void) { + id stored = [NSUserDefaults.standardUserDefaults objectForKey:OPNDerivedAccentColorsDefaultsKey]; + return stored ? [NSUserDefaults.standardUserDefaults boolForKey:OPNDerivedAccentColorsDefaultsKey] : YES; +} + +void OpnSetDerivedAccentColorsEnabled(BOOL enabled) { + if (enabled == OpnDerivedAccentColorsEnabled()) return; + [NSUserDefaults.standardUserDefaults setBool:enabled forKey:OPNDerivedAccentColorsDefaultsKey]; + [NSUserDefaults.standardUserDefaults synchronize]; + [NSNotificationCenter.defaultCenter postNotificationName:OPNInterfacePreferencesDidChangeNotification object:nil]; +} + +CGFloat OpnBackgroundTintStrength(void) { + id stored = [NSUserDefaults.standardUserDefaults objectForKey:OPNBackgroundTintStrengthDefaultsKey]; + CGFloat strength = [stored respondsToSelector:@selector(doubleValue)] ? (CGFloat)[stored doubleValue] : OPNDefaultBackgroundTintStrength; + if (!std::isfinite(strength)) strength = OPNDefaultBackgroundTintStrength; + return MAX(0.0, MIN(strength, 1.0)); +} + +void OpnSetBackgroundTintStrength(CGFloat strength) { + if (!std::isfinite(strength)) strength = OPNDefaultBackgroundTintStrength; + CGFloat clampedStrength = MAX(0.0, MIN(strength, 1.0)); + if (std::fabs(clampedStrength - OpnBackgroundTintStrength()) < 0.001) return; + [NSUserDefaults.standardUserDefaults setDouble:clampedStrength forKey:OPNBackgroundTintStrengthDefaultsKey]; + [NSUserDefaults.standardUserDefaults synchronize]; + [NSNotificationCenter.defaultCenter postNotificationName:OPNInterfacePreferencesDidChangeNotification object:nil]; +} + +uint16_t OpnControllerLibraryShortcutMask(void) { + id stored = [NSUserDefaults.standardUserDefaults objectForKey:OPNControllerLibraryShortcutDefaultsKey]; + if (![stored respondsToSelector:@selector(integerValue)]) return OPNDefaultControllerLibraryShortcutMask; + NSInteger value = [stored integerValue]; + return (uint16_t)MAX(0, MIN(value, 0xFFFF)); +} + +void OpnSetControllerLibraryShortcutMask(uint16_t mask) { + if (mask == OpnControllerLibraryShortcutMask()) return; + [NSUserDefaults.standardUserDefaults setInteger:(NSInteger)mask forKey:OPNControllerLibraryShortcutDefaultsKey]; + [NSUserDefaults.standardUserDefaults synchronize]; + [NSNotificationCenter.defaultCenter postNotificationName:OPNInterfacePreferencesDidChangeNotification object:nil]; +} + static void OPNAppendLittleEndianUInt16(NSMutableData *data, uint16_t value) { uint16_t little = CFSwapInt16HostToLittle(value); [data appendBytes:&little length:sizeof(little)]; diff --git a/src/streaming/OPNStreamView.h b/src/streaming/OPNStreamView.h index b632e9672..e97607f48 100644 --- a/src/streaming/OPNStreamView.h +++ b/src/streaming/OPNStreamView.h @@ -20,6 +20,7 @@ class IStreamSession; - (void)toggleSidebarHUD; - (void)setMicrophoneLevel:(double)level; - (void)setSuppressInputWhenWindowInactive:(BOOL)suppress; +- (void)setInputSuspendedForLibraryOverlay:(BOOL)suspended; - (void)attachToPipeline:(void *)pipeline; - (void)detachFromPipeline; - (void)handleKeyEvent:(NSEvent *)event; @@ -29,4 +30,6 @@ class IStreamSession; - (void)takeFocus; - (void)releasePointerLock; +@property (nonatomic, copy) void (^onGuideButtonPressed)(void); + @end diff --git a/src/streaming/OPNStreamView.mm b/src/streaming/OPNStreamView.mm index fd07b97e7..fa820496e 100644 --- a/src/streaming/OPNStreamView.mm +++ b/src/streaming/OPNStreamView.mm @@ -2,6 +2,7 @@ #include "OPNStreamSession.h" #include "OPNInputProtocol.h" #include "OPNStreamPreferences.h" +#include "../common/OPNUIHelpers.h" #import #import @@ -36,6 +37,7 @@ }; static constexpr NSTimeInterval OPNMouseCoalescingIntervalSeconds = 0.004; +static constexpr CFTimeInterval OPNGuideButtonDebounceSeconds = 0.35; static uint16_t OPNPushToTalkModifierFlags(NSEvent *event); @@ -77,6 +79,7 @@ @interface OPNStreamView () { BOOL _microphoneShortcutEnabled; BOOL _suppressInputWhenWindowInactive; BOOL _sidebarOpen; + BOOL _inputSuspendedForLibraryOverlay; double _gameVolume; double _microphoneVolumeLevel; double _microphoneLevel; @@ -85,6 +88,7 @@ @interface OPNStreamView () { int _maxBitrateMbps; OPNPadSnapshot _previousPads[GAMEPAD_MAX_CONTROLLERS]; CFTimeInterval _lastGamepadSend[GAMEPAD_MAX_CONTROLLERS]; + CFTimeInterval _lastGuideButtonDispatch; } @property (nonatomic, strong) OPNVideoSurfaceView *videoSurface; @property (nonatomic, strong) NSView *microphoneActiveOverlay; @@ -121,6 +125,7 @@ - (instancetype)initWithFrame:(NSRect)frame { _microphoneShortcutEnabled = YES; _suppressInputWhenWindowInactive = YES; _sidebarOpen = NO; + _inputSuspendedForLibraryOverlay = NO; OPN::StreamPreferenceProfile profile = OPN::LoadStreamPreferenceProfile(); _gameVolume = profile.gameVolume; _microphoneVolumeLevel = profile.microphoneVolume; @@ -138,6 +143,7 @@ - (instancetype)initWithFrame:(NSRect)frame { [self createMicrophoneActiveOverlay]; [self createSidebarHUDWithProfile:profile]; [self registerForControllerNotifications]; + [self registerGuideButtonHandlersForConnectedControllers]; } return self; } @@ -438,7 +444,19 @@ - (void)setSuppressInputWhenWindowInactive:(BOOL)suppress { _suppressInputWhenWindowInactive = suppress; } +- (void)setInputSuspendedForLibraryOverlay:(BOOL)suspended { + if (_inputSuspendedForLibraryOverlay == suspended) return; + _inputSuspendedForLibraryOverlay = suspended; + if (suspended) { + [self resetInputStateAfterSuppression]; + [self releaseCursorCapture]; + } else { + [self takeFocus]; + } +} + - (BOOL)streamWindowAcceptsInput { + if (_inputSuspendedForLibraryOverlay) return NO; if (_sidebarOpen) return NO; if (!_suppressInputWhenWindowInactive) return YES; NSWindow *window = self.window; @@ -964,7 +982,12 @@ - (void)registerForControllerNotifications { } - (void)controllerDidConnect:(NSNotification *)notification { - (void)notification; + GCController *controller = [notification.object isKindOfClass:GCController.class] ? notification.object : nil; + if (controller) { + [self registerGuideButtonHandlersForController:controller]; + } else { + [self registerGuideButtonHandlersForConnectedControllers]; + } NSLog(@"[StreamView] GameController connected"); [self startGamepadPolling]; } @@ -993,6 +1016,67 @@ - (void)stopGamepadPolling { _gamepadTimer = nil; } +- (void)registerGuideButtonHandlersForConnectedControllers { + for (GCController *controller in GCController.controllers) { + [self registerGuideButtonHandlersForController:controller]; + } +} + +- (void)registerGuideButtonHandlersForController:(GCController *)controller { + if (!controller.extendedGamepad) return; + + GCExtendedGamepad *pad = controller.extendedGamepad; + __weak OPNStreamView *weakSelf = self; + if (@available(macOS 11.0, *)) { + GCControllerButtonInput *homeButton = pad.buttonHome; + homeButton.pressedChangedHandler = ^(GCControllerButtonInput *button, float value, BOOL pressed) { + (void)button; + (void)value; + if (!pressed) return; + dispatch_async(dispatch_get_main_queue(), ^{ + OPNStreamView *strongSelf = weakSelf; + if (!strongSelf) return; + [strongSelf dispatchGuideButtonPressIfNeeded]; + }); + }; + } + + if (@available(macOS 13.0, *)) { + pad.valueDidChangeHandler = ^(GCPhysicalInputProfile *profile, GCControllerElement *element) { + if (![element.aliases containsObject:GCInputButtonHome]) return; + GCControllerButtonInput *homeButton = profile.buttons[GCInputButtonHome]; + BOOL pressed = homeButton ? homeButton.isPressed : NO; + if (!pressed && [element isKindOfClass:GCControllerButtonInput.class]) { + pressed = ((GCControllerButtonInput *)element).isPressed; + } + if (!pressed) return; + dispatch_async(dispatch_get_main_queue(), ^{ + OPNStreamView *strongSelf = weakSelf; + if (!strongSelf) return; + [strongSelf dispatchGuideButtonPressIfNeeded]; + }); + }; + } +} + +- (BOOL)dispatchGuideButtonPressIfNeeded { + if (!_streamSession || !self.onGuideButtonPressed || _inputSuspendedForLibraryOverlay) return NO; + + CFTimeInterval now = CACurrentMediaTime(); + if (now - _lastGuideButtonDispatch < OPNGuideButtonDebounceSeconds) return YES; + _lastGuideButtonDispatch = now; + + if (_sidebarOpen) { + _sidebarOpen = NO; + self.sidebarHUD.hidden = YES; + } + [self resetInputStateAfterSuppression]; + [self releaseCursorCapture]; + NSLog(@"[StreamView] GameController Home/Guide requested library overlay"); + self.onGuideButtonPressed(); + return YES; +} + static bool OPNStateEquals(const OPN::Input::GamepadState &a, const OPN::Input::GamepadState &b) { return a.connected == b.connected && a.buttons == b.buttons @@ -1066,6 +1150,17 @@ - (void)pollGamepads { state.timestampUs = OPN::Input::TimestampUs(); CFTimeInterval now = CACurrentMediaTime(); + uint16_t libraryShortcutMask = OpnControllerLibraryShortcutMask(); + BOOL guidePressed = (buttons & GAMEPAD_GUIDE) && (!_previousPads[i].known || !(_previousPads[i].state.buttons & GAMEPAD_GUIDE)); + BOOL shortcutPressed = libraryShortcutMask != 0 + && ((buttons & libraryShortcutMask) == libraryShortcutMask) + && (!_previousPads[i].known || ((_previousPads[i].state.buttons & libraryShortcutMask) != libraryShortcutMask)); + if ((guidePressed || shortcutPressed) && self.onGuideButtonPressed) { + _previousPads[i].known = true; + _previousPads[i].state = state; + _lastGamepadSend[i] = now; + if ([self dispatchGuideButtonPressIfNeeded]) return; + } BOOL changed = !_previousPads[i].known || !OPNStateEquals(_previousPads[i].state, state); BOOL keepalive = (now - _lastGamepadSend[i]) >= 1.0; if (changed || keepalive) { diff --git a/src/streaming/OPNStreamViewController.h b/src/streaming/OPNStreamViewController.h index 7e455d077..526e319fa 100644 --- a/src/streaming/OPNStreamViewController.h +++ b/src/streaming/OPNStreamViewController.h @@ -15,9 +15,13 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, copy) void (^onStreamEnd) (BOOL success, const std::string &errorMessage); +@property(nonatomic, copy) void (^onControllerLibraryRequested)(void); - (void)requestQuitGameConfirmation; - (void)shutdownForApplicationTermination; +- (void)prepareForLibraryPictureInPicture; +- (void)restoreFromLibraryPictureInPicture; +- (NSView *)streamPictureInPictureView; @end diff --git a/src/streaming/OPNStreamViewController.mm b/src/streaming/OPNStreamViewController.mm index 576a6dda0..886836f7b 100644 --- a/src/streaming/OPNStreamViewController.mm +++ b/src/streaming/OPNStreamViewController.mm @@ -793,10 +793,34 @@ - (void)loadView { view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; self.view = view; self.streamView = view; + __weak __typeof__(self) weakSelf = self; + view.onGuideButtonPressed = ^{ + __typeof__(self) strongSelf = weakSelf; + if (!strongSelf || strongSelf->_streamEnded) return; + if (strongSelf.onControllerLibraryRequested) strongSelf.onControllerLibraryRequested(); + }; [self.streamView setStreamSession:_session]; NSLog(@"[StreamVC] loadView called, view=%p", (__bridge void *)view); } +- (NSView *)streamPictureInPictureView { + return self.view; +} + +- (void)prepareForLibraryPictureInPicture { + [self removeQuitShortcutMonitor]; + [self dismissQuitGameOverlayAndRefocus:NO]; + [self.streamView setInputSuspendedForLibraryOverlay:YES]; + self.view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; +} + +- (void)restoreFromLibraryPictureInPicture { + self.view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + [self.streamView setInputSuspendedForLibraryOverlay:NO]; + [self installQuitShortcutMonitor]; + [self.streamView takeFocus]; +} + - (void)viewDidLoad { [super viewDidLoad]; NSLog(@"[StreamVC] viewDidLoad called"); diff --git a/src/views/OPNBackdropView.mm b/src/views/OPNBackdropView.mm index 686947f84..1b2c0c933 100644 --- a/src/views/OPNBackdropView.mm +++ b/src/views/OPNBackdropView.mm @@ -30,6 +30,23 @@ static unsigned OPNControllerAccentBlackRGB(CGFloat blackMix) { return OpnBlendRGB(OpnCurrentAccentRGB(), 0x000000, blackMix); } +static NSImage *OPNHeaderLogoImage(void) { + static NSImage *logo = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSString *bundlePath = [NSBundle.mainBundle pathForResource:@"logo" ofType:@"png"]; + NSString *relativePath = @"assets/logo.png"; + logo = [[NSImage alloc] initWithContentsOfFile:bundlePath ?: relativePath]; + }); + return logo; +} + +static NSString *OPNCurrentHeaderTimeText(void) { + NSDateFormatter *timeFormatter = [[NSDateFormatter alloc] init]; + timeFormatter.dateFormat = @"h:mm a"; + return [[timeFormatter stringFromDate:NSDate.date] uppercaseString]; +} + @implementation OPNBackdropView { NSRect _storeNavFrame; NSRect _libraryNavFrame; @@ -182,9 +199,6 @@ - (uint16_t)currentControllerNavigationButtons { uint16_t buttons = 0; if (pad.leftShoulder.value > 0.5) buttons |= 1u << 0; if (pad.rightShoulder.value > 0.5) buttons |= 1u << 1; - if (@available(macOS 10.15, *)) { - if (pad.buttonMenu.value > 0.5 || pad.buttonOptions.value > 0.5) buttons |= 1u << 2; - } return buttons; } @@ -213,7 +227,6 @@ - (void)pollControllerNavigation { uint16_t pressed = buttons & (uint16_t)~_previousControllerButtons; if (pressed & (1u << 0)) [self selectPreviousControllerTab]; if (pressed & (1u << 1)) [self selectNextControllerTab]; - if (pressed & (1u << 2)) [self accountButtonPressed:self]; _previousControllerButtons = buttons; } @@ -264,10 +277,16 @@ - (void)drawSparkleAtPoint:(NSPoint)point radius:(CGFloat)radius alpha:(CGFloat) - (void)drawControllerElectricBackgroundInRect:(NSRect)bounds { BOOL animationEnabled = OpnBackgroundAnimationEnabled(); CGFloat phase = animationEnabled ? (CGFloat)(CACurrentMediaTime() - _backgroundAnimationStartTime) : 0.0; + CGFloat tintStrength = OpnBackgroundTintStrength(); + CGFloat baseBlackA = 0.18 + 0.71 * tintStrength; + CGFloat baseBlackB = 0.10 + 0.71 * tintStrength; + CGFloat baseBlackC = 0.22 + 0.70 * tintStrength; + CGFloat vignetteBlack = 0.30 + 0.67 * tintStrength; + CGFloat vignetteAlpha = 0.04 + 0.26 * tintStrength; NSGradient *base = [[NSGradient alloc] initWithColors:@[ - OpnColor([self resolvedControllerAccentBlackRGB:0.89], 1.0), - OpnColor([self resolvedControllerAccentBlackRGB:0.81], 1.0), - OpnColor([self resolvedControllerAccentBlackRGB:0.92], 1.0), + OpnColor([self resolvedControllerAccentBlackRGB:baseBlackA], 1.0), + OpnColor([self resolvedControllerAccentBlackRGB:baseBlackB], 1.0), + OpnColor([self resolvedControllerAccentBlackRGB:baseBlackC], 1.0), ]]; [base drawInRect:bounds angle:88.0]; @@ -310,8 +329,8 @@ - (void)drawControllerElectricBackgroundInRect:(NSRect)bounds { [self drawSparkleAtPoint:NSMakePoint(x, y) radius:radius alpha:alpha color:sparkleColor]; } - NSGradient *vignette = [[NSGradient alloc] initWithStartingColor:OpnColor([self resolvedControllerAccentBlackRGB:0.88], 0.0) - endingColor:OpnColor([self resolvedControllerAccentBlackRGB:0.97], 0.30)]; + NSGradient *vignette = [[NSGradient alloc] initWithStartingColor:OpnColor([self resolvedControllerAccentBlackRGB:vignetteBlack], 0.0) + endingColor:OpnColor([self resolvedControllerAccentBlackRGB:vignetteBlack], vignetteAlpha)]; [vignette drawInRect:bounds angle:-90.0]; } @@ -428,26 +447,27 @@ - (void)drawRect:(NSRect)dirtyRect { NSRectFill(NSMakeRect(0, navHeight - 1.0, NSWidth(bounds), 1)); } - if (!controllerMode) { + NSImage *logo = OPNHeaderLogoImage(); + if (logo) { + CGFloat logoHeight = controllerMode ? 42.0 : 30.0; + CGFloat aspect = logo.size.height > 0 ? logo.size.width / logo.size.height : 1.0; + CGFloat logoWidth = MIN(controllerMode ? 180.0 : 132.0, logoHeight * aspect); + NSRect logoRect = controllerMode ? NSMakeRect(28.0, 18.0, logoWidth, logoHeight) : NSMakeRect(28.0, 17.0, logoWidth, logoHeight); + [logo drawInRect:logoRect + fromRect:NSZeroRect + operation:NSCompositingOperationSourceOver + fraction:1.0 + respectFlipped:YES + hints:@{NSImageHintInterpolation: @(NSImageInterpolationHigh)}]; + } else if (!controllerMode) { [@"OpenNOW" drawInRect:NSMakeRect(32.0, 21.0, 132, 22) withAttributes:OpnTextStyle(16.0, OpnColor(kTextPrimary), NSFontWeightSemibold)]; } - if (controllerMode) { - NSDateFormatter *timeFormatter = [[NSDateFormatter alloc] init]; - timeFormatter.dateFormat = @"h:mm a"; - NSString *timeText = [[timeFormatter stringFromDate:NSDate.date] uppercaseString]; - NSBezierPath *timeGlow = [NSBezierPath bezierPathWithRoundedRect:NSMakeRect(20.0, 32.0, 128.0, 30.0) xRadius:15.0 yRadius:15.0]; - [OpnColor([self resolvedControllerAccentRGB], 0.055) setFill]; - [timeGlow fill]; - [timeText drawInRect:NSMakeRect(32.0, 40.0, 112.0, 18.0) - withAttributes:OpnTextStyle(13.0, OpnColor(kTextSecondary), NSFontWeightSemibold)]; - } - NSArray *items = controllerMode ? @[@"Games", @"Settings"] : @[@"Store", @"Library", @"Settings"]; CGFloat widths[] = {82.0, 92.0, 78.0}; CGFloat navWidth = controllerMode ? widths[0] + widths[1] + 10.0 : widths[2] + widths[0] + widths[1] + 8.0; - CGFloat x = controllerMode ? 30.0 : floor((NSWidth(bounds) - navWidth) / 2.0); + CGFloat x = floor((NSWidth(bounds) - navWidth) / 2.0); _storeNavFrame = controllerMode ? NSZeroRect : _storeNavFrame; CGFloat navRowY = controllerMode ? 64.0 : 15.0; NSRect segmentedRect = NSMakeRect(x - 8.0, navRowY, navWidth + 16.0, controllerMode ? 42.0 : 34.0); @@ -507,7 +527,7 @@ - (void)drawRect:(NSRect)dirtyRect { [remaining drawInRect:NSInsetRect(planRect, 0, 5) withAttributes:remainingAttrs]; - NSString *gameCount = self.gameCountText.length > 0 ? self.gameCountText : @""; + NSString *gameCount = controllerMode ? OPNCurrentHeaderTimeText() : (self.gameCountText.length > 0 ? self.gameCountText : @""); NSMutableParagraphStyle *gameCountStyle = [[NSMutableParagraphStyle alloc] init]; gameCountStyle.alignment = controllerMode ? NSTextAlignmentRight : NSTextAlignmentCenter; NSMutableDictionary *gameCountAttrs = [OpnTextStyle(10, OpnColor(kTextMuted), NSFontWeightMedium) mutableCopy]; diff --git a/src/views/OPNEmailEntryView.mm b/src/views/OPNEmailEntryView.mm index 0fe2551c6..6d8b60007 100644 --- a/src/views/OPNEmailEntryView.mm +++ b/src/views/OPNEmailEntryView.mm @@ -43,35 +43,11 @@ - (void)buildUI { card.layer.shadowOffset = CGSizeMake(0, 14); [content addSubview:card]; - [card addSubview:OpnLabel(@"Sign in to OpenNOW", NSMakeRect(0, 34, 400, 30), - 24, OpnColor(kTextPrimary), NSFontWeightSemibold, NSTextAlignmentCenter)]; - [card addSubview:OpnLabel(@"Access your cloud gaming library with your NVIDIA account.", - NSMakeRect(56, 74, 288, 38), 13, + NSMakeRect(56, 48, 288, 38), 13, OpnColor(kTextMuted), NSFontWeightRegular, NSTextAlignmentCenter)]; - NSTextField *providerLabel = OpnLabel(@"Provider", NSMakeRect(56, 128, 288, 18), - 10, OpnColor(kTextMuted), NSFontWeightSemibold); - providerLabel.attributedStringValue = [[NSAttributedString alloc] initWithString:@"Provider" - attributes:@{ - NSFontAttributeName: [NSFont systemFontOfSize:10 weight:NSFontWeightSemibold], - NSForegroundColorAttributeName: OpnColor(kTextMuted), - }]; - [card addSubview:providerLabel]; - - NSView *provider = [[NSView alloc] initWithFrame:NSMakeRect(56, 150, 288, 48)]; - provider.wantsLayer = YES; - provider.layer.backgroundColor = OpnColor(kInputBackground, 0.72).CGColor; - provider.layer.cornerRadius = 12; - provider.layer.borderWidth = 1; - provider.layer.borderColor = OpnColor(kPanelBorder, 0.72).CGColor; - [card addSubview:provider]; - [provider addSubview:OpnLabel(@"NVIDIA Account", NSMakeRect(16, 14, 210, 20), - 13, OpnColor(kTextPrimary), NSFontWeightRegular)]; - [provider addSubview:OpnLabel(@"Selected", NSMakeRect(210, 15, 62, 18), - 12, OpnColor(kTextMuted), NSFontWeightRegular, NSTextAlignmentRight)]; - - self.stayLoggedInToggle = [[NSButton alloc] initWithFrame:NSMakeRect(54, 214, 180, 24)]; + self.stayLoggedInToggle = [[NSButton alloc] initWithFrame:NSMakeRect(54, 168, 180, 24)]; self.stayLoggedInToggle.buttonType = NSButtonTypeSwitch; self.stayLoggedInToggle.title = @"Keep me signed in"; self.stayLoggedInToggle.font = [NSFont systemFontOfSize:13 weight:NSFontWeightMedium]; @@ -81,7 +57,7 @@ - (void)buildUI { [card addSubview:self.stayLoggedInToggle]; NSButton *browserButton = OpnButton(@"Continue with Browser", - NSMakeRect(56, 260, 288, 48), + NSMakeRect(56, 224, 288, 48), OpnColor(kBrandGreen), OpnColor(kAccentOn)); browserButton.font = [NSFont systemFontOfSize:14 weight:NSFontWeightSemibold]; browserButton.target = self; diff --git a/src/views/OPNGameCatalogView.h b/src/views/OPNGameCatalogView.h index 0c76eccb8..8053447a6 100644 --- a/src/views/OPNGameCatalogView.h +++ b/src/views/OPNGameCatalogView.h @@ -9,6 +9,7 @@ @property (nonatomic, copy) void (^onGameCountChanged)(NSInteger count); @property (nonatomic, copy) void (^onCatalogBrowseRequested)(NSString *searchQuery, NSString *sortId, const std::vector &filterIds); @property (nonatomic, copy) void (^onFocusedArtworkAccentChanged)(unsigned accentRGB); +@property (nonatomic, copy) void (^onStreamPictureInPictureSelected)(void); - (instancetype)initWithFrame:(NSRect)frame; - (void)setGames:(const std::vector &)games; @@ -16,5 +17,6 @@ - (void)setLoading:(BOOL)loading; - (void)setError:(NSString *)message; - (void)setUserName:(NSString *)name; +- (void)setStreamPictureInPictureView:(NSView *)view title:(NSString *)title; @end diff --git a/src/views/OPNGameCatalogView.mm b/src/views/OPNGameCatalogView.mm index c7146e9e4..8e9d7f0be 100644 --- a/src/views/OPNGameCatalogView.mm +++ b/src/views/OPNGameCatalogView.mm @@ -43,6 +43,19 @@ static unsigned OPNRGBFromColor(NSColor *color, unsigned fallbackRGB) { return value.empty() ? fallback : [NSString stringWithUTF8String:value.c_str()]; } +static NSAttributedString *OPNOutlinedControllerStoreText(NSString *text) { + NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; + style.lineBreakMode = NSLineBreakByTruncatingTail; + return [[NSAttributedString alloc] initWithString:text ?: @"" + attributes:@{ + NSFontAttributeName: [NSFont systemFontOfSize:16.0 weight:NSFontWeightSemibold], + NSForegroundColorAttributeName: NSColor.whiteColor, + NSStrokeColorAttributeName: NSColor.blackColor, + NSStrokeWidthAttributeName: @-3.0, + NSParagraphStyleAttributeName: style, + }]; +} + @interface OPNFlippedGridDocumentView : NSView @end @@ -258,6 +271,12 @@ @interface OPNGameCatalogView () @property (nonatomic, strong) NSTextField *controllerDetailStatsLabel; @property (nonatomic, strong) NSTextField *controllerDetailFeaturesLabel; @property (nonatomic, strong) OPNControllerPromptBarView *controllerPromptBarView; +@property (nonatomic, strong) NSView *streamPipContainerView; +@property (nonatomic, strong) NSView *streamPipHostView; +@property (nonatomic, strong) NSTextField *streamPipTitleLabel; +@property (nonatomic, strong) NSTextField *streamPipHintLabel; +@property (nonatomic, weak) NSView *streamPipContentView; +@property (nonatomic, assign, getter=isStreamPipFocused) BOOL streamPipFocused; @property (nonatomic, strong) CAGradientLayer *controllerDetailGradientLayer; @property (nonatomic, strong) CALayer *controllerDetailAccentLayer; @property (nonatomic, strong) NSMutableArray *cardViews; @@ -292,6 +311,7 @@ - (void)closeGameDetails; - (void)launchFocusedGame; - (void)cycleFocusedVariant; - (void)updateControllerDetailContent; +- (void)setStreamPipFocused:(BOOL)focused; - (void)startGamepadNavigationIfNeeded; - (void)controllerDidConnect:(NSNotification *)notification; - (void)controllerDidDisconnect:(NSNotification *)notification; @@ -722,7 +742,7 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerDetailMetaLabel = OpnLabel(@"", NSZeroRect, 15.0, OpnColor(kTextSecondary), NSFontWeightMedium); [_controllerDetailView addSubview:_controllerDetailMetaLabel]; - _controllerDetailStoreLabel = OpnLabel(@"", NSZeroRect, 16.0, OpnColor(OPNControllerAccentSoftRGB()), NSFontWeightSemibold); + _controllerDetailStoreLabel = OpnLabel(@"", NSZeroRect, 16.0, NSColor.whiteColor, NSFontWeightSemibold); [_controllerDetailView addSubview:_controllerDetailStoreLabel]; _controllerDetailStatsLabel = OpnLabel(@"", NSZeroRect, 14.0, OpnColor(kTextSecondary), NSFontWeightMedium); @@ -736,6 +756,32 @@ - (instancetype)initWithFrame:(NSRect)frame { _controllerPromptBarView.wantsLayer = YES; [_controllerDetailView addSubview:_controllerPromptBarView]; + _streamPipContainerView = [[NSView alloc] initWithFrame:NSZeroRect]; + _streamPipContainerView.hidden = YES; + _streamPipContainerView.wantsLayer = YES; + _streamPipContainerView.layer.cornerRadius = 22.0; + _streamPipContainerView.layer.masksToBounds = NO; + _streamPipContainerView.layer.backgroundColor = OpnColor(0x030507, 0.68).CGColor; + _streamPipContainerView.layer.borderWidth = 1.0; + _streamPipContainerView.layer.borderColor = OpnColor(0xFFFFFF, 0.16).CGColor; + _streamPipContainerView.layer.shadowColor = NSColor.blackColor.CGColor; + _streamPipContainerView.layer.shadowOpacity = 0.30; + _streamPipContainerView.layer.shadowRadius = 24.0; + _streamPipContainerView.layer.shadowOffset = CGSizeMake(0.0, 12.0); + + _streamPipHostView = [[NSView alloc] initWithFrame:NSZeroRect]; + _streamPipHostView.wantsLayer = YES; + _streamPipHostView.layer.cornerRadius = 18.0; + _streamPipHostView.layer.masksToBounds = YES; + _streamPipHostView.layer.backgroundColor = NSColor.blackColor.CGColor; + [_streamPipContainerView addSubview:_streamPipHostView]; + + _streamPipTitleLabel = OpnLabel(@"Current Stream", NSZeroRect, 14.0, OpnColor(kTextPrimary), NSFontWeightSemibold); + [_streamPipContainerView addSubview:_streamPipTitleLabel]; + _streamPipHintLabel = OpnLabel(@"Press A to return", NSZeroRect, 12.0, OpnColor(kTextSecondary), NSFontWeightMedium, NSTextAlignmentRight); + [_streamPipContainerView addSubview:_streamPipHintLabel]; + [self addSubview:_streamPipContainerView]; + _loadingView = [[OPNLoadingView alloc] initWithFrame:self.bounds message:@"Loading games..."]; _loadingView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; @@ -782,7 +828,7 @@ - (void)applyControllerAccentColors { (id)NSColor.clearColor.CGColor]; self.controllerDetailGradientLayer.opacity = 0.0; self.controllerDetailAccentLayer.backgroundColor = OpnColor(OPNControllerAccentSoftRGB(), 0.86).CGColor; - self.controllerDetailStoreLabel.textColor = OpnColor(OPNControllerAccentSoftRGB()); + self.controllerDetailStoreLabel.textColor = NSColor.whiteColor; self.layer.backgroundColor = NSColor.clearColor.CGColor; [self.controllerElectricBackgroundView setNeedsDisplay:YES]; } @@ -818,6 +864,30 @@ - (void)setUserName:(NSString *)name { _userLabel.stringValue = name ? [NSString stringWithFormat:@"Signed in as %@", name] : @""; } +- (void)setStreamPictureInPictureView:(NSView *)view title:(NSString *)title { + if (self.streamPipContentView == view) { + self.streamPipTitleLabel.stringValue = title.length > 0 ? title : @"Current Stream"; + [self layoutCatalogSubviews]; + return; + } + + [self.streamPipContentView removeFromSuperview]; + self.streamPipContentView = nil; + self.streamPipTitleLabel.stringValue = title.length > 0 ? title : @"Current Stream"; + self.streamPipHintLabel.stringValue = @"Press A to return"; + + if (view) { + view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + view.frame = self.streamPipHostView.bounds; + [self.streamPipHostView addSubview:view]; + self.streamPipContentView = view; + } else { + self.streamPipFocused = NO; + } + [self setStreamPipFocused:NO]; + [self layoutCatalogSubviews]; +} + - (void)setLoading:(BOOL)loading { _loadingView.hidden = !loading; if (loading) { @@ -1333,6 +1403,21 @@ - (void)layoutCatalogSubviews { self.controllerDetailFeaturesLabel.hidden = NO; self.controllerDetailFeaturesLabel.frame = NSMakeRect(heroX + 2.0, featuresY, MIN(980.0, heroWidth), MAX(0.0, detailHeight - featuresY - 88.0)); self.controllerPromptBarView.frame = NSMakeRect(heroX + 2.0, MAX(188.0, detailHeight - 52.0), heroWidth, 36.0); + BOOL showStreamPip = controllerMode && self.streamPipContentView != nil; + self.streamPipContainerView.hidden = !showStreamPip; + if (showStreamPip) { + CGFloat pipWidth = MIN(420.0, MAX(300.0, width * 0.24)); + CGFloat pipVideoHeight = floor(pipWidth * 9.0 / 16.0); + CGFloat pipHeight = pipVideoHeight + 54.0; + CGFloat pipX = MAX(heroX + 520.0, width - pipWidth - 64.0); + CGFloat pipY = MIN(detailY + detailHeight - pipHeight - 48.0, detailY + MAX(34.0, detailHeight * 0.24)); + pipY = MAX(detailY + 28.0, pipY); + self.streamPipContainerView.frame = NSMakeRect(pipX, pipY, pipWidth, pipHeight); + self.streamPipHostView.frame = NSMakeRect(12.0, 12.0, pipWidth - 24.0, pipVideoHeight); + self.streamPipContentView.frame = self.streamPipHostView.bounds; + self.streamPipTitleLabel.frame = NSMakeRect(16.0, pipVideoHeight + 22.0, pipWidth * 0.50, 22.0); + self.streamPipHintLabel.frame = NSMakeRect(pipWidth * 0.50 - 12.0, pipVideoHeight + 24.0, pipWidth * 0.50, 18.0); + } CGFloat railFrameY = controllerMode ? MAX(0.0, gridY - selectorOverlap) : gridY; CGFloat railHeight = controllerMode ? MIN(carouselHeight + selectorOverlap, MAX(0.0, height - railFrameY)) : MAX(0.0, height - gridY); self.scrollView.frame = NSMakeRect(0, railFrameY, width, railHeight); @@ -1375,6 +1460,7 @@ - (void)focusCardAtIndex:(NSInteger)index scrollIntoView:(BOOL)scrollIntoView { self.focusedCardIndex = -1; return; } + [self setStreamPipFocused:NO]; NSInteger previousIndex = self.focusedCardIndex; NSInteger clamped = MAX(0, MIN(index, (NSInteger)self.cardViews.count - 1)); self.focusedCardIndex = clamped; @@ -1406,6 +1492,24 @@ - (void)focusCardAtIndex:(NSInteger)index scrollIntoView:(BOOL)scrollIntoView { } } +- (void)setStreamPipFocused:(BOOL)focused { + if (focused && self.streamPipContentView == nil) focused = NO; + _streamPipFocused = focused; + [CATransaction begin]; + [CATransaction setAnimationDuration:0.20]; + [CATransaction setAnimationTimingFunction:[OPNCoreAnimationCoordinator appleQuinticTimingFunction]]; + self.streamPipContainerView.layer.borderWidth = focused ? 3.0 : 1.0; + self.streamPipContainerView.layer.borderColor = (focused ? OpnColor(0xFFFFFF, 0.92) : OpnColor(0xFFFFFF, 0.16)).CGColor; + self.streamPipContainerView.layer.shadowColor = (focused ? OpnColor(OPNControllerAccentSoftRGB()) : NSColor.blackColor).CGColor; + self.streamPipContainerView.layer.shadowOpacity = focused ? 0.48 : 0.30; + self.streamPipContainerView.layer.shadowRadius = focused ? 38.0 : 24.0; + CATransform3D transform = CATransform3DIdentity; + if (focused) transform = CATransform3DScale(transform, 1.035, 1.035, 1.0); + self.streamPipContainerView.layer.transform = transform; + self.streamPipHintLabel.textColor = focused ? OpnColor(OPNControllerAccentSoftRGB()) : OpnColor(kTextSecondary); + [CATransaction commit]; +} + - (OPNGameCardView *)focusedCard { if (self.focusedCardIndex < 0 || self.focusedCardIndex >= (NSInteger)self.cardViews.count) return nil; return self.cardViews[(NSUInteger)self.focusedCardIndex]; @@ -1413,9 +1517,27 @@ - (OPNGameCardView *)focusedCard { - (void)moveFocusByRows:(NSInteger)rows columns:(NSInteger)columns { if (OpnControllerModeEnabled() && rows != 0) { + if (self.streamPipContentView) { + if (self.isStreamPipFocused && rows < 0) { + [self setStreamPipFocused:NO]; + return; + } + if (!self.isStreamPipFocused && rows > 0) { + [self setStreamPipFocused:YES]; + OpnPlayConsoleTone(OPNConsoleToneMove); + return; + } + } [self cycleCategoryBy:rows > 0 ? 1 : -1]; return; } + if (OpnControllerModeEnabled() && self.isStreamPipFocused) { + if (columns < 0 || columns > 0) { + [self setStreamPipFocused:NO]; + OpnPlayConsoleTone(OPNConsoleToneMove); + } + return; + } NSInteger next = self.focusedCardIndex + rows * MAX(1, self.gridColumnCount) + columns; [self focusCardAtIndex:next scrollIntoView:YES]; } @@ -1451,12 +1573,12 @@ - (void)updateControllerDetailContent { self.controllerDetailGradientLayer.opacity = 0.0; self.controllerDetailAccentLayer.backgroundColor = [detailAccentSoftColor colorWithAlphaComponent:0.90].CGColor; self.controllerDetailView.layer.shadowColor = detailAccentColor.CGColor; - self.controllerDetailStoreLabel.textColor = detailAccentSoftColor; + self.controllerDetailStoreLabel.textColor = NSColor.whiteColor; [CATransaction commit]; if (!card) { self.controllerDetailTitleLabel.stringValue = @"Select a game"; self.controllerDetailMetaLabel.stringValue = @""; - self.controllerDetailStoreLabel.stringValue = @""; + self.controllerDetailStoreLabel.attributedStringValue = OPNOutlinedControllerStoreText(@""); self.controllerDetailStatsLabel.stringValue = @""; self.controllerDetailFeaturesLabel.stringValue = @""; self.controllerPromptBarView.hidden = YES; @@ -1482,7 +1604,7 @@ - (void)updateControllerDetailContent { store = OPNCatalogString(game.availableStores.front(), store); } NSString *storePrefix = game.variants.size() > 1 ? @"Selected Store" : @"Store"; - self.controllerDetailStoreLabel.stringValue = [NSString stringWithFormat:@"%@: %@", storePrefix, store]; + self.controllerDetailStoreLabel.attributedStringValue = OPNOutlinedControllerStoreText([NSString stringWithFormat:@"%@: %@", storePrefix, store]); self.controllerDetailStatsLabel.stringValue = @""; NSString *description = OPNCatalogString(game.description, @""); @@ -1529,11 +1651,12 @@ - (void)openFocusedGameDetails { if (card.selectedVariantIndex >= 0 && card.selectedVariantIndex < (int)card.game.variants.size()) { store = OPNCatalogString(card.game.variants[(size_t)card.selectedVariantIndex].appStore, store); } - NSTextField *storeLabel = OpnLabel([NSString stringWithFormat:@"Selected Store: %@", store], + NSTextField *storeLabel = OpnLabel(@"", NSMakeRect(38.0, 92.0, panelWidth - 76.0, 24.0), 15.0, - OpnColor(OPNControllerAccentSoftRGB()), + NSColor.whiteColor, NSFontWeightSemibold); + storeLabel.attributedStringValue = OPNOutlinedControllerStoreText([NSString stringWithFormat:@"Selected Store: %@", store]); [panel addSubview:storeLabel]; NSString *body = card.game.variants.size() > 1 @@ -1576,6 +1699,11 @@ - (void)closeGameDetails { } - (void)launchFocusedGame { + if (self.isStreamPipFocused && self.onStreamPictureInPictureSelected) { + OpnPlayConsoleTone(OPNConsoleToneSelect); + self.onStreamPictureInPictureSelected(); + return; + } OPNGameCardView *card = [self focusedCard]; if (!card || !self.onSelectGame) return; if (OpnControllerModeEnabled()) OpnPlayConsoleTone(OPNConsoleToneSelect); @@ -1667,7 +1795,9 @@ - (void)pollGamepadNavigation { if (pressed & (1u << 0)) { [self launchFocusedGame]; } - if (pressed & (1u << 1)) { } + if (pressed & (1u << 1)) { + if (self.isStreamPipFocused) [self setStreamPipFocused:NO]; + } if (pressed & (1u << 2)) [self toggleFavoriteForFocusedGame]; if (pressed & (1u << 5)) [self moveFocusByRows:-1 columns:0]; if (pressed & (1u << 6)) [self moveFocusByRows:1 columns:0]; diff --git a/src/views/OPNSettingsView.mm b/src/views/OPNSettingsView.mm index 5185605c5..7791909f2 100644 --- a/src/views/OPNSettingsView.mm +++ b/src/views/OPNSettingsView.mm @@ -2,9 +2,11 @@ #import "../common/OPNColorTokens.h" #import "../common/OPNUIHelpers.h" #include "../games/OPNGameService.h" +#include "../streaming/OPNInputProtocol.h" #include "../streaming/OPNLibWebRTCStreamSession.h" #include "../streaming/OPNStreamBackend.h" #include "../streaming/OPNStreamPreferences.h" +#import #include #include #include @@ -74,6 +76,80 @@ static uint16_t OPNShortcutModifierBitForKeyCode(uint16_t keyCode) { } } +static NSString *OPNControllerShortcutNameForButton(uint16_t button) { + using namespace OPN::Input; + switch (button) { + case GAMEPAD_DPAD_UP: return @"D-Pad Up"; + case GAMEPAD_DPAD_DOWN: return @"D-Pad Down"; + case GAMEPAD_DPAD_LEFT: return @"D-Pad Left"; + case GAMEPAD_DPAD_RIGHT: return @"D-Pad Right"; + case GAMEPAD_START: return @"Menu"; + case GAMEPAD_BACK: return @"View"; + case GAMEPAD_LS: return @"L3"; + case GAMEPAD_RS: return @"R3"; + case GAMEPAD_LB: return @"LB"; + case GAMEPAD_RB: return @"RB"; + case GAMEPAD_GUIDE: return @"Home"; + case GAMEPAD_A: return @"A / Cross"; + case GAMEPAD_B: return @"B / Circle"; + case GAMEPAD_X: return @"X / Square"; + case GAMEPAD_Y: return @"Y / Triangle"; + default: return nil; + } +} + +static NSString *OPNControllerShortcutLabel(uint16_t mask) { + using namespace OPN::Input; + if (mask == 0) return @"Disabled"; + static const uint16_t order[] = { + GAMEPAD_BACK, GAMEPAD_START, GAMEPAD_LB, GAMEPAD_RB, GAMEPAD_LS, GAMEPAD_RS, + GAMEPAD_DPAD_UP, GAMEPAD_DPAD_DOWN, GAMEPAD_DPAD_LEFT, GAMEPAD_DPAD_RIGHT, + GAMEPAD_A, GAMEPAD_B, GAMEPAD_X, GAMEPAD_Y, GAMEPAD_GUIDE, + }; + NSMutableArray *parts = [NSMutableArray array]; + uint16_t remaining = mask; + for (uint16_t button : order) { + if ((mask & button) == 0) continue; + NSString *name = OPNControllerShortcutNameForButton(button); + if (name.length > 0) [parts addObject:name]; + remaining &= (uint16_t)~button; + } + if (remaining != 0) { + [parts addObject:[NSString stringWithFormat:@"0x%04X", remaining]]; + } + return parts.count > 0 ? [parts componentsJoinedByString:@" + "] : @"Disabled"; +} + +static uint16_t OPNPressedControllerShortcutMask(void) { + using namespace OPN::Input; + uint16_t buttons = 0; + for (GCController *controller in GCController.controllers) { + GCExtendedGamepad *pad = controller.extendedGamepad; + if (!pad) continue; + if (pad.buttonA.value > 0) buttons |= GAMEPAD_A; + if (pad.buttonB.value > 0) buttons |= GAMEPAD_B; + if (pad.buttonX.value > 0) buttons |= GAMEPAD_X; + if (pad.buttonY.value > 0) buttons |= GAMEPAD_Y; + if (pad.leftShoulder.value > 0) buttons |= GAMEPAD_LB; + if (pad.rightShoulder.value > 0) buttons |= GAMEPAD_RB; + if (pad.dpad.up.value > 0) buttons |= GAMEPAD_DPAD_UP; + if (pad.dpad.down.value > 0) buttons |= GAMEPAD_DPAD_DOWN; + if (pad.dpad.left.value > 0) buttons |= GAMEPAD_DPAD_LEFT; + if (pad.dpad.right.value > 0) buttons |= GAMEPAD_DPAD_RIGHT; + if (@available(macOS 10.15, *)) { + if (pad.buttonOptions.value > 0) buttons |= GAMEPAD_BACK; + if (pad.buttonMenu.value > 0) buttons |= GAMEPAD_START; + if (pad.leftThumbstickButton.value > 0) buttons |= GAMEPAD_LS; + if (pad.rightThumbstickButton.value > 0) buttons |= GAMEPAD_RS; + } + if (@available(macOS 11.0, *)) { + if (pad.buttonHome.value > 0) buttons |= GAMEPAD_GUIDE; + } + if (buttons != 0) break; + } + return buttons; +} + @interface OPNPushToTalkShortcutField : NSTextField @property (nonatomic, assign) uint16_t shortcutKeyCode; @property (nonatomic, assign) uint16_t shortcutModifierMask; @@ -176,9 +252,16 @@ @interface OPNSettingsView () @property (nonatomic, assign) BOOL enableL4S; @property (nonatomic, assign) BOOL suppressInputWhenInactive; @property (nonatomic, strong) NSTextField *posterSizeValueLabel; +@property (nonatomic, strong) NSTextField *backgroundTintValueLabel; @property (nonatomic, strong) NSTextField *accentRedValueLabel; @property (nonatomic, strong) NSTextField *accentGreenValueLabel; @property (nonatomic, strong) NSTextField *accentBlueValueLabel; +@property (nonatomic, strong) NSButton *controllerShortcutCaptureButton; +@property (nonatomic, strong) NSTextField *controllerShortcutStatusLabel; +@property (nonatomic, strong) NSTimer *controllerShortcutCaptureTimer; +@property (nonatomic, assign) CFTimeInterval controllerShortcutCaptureDeadline; +@property (nonatomic, assign) CFTimeInterval controllerShortcutPendingSince; +@property (nonatomic, assign) uint16_t controllerShortcutPendingMask; @property (nonatomic, assign) BOOL audioDeviceListenerInstalled; @property (nonatomic, assign) CGFloat contentAreaWidth; - (void)applyPerformanceProfile:(NSInteger)index; @@ -262,6 +345,7 @@ - (instancetype)initWithFrame:(NSRect)frame { - (BOOL)isFlipped { return YES; } - (void)dealloc { + [self.controllerShortcutCaptureTimer invalidate]; [self stopAudioDeviceMonitoring]; [[NSNotificationCenter defaultCenter] removeObserver:self]; } @@ -729,7 +813,7 @@ - (void)buildInputContent { } - (void)buildInterfaceContent { - NSView *panel = [self panelWithTitle:@"Interface" height:596.0]; + NSView *panel = [self panelWithTitle:@"Interface" height:870.0]; CGFloat panelWidth = MAX(320.0, NSWidth(panel.frame)); CGFloat controlX = [self controlXForPanelWidth:panelWidth]; CGFloat controlWidth = [self controlWidthForPanelWidth:panelWidth]; @@ -777,15 +861,99 @@ - (void)buildInterfaceContent { backgroundHint.maximumNumberOfLines = 2; [panel addSubview:backgroundHint]; - [panel addSubview:[self rowLabel:@"Accent Color" y:282.0]]; + [panel addSubview:[self rowLabel:@"Derived Accent" y:292.0]]; + NSButton *derivedAccentToggle = [[NSButton alloc] initWithFrame:NSMakeRect(controlX, 284.0, controlWidth, 28.0)]; + derivedAccentToggle.buttonType = NSButtonTypeSwitch; + derivedAccentToggle.title = @"Use focused game artwork to tint Controller Mode"; + derivedAccentToggle.font = [NSFont systemFontOfSize:13.0 weight:NSFontWeightMedium]; + derivedAccentToggle.contentTintColor = OpnColor(kBrandGreen); + derivedAccentToggle.state = OpnDerivedAccentColorsEnabled() ? NSControlStateValueOn : NSControlStateValueOff; + derivedAccentToggle.target = self; + derivedAccentToggle.action = @selector(derivedAccentToggleChanged:); + [panel addSubview:derivedAccentToggle]; + + NSTextField *derivedAccentHint = OpnLabel(@"When off, Controller Mode uses your manual accent color instead of per-game artwork colors.", + NSMakeRect(controlX, 320.0, controlWidth, 38.0), + 12.0, + OpnColor(kTextMuted), + NSFontWeightRegular); + derivedAccentHint.maximumNumberOfLines = 2; + [panel addSubview:derivedAccentHint]; + + [panel addSubview:[self rowLabel:@"Background Tint" y:386.0]]; + NSSlider *backgroundTintSlider = [[NSSlider alloc] initWithFrame:NSMakeRect(controlX, 380.0, MIN(300.0, controlWidth - 72.0), 28.0)]; + backgroundTintSlider.minValue = 0.0; + backgroundTintSlider.maxValue = 100.0; + backgroundTintSlider.doubleValue = OpnBackgroundTintStrength() * 100.0; + backgroundTintSlider.continuous = YES; + backgroundTintSlider.target = self; + backgroundTintSlider.action = @selector(backgroundTintSliderChanged:); + [panel addSubview:backgroundTintSlider]; + + self.backgroundTintValueLabel = OpnLabel([NSString stringWithFormat:@"%.0f%%", backgroundTintSlider.doubleValue], + NSMakeRect(controlX + MIN(312.0, controlWidth - 60.0), 384.0, 60.0, 22.0), + 12.0, + OpnColor(kTextSecondary), + NSFontWeightSemibold, + NSTextAlignmentRight); + [panel addSubview:self.backgroundTintValueLabel]; + + NSTextField *tintHint = OpnLabel(@"Lower values keep the animated background brighter. Set to 0% to remove the dark tint.", + NSMakeRect(controlX, 418.0, controlWidth, 38.0), + 12.0, + OpnColor(kTextMuted), + NSFontWeightRegular); + tintHint.maximumNumberOfLines = 2; + [panel addSubview:tintHint]; + + [panel addSubview:[self rowLabel:@"Stream Library Shortcut" y:480.0]]; + CGFloat shortcutButtonWidth = MIN(300.0, MAX(170.0, controlWidth - 112.0)); + NSButton *shortcutButton = [[NSButton alloc] initWithFrame:NSMakeRect(controlX, 470.0, shortcutButtonWidth, 38.0)]; + shortcutButton.title = OPNControllerShortcutLabel(OpnControllerLibraryShortcutMask()); + shortcutButton.font = [NSFont systemFontOfSize:13.0 weight:NSFontWeightSemibold]; + shortcutButton.bordered = NO; + shortcutButton.contentTintColor = OpnColor(kTextPrimary); + shortcutButton.wantsLayer = YES; + shortcutButton.layer.cornerRadius = 10.0; + shortcutButton.layer.borderWidth = 1.0; + shortcutButton.layer.borderColor = OpnColor(kBrandGreen, 0.38).CGColor; + shortcutButton.layer.backgroundColor = OpnColor(kInputBackground, 0.72).CGColor; + shortcutButton.target = self; + shortcutButton.action = @selector(controllerShortcutCaptureClicked:); + self.controllerShortcutCaptureButton = shortcutButton; + [panel addSubview:shortcutButton]; + + NSButton *shortcutReset = [[NSButton alloc] initWithFrame:NSMakeRect(controlX + shortcutButtonWidth + 10.0, 470.0, MIN(96.0, MAX(76.0, controlWidth - shortcutButtonWidth - 10.0)), 38.0)]; + shortcutReset.title = @"Default"; + shortcutReset.font = [NSFont systemFontOfSize:13.0 weight:NSFontWeightMedium]; + shortcutReset.bordered = NO; + shortcutReset.contentTintColor = OpnColor(kTextMuted); + shortcutReset.wantsLayer = YES; + shortcutReset.layer.cornerRadius = 10.0; + shortcutReset.layer.borderWidth = 1.0; + shortcutReset.layer.borderColor = OpnColor(kPanelBorder, 0.72).CGColor; + shortcutReset.layer.backgroundColor = OpnColor(kInputBackground, 0.52).CGColor; + shortcutReset.target = self; + shortcutReset.action = @selector(controllerShortcutResetClicked:); + [panel addSubview:shortcutReset]; + + self.controllerShortcutStatusLabel = OpnLabel(@"Click the combo, press and hold any controller button or combo, then release after it saves.", + NSMakeRect(controlX, 518.0, controlWidth, 54.0), + 12.0, + OpnColor(kTextMuted), + NSFontWeightRegular); + self.controllerShortcutStatusLabel.maximumNumberOfLines = 3; + [panel addSubview:self.controllerShortcutStatusLabel]; + + [panel addSubview:[self rowLabel:@"Accent Color" y:588.0]]; NSTextField *accentSummary = OpnLabel([NSString stringWithFormat:@"RGB %ld, %ld, %ld", (long)red, (long)green, (long)blue], - NSMakeRect(controlX, 282.0, controlWidth, 20.0), + NSMakeRect(controlX, 588.0, controlWidth, 20.0), 13.0, OpnColor(kTextPrimary), NSFontWeightSemibold); [panel addSubview:accentSummary]; - NSView *swatch = [[NSView alloc] initWithFrame:NSMakeRect(controlX + MIN(162.0, controlWidth - 36.0), 279.0, 34.0, 24.0)]; + NSView *swatch = [[NSView alloc] initWithFrame:NSMakeRect(controlX + MIN(162.0, controlWidth - 36.0), 585.0, 34.0, 24.0)]; swatch.wantsLayer = YES; swatch.layer.cornerRadius = 8.0; swatch.layer.backgroundColor = OpnColor(kBrandGreen).CGColor; @@ -796,7 +964,7 @@ - (void)buildInterfaceContent { NSArray *channelNames = @[@"Red", @"Green", @"Blue"]; NSArray *channelValues = @[@(red), @(green), @(blue)]; for (NSInteger i = 0; i < 3; i++) { - CGFloat y = 318.0 + i * 42.0; + CGFloat y = 624.0 + i * 42.0; NSTextField *label = OpnLabel(channelNames[(NSUInteger)i], NSMakeRect(controlX, y + 3.0, 62.0, 20.0), 12.0, OpnColor(kTextSecondary), NSFontWeightMedium); [panel addSubview:label]; @@ -822,8 +990,8 @@ - (void)buildInterfaceContent { if (i == 2) self.accentBlueValueLabel = valueLabel; } - [panel addSubview:[self rowLabel:@"Poster Size" y:452.0]]; - NSSlider *posterSlider = [[NSSlider alloc] initWithFrame:NSMakeRect(controlX, 446.0, MIN(300.0, controlWidth - 72.0), 28.0)]; + [panel addSubview:[self rowLabel:@"Poster Size" y:758.0]]; + NSSlider *posterSlider = [[NSSlider alloc] initWithFrame:NSMakeRect(controlX, 752.0, MIN(300.0, controlWidth - 72.0), 28.0)]; posterSlider.minValue = 80.0; posterSlider.maxValue = 130.0; posterSlider.doubleValue = OpnPosterSizeScale() * 100.0; @@ -833,15 +1001,15 @@ - (void)buildInterfaceContent { [panel addSubview:posterSlider]; self.posterSizeValueLabel = OpnLabel([NSString stringWithFormat:@"%.0f%%", posterSlider.doubleValue], - NSMakeRect(controlX + MIN(312.0, controlWidth - 60.0), 450.0, 60.0, 22.0), + NSMakeRect(controlX + MIN(312.0, controlWidth - 60.0), 756.0, 60.0, 22.0), 12.0, OpnColor(kTextSecondary), NSFontWeightSemibold, NSTextAlignmentRight); [panel addSubview:self.posterSizeValueLabel]; - [panel addSubview:[self rowLabel:@"Auto Full Screen" y:524.0]]; - NSButton *autoFullScreenToggle = [[NSButton alloc] initWithFrame:NSMakeRect(controlX, 516.0, controlWidth, 28.0)]; + [panel addSubview:[self rowLabel:@"Auto Full Screen" y:830.0]]; + NSButton *autoFullScreenToggle = [[NSButton alloc] initWithFrame:NSMakeRect(controlX, 822.0, controlWidth, 28.0)]; autoFullScreenToggle.buttonType = NSButtonTypeSwitch; autoFullScreenToggle.title = @"Enter full screen automatically when a stream starts"; autoFullScreenToggle.font = [NSFont systemFontOfSize:13.0 weight:NSFontWeightMedium]; @@ -1109,11 +1277,83 @@ - (void)backgroundAnimationToggleChanged:(NSButton *)sender { OpnSetBackgroundAnimationEnabled(sender.state == NSControlStateValueOn); } +- (void)derivedAccentToggleChanged:(NSButton *)sender { + OpnSetDerivedAccentColorsEnabled(sender.state == NSControlStateValueOn); +} + +- (void)backgroundTintSliderChanged:(NSSlider *)sender { + OpnSetBackgroundTintStrength((CGFloat)sender.doubleValue / 100.0); + self.backgroundTintValueLabel.stringValue = [NSString stringWithFormat:@"%.0f%%", sender.doubleValue]; +} + - (void)controllerModeToggleChanged:(NSButton *)sender { OpnSetControllerModeEnabled(sender.state == NSControlStateValueOn); [self rebuildContent]; } +- (void)controllerShortcutCaptureClicked:(NSButton *)sender { + (void)sender; + [self.controllerShortcutCaptureTimer invalidate]; + self.controllerShortcutPendingMask = 0; + self.controllerShortcutPendingSince = 0; + self.controllerShortcutCaptureDeadline = CACurrentMediaTime() + 6.0; + self.controllerShortcutCaptureButton.title = @"Listening..."; + self.controllerShortcutCaptureButton.layer.borderColor = OpnColor(kBrandGreen, 0.70).CGColor; + self.controllerShortcutStatusLabel.stringValue = @"Press and hold the controller button or combo you want to use for opening the stream library."; + self.controllerShortcutCaptureTimer = [NSTimer scheduledTimerWithTimeInterval:(1.0 / 60.0) + target:self + selector:@selector(controllerShortcutCaptureTimerFired:) + userInfo:nil + repeats:YES]; +} + +- (void)controllerShortcutResetClicked:(NSButton *)sender { + (void)sender; + [self.controllerShortcutCaptureTimer invalidate]; + self.controllerShortcutCaptureTimer = nil; + OpnSetControllerLibraryShortcutMask((uint16_t)(OPN::Input::GAMEPAD_BACK | OPN::Input::GAMEPAD_START)); + self.controllerShortcutCaptureButton.title = OPNControllerShortcutLabel(OpnControllerLibraryShortcutMask()); + self.controllerShortcutCaptureButton.layer.borderColor = OpnColor(kBrandGreen, 0.38).CGColor; + self.controllerShortcutStatusLabel.stringValue = @"Default restored. While streaming, press View + Menu to open the controller library."; +} + +- (void)controllerShortcutCaptureTimerFired:(NSTimer *)timer { + if (timer != self.controllerShortcutCaptureTimer) return; + + CFTimeInterval now = CACurrentMediaTime(); + if (now >= self.controllerShortcutCaptureDeadline) { + [self.controllerShortcutCaptureTimer invalidate]; + self.controllerShortcutCaptureTimer = nil; + self.controllerShortcutCaptureButton.title = OPNControllerShortcutLabel(OpnControllerLibraryShortcutMask()); + self.controllerShortcutCaptureButton.layer.borderColor = OpnColor(kBrandGreen, 0.38).CGColor; + self.controllerShortcutStatusLabel.stringValue = @"No controller input detected. Connect a controller and try again."; + return; + } + + uint16_t mask = OPNPressedControllerShortcutMask(); + if (mask == 0) { + self.controllerShortcutPendingMask = 0; + self.controllerShortcutPendingSince = 0; + return; + } + + if (mask != self.controllerShortcutPendingMask) { + self.controllerShortcutPendingMask = mask; + self.controllerShortcutPendingSince = now; + self.controllerShortcutCaptureButton.title = OPNControllerShortcutLabel(mask); + self.controllerShortcutStatusLabel.stringValue = @"Hold briefly to save this combo."; + return; + } + + if (now - self.controllerShortcutPendingSince < 0.24) return; + [self.controllerShortcutCaptureTimer invalidate]; + self.controllerShortcutCaptureTimer = nil; + OpnSetControllerLibraryShortcutMask(mask); + self.controllerShortcutCaptureButton.title = OPNControllerShortcutLabel(mask); + self.controllerShortcutCaptureButton.layer.borderColor = OpnColor(kBrandGreen, 0.38).CGColor; + self.controllerShortcutStatusLabel.stringValue = [NSString stringWithFormat:@"Saved. While streaming, press %@ to open the controller library.", OPNControllerShortcutLabel(mask)]; +} + - (void)microphoneModePopupChanged:(NSPopUpButton *)sender { std::vector modes = OPN::StreamMicrophoneModeOptions(); NSInteger index = MAX(0, MIN(sender.indexOfSelectedItem, (NSInteger)modes.size() - 1)); From ce6870d7356084a801793155a586418d6d9dc0f4 Mon Sep 17 00:00:00 2001 From: Jayian1890 <637445+Jayian1890@users.noreply.github.com> Date: Tue, 12 May 2026 10:36:21 -0500 Subject: [PATCH 18/18] Fix controller card transform animations --- src/common/OPNCoreAnimationCoordinator.mm | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/common/OPNCoreAnimationCoordinator.mm b/src/common/OPNCoreAnimationCoordinator.mm index 8fcaa3fcf..fa0a9c9a8 100644 --- a/src/common/OPNCoreAnimationCoordinator.mm +++ b/src/common/OPNCoreAnimationCoordinator.mm @@ -73,6 +73,7 @@ - (void)animateFocusForCardLayer:(CALayer *)cardLayer targetTransform = CATransform3DTranslate(targetTransform, 0.0, -10.0 * focusAmount, 42.0 * focusAmount); targetTransform = CATransform3DScale(targetTransform, scale, scale, 1.0); targetTransform = CATransform3DRotate(targetTransform, -0.030 * focusAmount, 1.0, 0.0, 0.0); + NSValue *currentTransform = OPNCurrentTransformValue(cardLayer); [CATransaction begin]; [CATransaction setDisableActions:YES]; @@ -85,7 +86,7 @@ - (void)animateFocusForCardLayer:(CALayer *)cardLayer cardLayer.shadowOffset = CGSizeMake(0.0, 12.0 + 16.0 * focusAmount); CASpringAnimation *transformSpring = OPNSpringAnimation(@"transform", - OPNCurrentTransformValue(cardLayer), + currentTransform, [NSValue valueWithCATransform3D:targetTransform], 0.78, 560.0, @@ -132,6 +133,7 @@ - (void)animateCardLayer:(CALayer *)cardLayer targetTransform.m34 = -1.0 / 900.0; targetTransform = CATransform3DTranslate(targetTransform, 0.0, expanded ? -18.0 : 0.0, expanded ? 80.0 : 0.0); targetTransform = CATransform3DScale(targetTransform, scale, scale, 1.0); + NSValue *currentTransform = OPNCurrentTransformValue(cardLayer); CIFilter *blurFilter = [CIFilter filterWithName:@"CIGaussianBlur"]; if (!blurFilter) return; @@ -159,7 +161,7 @@ - (void)animateCardLayer:(CALayer *)cardLayer [backgroundLayer addAnimation:blurAnimation forKey:@"opn.metadata.blur"]; CASpringAnimation *transformSpring = OPNSpringAnimation(@"transform", - OPNCurrentTransformValue(cardLayer), + currentTransform, [NSValue valueWithCATransform3D:targetTransform], 0.85, 360.0,