diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index a2489cd7..3b57a675 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -66,6 +66,18 @@ 11CFC6632E09918400748C80 /* MusicControllerSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CFC6622E09917B00748C80 /* MusicControllerSelectionView.swift */; }; 11CFC6652E09C7B300748C80 /* OnboardingFinishView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CFC6642E09C7B300748C80 /* OnboardingFinishView.swift */; }; 11D58EA22E760AE100FA8377 /* ImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D58EA12E760AE100FA8377 /* ImageService.swift */; }; + 11DB26662EDD0BE1001EA0CF /* LyricsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26652EDD0BE1001EA0CF /* LyricsService.swift */; }; + 11DB26732EDD0CDF001EA0CF /* ShortcutsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */; }; + 11DB26742EDD0CDF001EA0CF /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */; }; + 11DB26752EDD0CDF001EA0CF /* ShelfSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */; }; + 11DB26762EDD0CDF001EA0CF /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26692EDD0CDF001EA0CF /* AppearanceSettingsView.swift */; }; + 11DB26772EDD0CDF001EA0CF /* SettingsHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */; }; + 11DB26782EDD0CDF001EA0CF /* BatterySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266A2EDD0CDF001EA0CF /* BatterySettingsView.swift */; }; + 11DB26792EDD0CDF001EA0CF /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26682EDD0CDF001EA0CF /* AdvancedSettingsView.swift */; }; + 11DB267A2EDD0CDF001EA0CF /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26672EDD0CDF001EA0CF /* AboutView.swift */; }; + 11DB267B2EDD0CDF001EA0CF /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */; }; + 11DB267C2EDD0CDF001EA0CF /* HUDSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266D2EDD0CDF001EA0CF /* HUDSettingsView.swift */; }; + 11DB267D2EDD0CDF001EA0CF /* MediaSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */; }; 11EFCD702E8E92D600D0B974 /* ShelfItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11EFCD6F2E8E92D600D0B974 /* ShelfItemViewModel.swift */; }; 11F747CE2EC75CEA00F841DB /* DragPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F747CD2EC75CEA00F841DB /* DragPreviewView.swift */; }; 11F7485B2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 11F7484F2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -127,7 +139,6 @@ B186543C2C6F49AE000B926A /* ShortcutConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = B186543B2C6F49AE000B926A /* ShortcutConstants.swift */; }; B19016222CC15B3D00E3F12E /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = B19016212CC15B3D00E3F12E /* Defaults */; }; B19016242CC15B5000E3F12E /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = B19016232CC15B4D00E3F12E /* Constants.swift */; }; - B19424092CD0FF01003E5DC2 /* LottieAnimationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B19424082CD0FEFE003E5DC2 /* LottieAnimationView.swift */; }; B1A78C822C8BA08100BD51B0 /* FullscreenMediaDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A78C812C8BA08100BD51B0 /* FullscreenMediaDetection.swift */; }; B1B112912C6A572100093D8F /* EditPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B112902C6A572100093D8F /* EditPanelView.swift */; }; B1B112932C6A577E00093D8F /* MouseTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B112922C6A577E00093D8F /* MouseTracker.swift */; }; @@ -140,7 +151,6 @@ B1D365D02C6A9A6C0047BDBC /* SystemEventIndicatorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D365CF2C6A9A6C0047BDBC /* SystemEventIndicatorModifier.swift */; }; B1D6FD432C6603730015F173 /* SoftwareUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D6FD422C6603730015F173 /* SoftwareUpdater.swift */; }; B1F0A0022E60000100000001 /* BrightnessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0A0012E60000100000001 /* BrightnessManager.swift */; }; - B1F747F92EC7E94000F841DB /* LottieView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F747F82EC7E94000F841DB /* LottieView.swift */; }; B1FEB4992C7686630066EBBC /* PanGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FEB4982C7686630066EBBC /* PanGesture.swift */; }; F38DE6482D8243E7008B5C6D /* BatteryActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */; }; /* End PBXBuildFile section */ @@ -234,6 +244,18 @@ 11CFC6622E09917B00748C80 /* MusicControllerSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicControllerSelectionView.swift; sourceTree = ""; }; 11CFC6642E09C7B300748C80 /* OnboardingFinishView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFinishView.swift; sourceTree = ""; }; 11D58EA12E760AE100FA8377 /* ImageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageService.swift; sourceTree = ""; }; + 11DB26652EDD0BE1001EA0CF /* LyricsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsService.swift; sourceTree = ""; }; + 11DB26672EDD0CDF001EA0CF /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + 11DB26682EDD0CDF001EA0CF /* AdvancedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsView.swift; sourceTree = ""; }; + 11DB26692EDD0CDF001EA0CF /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = ""; }; + 11DB266A2EDD0CDF001EA0CF /* BatterySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatterySettingsView.swift; sourceTree = ""; }; + 11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSettingsView.swift; sourceTree = ""; }; + 11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; + 11DB266D2EDD0CDF001EA0CF /* HUDSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDSettingsView.swift; sourceTree = ""; }; + 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSettingsView.swift; sourceTree = ""; }; + 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHelpers.swift; sourceTree = ""; }; + 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfSettingsView.swift; sourceTree = ""; }; + 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsView.swift; sourceTree = ""; }; 11EFCD6F2E8E92D600D0B974 /* ShelfItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemViewModel.swift; sourceTree = ""; }; 11F747CD2EC75CEA00F841DB /* DragPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragPreviewView.swift; sourceTree = ""; }; 11F7484F2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = BoringNotchXPCHelper.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -293,7 +315,6 @@ B172AABF2C95DA0B001623F1 /* InlineHUD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineHUD.swift; sourceTree = ""; }; B186543B2C6F49AE000B926A /* ShortcutConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutConstants.swift; sourceTree = ""; }; B19016232CC15B4D00E3F12E /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; - B19424082CD0FEFE003E5DC2 /* LottieAnimationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieAnimationView.swift; sourceTree = ""; }; B1A78C812C8BA08100BD51B0 /* FullscreenMediaDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenMediaDetection.swift; sourceTree = ""; }; B1B112902C6A572100093D8F /* EditPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPanelView.swift; sourceTree = ""; }; B1B112922C6A577E00093D8F /* MouseTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MouseTracker.swift; sourceTree = ""; }; @@ -306,7 +327,6 @@ B1D365CF2C6A9A6C0047BDBC /* SystemEventIndicatorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemEventIndicatorModifier.swift; sourceTree = ""; }; B1D6FD422C6603730015F173 /* SoftwareUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftwareUpdater.swift; sourceTree = ""; }; B1F0A0012E60000100000001 /* BrightnessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessManager.swift; sourceTree = ""; }; - B1F747F82EC7E94000F841DB /* LottieView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieView.swift; sourceTree = ""; }; B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = ""; }; F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActivityManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -443,6 +463,24 @@ path = Providers; sourceTree = ""; }; + 11DB26722EDD0CDF001EA0CF /* Views */ = { + isa = PBXGroup; + children = ( + 11DB26672EDD0CDF001EA0CF /* AboutView.swift */, + 11DB26682EDD0CDF001EA0CF /* AdvancedSettingsView.swift */, + 11DB26692EDD0CDF001EA0CF /* AppearanceSettingsView.swift */, + 11DB266A2EDD0CDF001EA0CF /* BatterySettingsView.swift */, + 11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */, + 11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */, + 11DB266D2EDD0CDF001EA0CF /* HUDSettingsView.swift */, + 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */, + 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */, + 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */, + 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */, + ); + path = Views; + sourceTree = ""; + }; 11F748672EC9AC9600F841DB /* XPCHelperClient */ = { isa = PBXGroup; children = ( @@ -502,7 +540,6 @@ B17266E22C65F7FB0031BA0D /* WhatsNewView.swift */, B10F84A22C6C9596009F3026 /* TestView.swift */, 507266DA2C908E2E00A2D00D /* HoverButton.swift */, - B1F747F82EC7E94000F841DB /* LottieView.swift */, ); path = components; sourceTree = ""; @@ -510,6 +547,7 @@ 147163B52C5D804B0068B555 /* managers */ = { isa = PBXGroup; children = ( + 11DB26652EDD0BE1001EA0CF /* LyricsService.swift */, 11D58EA12E760AE100FA8377 /* ImageService.swift */, F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */, 112FB7342CCF16F70015238C /* NotchSpaceManager.swift */, @@ -695,7 +733,6 @@ B186542E2C6F453B000B926A /* Music */ = { isa = PBXGroup; children = ( - B19424082CD0FEFE003E5DC2 /* LottieAnimationView.swift */, 147163972C5D35B70068B555 /* MusicVisualizer.swift */, ); path = Music; @@ -717,6 +754,7 @@ B18654302C6F4590000B926A /* Settings */ = { isa = PBXGroup; children = ( + 11DB26722EDD0CDF001EA0CF /* Views */, 11F748832ECB27DC00F841DB /* MusicSlotConfigurationView.swift */, 11C5E3152DFE88510065821E /* SettingsView.swift */, 11C5E3112DFE85970065821E /* SettingsWindowController.swift */, @@ -834,7 +872,7 @@ }; 14CEF4112C5CAED300855D72 = { CreatedOnToolsVersion = 15.4; - LastSwiftMigration = 1540; + LastSwiftMigration = 1640; }; }; }; @@ -936,12 +974,11 @@ 1163988F2DF5CC870052E6AF /* CalendarModel.swift in Sources */, 1153BD9A2D98824300979FB0 /* SpotifyController.swift in Sources */, 118EBE292E946B3F00D54B5A /* ShareServiceFinder.swift in Sources */, - B19424092CD0FF01003E5DC2 /* LottieAnimationView.swift in Sources */, - B1F747F92EC7E94000F841DB /* LottieView.swift in Sources */, B1C974342C642B6D0000E707 /* MarqueeTextView.swift in Sources */, 14288DE82C6E01C800B9F80C /* ProgressIndicator.swift in Sources */, 1113ABC52E80E27000EC13B2 /* ShelfItemView.swift in Sources */, 1113ABC62E80E27000EC13B2 /* ShelfPersistenceService.swift in Sources */, + 11DB26662EDD0BE1001EA0CF /* LyricsService.swift in Sources */, 1113ABC82E80E27000EC13B2 /* ShelfItem.swift in Sources */, 1113ABCA2E80E27000EC13B2 /* ShelfSelectionModel.swift in Sources */, 1113ABCB2E80E27000EC13B2 /* QuickLookService.swift in Sources */, @@ -972,6 +1009,17 @@ 14D570BC2C5E98EB0011E668 /* generic.swift in Sources */, 118EBE272E92DE8400D54B5A /* NSMenu+AssociatedObject.swift in Sources */, B1CE8CFE2C6F659400DD9871 /* KeyboardShortcutsHelper.swift in Sources */, + 11DB26732EDD0CDF001EA0CF /* ShortcutsSettingsView.swift in Sources */, + 11DB26742EDD0CDF001EA0CF /* GeneralSettingsView.swift in Sources */, + 11DB26752EDD0CDF001EA0CF /* ShelfSettingsView.swift in Sources */, + 11DB26762EDD0CDF001EA0CF /* AppearanceSettingsView.swift in Sources */, + 11DB26772EDD0CDF001EA0CF /* SettingsHelpers.swift in Sources */, + 11DB26782EDD0CDF001EA0CF /* BatterySettingsView.swift in Sources */, + 11DB26792EDD0CDF001EA0CF /* AdvancedSettingsView.swift in Sources */, + 11DB267A2EDD0CDF001EA0CF /* AboutView.swift in Sources */, + 11DB267B2EDD0CDF001EA0CF /* CalendarSettingsView.swift in Sources */, + 11DB267C2EDD0CDF001EA0CF /* HUDSettingsView.swift in Sources */, + 11DB267D2EDD0CDF001EA0CF /* MediaSettingsView.swift in Sources */, 14D570C62C5F38210011E668 /* BoringHeader.swift in Sources */, B17266E32C65F7FB0031BA0D /* WhatsNewView.swift in Sources */, 14C08BB62C8DE42D000F8AA0 /* CalendarManager.swift in Sources */, diff --git a/boringNotch/Assets.xcassets/chrome.imageset/Contents.json b/boringNotch/Assets.xcassets/chrome.imageset/Contents.json deleted file mode 100644 index f0f188ff..00000000 --- a/boringNotch/Assets.xcassets/chrome.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "Google Chrome macOS BigSur Icon.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "Google Chrome macOS BigSur Icon 1.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "Google Chrome macOS BigSur Icon 2.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/boringNotch/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 1.png b/boringNotch/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 1.png deleted file mode 100644 index 1cce1cd5..00000000 Binary files a/boringNotch/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 1.png and /dev/null differ diff --git a/boringNotch/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 2.png b/boringNotch/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 2.png deleted file mode 100644 index 1cce1cd5..00000000 Binary files a/boringNotch/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 2.png and /dev/null differ diff --git a/boringNotch/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon.png b/boringNotch/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon.png deleted file mode 100644 index 1cce1cd5..00000000 Binary files a/boringNotch/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon.png and /dev/null differ diff --git a/boringNotch/Assets.xcassets/defaultmusic.imageset/Contents.json b/boringNotch/Assets.xcassets/defaultmusic.imageset/Contents.json deleted file mode 100644 index a19a5492..00000000 --- a/boringNotch/Assets.xcassets/defaultmusic.imageset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/boringNotch/Assets.xcassets/logo.imageset/256-mac 1.png b/boringNotch/Assets.xcassets/logo.imageset/256-mac 1.png deleted file mode 100644 index 3bc82b07..00000000 Binary files a/boringNotch/Assets.xcassets/logo.imageset/256-mac 1.png and /dev/null differ diff --git a/boringNotch/Assets.xcassets/logo.imageset/Contents.json b/boringNotch/Assets.xcassets/logo.imageset/Contents.json deleted file mode 100644 index e05b766d..00000000 --- a/boringNotch/Assets.xcassets/logo.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "256-mac 1.png", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index d95af616..b6fd1a54 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -33,28 +33,52 @@ struct ContentView: View { @Namespace var albumArtNamespace - @Default(.useMusicVisualizer) var useMusicVisualizer - @Default(.showNotHumanFace) var showNotHumanFace - // Shared interactive spring for movement/resizing to avoid conflicting animations - private let animationSpring = Animation.interactiveSpring(response: 0.38, dampingFraction: 0.8, blendDuration: 0) + // Use standardized animations from StandardAnimations enum + private let animationSpring = StandardAnimations.interactive private let extendedHoverPadding: CGFloat = 30 private let zeroHeightHoverPadding: CGFloat = 10 + // MARK: - Corner Radius Scaling + private var cornerRadiusScaleFactor: CGFloat? { + guard Defaults[.cornerRadiusScaling] else { return nil } + let effectiveHeight = displayClosedNotchHeight + guard effectiveHeight > 0 else { return nil } + return effectiveHeight / 38.0 + } + private var topCornerRadius: CGFloat { - ((vm.notchState == .open) && Defaults[.cornerRadiusScaling]) - ? cornerRadiusInsets.opened.top - : cornerRadiusInsets.closed.top + // If the notch is open, return the opened radius. + if vm.notchState == .open { + return cornerRadiusInsets.opened.top + } + + // For the closed notch, scale if enabled + let baseClosedTop = cornerRadiusInsets.closed.top + guard let scaleFactor = cornerRadiusScaleFactor else { + return displayClosedNotchHeight > 0 ? baseClosedTop : 0 + } + return max(0, baseClosedTop * scaleFactor) } private var currentNotchShape: NotchShape { - NotchShape( + // Scale bottom corner radius for closed notch shape when scaling is enabled. + let baseClosedBottom = cornerRadiusInsets.closed.bottom + let bottomCorner: CGFloat + + if vm.notchState == .open { + bottomCorner = cornerRadiusInsets.opened.bottom + } else if let scaleFactor = cornerRadiusScaleFactor { + bottomCorner = max(0, baseClosedBottom * scaleFactor) + } else { + bottomCorner = displayClosedNotchHeight > 0 ? baseClosedBottom : 0 + } + + return NotchShape( topCornerRadius: topCornerRadius, - bottomCornerRadius: ((vm.notchState == .open) && Defaults[.cornerRadiusScaling]) - ? cornerRadiusInsets.opened.bottom - : cornerRadiusInsets.closed.bottom + bottomCornerRadius: bottomCorner ) } @@ -69,17 +93,23 @@ struct ContentView: View { && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { - chinWidth += (2 * max(0, vm.effectiveClosedNotchHeight - 12) + 20) + chinWidth += (2 * max(0, displayClosedNotchHeight - 12) + 20) } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { - chinWidth += (2 * max(0, vm.effectiveClosedNotchHeight - 12) + 20) + chinWidth += (2 * max(0, displayClosedNotchHeight - 12) + 20) } return chinWidth } + // If the closed notch height is 0 (any display/setting), display a 10pt nearly-invisible notch + // instead of fully hiding it. This preserves layout while avoiding visual artifacts. + private var isNotchHeightZero: Bool { vm.effectiveClosedNotchHeight == 0 } + + private var displayClosedNotchHeight: CGFloat { isNotchHeightZero ? 10 : vm.effectiveClosedNotchHeight } + var body: some View { // Calculate scale based on gesture progress only let gestureScale: CGFloat = { @@ -94,37 +124,30 @@ struct ContentView: View { .frame(alignment: .top) .padding( .horizontal, - vm.notchState == .open - ? Defaults[.cornerRadiusScaling] - ? (cornerRadiusInsets.opened.top) : (cornerRadiusInsets.opened.bottom) - : cornerRadiusInsets.closed.bottom + vm.notchState == .open ? cornerRadiusInsets.opened.top : cornerRadiusInsets.closed.bottom ) .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) .background(.black) .clipShape(currentNotchShape) - .overlay(alignment: .top) { - Rectangle() + .overlay(alignment: .top) { + displayClosedNotchHeight.isZero && vm.notchState == .closed ? nil + : Rectangle() .fill(.black) .frame(height: 1) .padding(.horizontal, topCornerRadius) } .shadow( color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) - ? .black.opacity(0.7) : .clear, radius: Defaults[.cornerRadiusScaling] ? 6 : 4 - ) - .padding( - .bottom, - vm.effectiveClosedNotchHeight == 0 ? 10 : 0 + ? .black.opacity(0.7) : .clear, radius: 6 ) + // Removed conditional bottom padding when using custom 0 notch to keep layout stable + .opacity((isNotchHeightZero && vm.notchState == .closed) ? 0.01 : 1) mainLayout .frame(height: vm.notchState == .open ? vm.notchSize.height : nil) .conditionalModifier(true) { view in - let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0) - let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0) - return view - .animation(vm.notchState == .open ? openAnimation : closeAnimation, value: vm.notchState) + .animation(vm.notchState == .open ? StandardAnimations.open : StandardAnimations.close, value: vm.notchState) .animation(.smooth, value: gestureProgress) } .contentShape(Rectangle()) @@ -281,7 +304,7 @@ struct ContentView: View { } .frame(width: 76, alignment: .trailing) } - .frame(height: vm.effectiveClosedNotchHeight, alignment: .center) + .frame(height: displayClosedNotchHeight, alignment: .center) } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && vm.notchState == .closed { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition(.opacity) @@ -292,10 +315,10 @@ struct ContentView: View { BoringFaceAnimation() } else if vm.notchState == .open { BoringHeader() - .frame(height: max(24, vm.effectiveClosedNotchHeight)) + .frame(height: max(24, displayClosedNotchHeight)) .opacity(gestureProgress != 0 ? 1.0 - min(abs(gestureProgress) * 0.1, 0.3) : 1.0) } else { - Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: vm.effectiveClosedNotchHeight) + Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: displayClosedNotchHeight) } if coordinator.sneakPeek.show { @@ -325,7 +348,7 @@ struct ContentView: View { HStack(alignment: .center) { Image(systemName: "music.note") GeometryReader { geo in - MarqueeText(.constant(musicManager.songTitle + " - " + musicManager.artistName), textColor: Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray, minDuration: 1, frameWidth: geo.size.width) + MarqueeText(musicManager.songTitle + " - " + musicManager.artistName, color: Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray, delayDuration: 1.0, frameWidth: geo.size.width) } } .foregroundStyle(.gray) @@ -339,7 +362,7 @@ struct ContentView: View { view .fixedSize() } - .zIndex(2) + .zIndex(1) if vm.notchState == .open { VStack { switch coordinator.currentView { @@ -369,8 +392,8 @@ struct ContentView: View { Rectangle() .fill(.clear) .frame( - width: max(0, vm.effectiveClosedNotchHeight - 12), - height: max(0, vm.effectiveClosedNotchHeight - 12) + width: max(0, displayClosedNotchHeight - 12), + height: max(0, displayClosedNotchHeight - 12) ) Rectangle() .fill(.black) @@ -378,25 +401,42 @@ struct ContentView: View { MinimalFaceFeatures() } }.frame( - height: vm.effectiveClosedNotchHeight, + height: displayClosedNotchHeight, alignment: .center ) } @ViewBuilder func MusicLiveActivity() -> some View { - HStack { + HStack(spacing: 0) { + // Closed-mode album art: scale padding and corner radius according to cornerRadiusScaleFactor + let baseArtSize = displayClosedNotchHeight - 12 + let scaledArtSize: CGFloat = { + if let scale = cornerRadiusScaleFactor { + return displayClosedNotchHeight - 12 * scale + } + return baseArtSize + }() + + let closedCornerRadius: CGFloat = { + let base = MusicPlayerImageSizes.cornerRadiusInset.closed + if let scale = cornerRadiusScaleFactor { + return max(0, base * scale) + } + return base + }() + Image(nsImage: musicManager.albumArt) .resizable() - .clipped() + .aspectRatio(contentMode: .fit) .clipShape( RoundedRectangle( - cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed) + cornerRadius: closedCornerRadius) ) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) .frame( - width: max(0, vm.effectiveClosedNotchHeight - 12), - height: max(0, vm.effectiveClosedNotchHeight - 12) + width: scaledArtSize, + height: scaledArtSize ) Rectangle() @@ -407,10 +447,10 @@ struct ContentView: View { && coordinator.expandingView.type == .music { MarqueeText( - .constant(musicManager.songTitle), - textColor: Defaults[.coloredSpectrogram] + musicManager.songTitle, + color: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, - minDuration: 0.4, + delayDuration: 0.4, frameWidth: 100 ) .opacity( @@ -447,39 +487,29 @@ struct ContentView: View { ) HStack { - if useMusicVisualizer { - Rectangle() - .fill( - Defaults[.coloredSpectrogram] - ? Color(nsColor: musicManager.avgColor).gradient - : Color.gray.gradient - ) - .frame(width: 50, alignment: .center) - .matchedGeometryEffect(id: "spectrum", in: albumArtNamespace) - .mask { - AudioSpectrumView(isPlaying: $musicManager.isPlaying) - .frame(width: 16, height: 12) - } - } else { - LottieAnimationContainer() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + AudioSpectrumView( + isPlaying: musicManager.isPlaying, + tintColor: Defaults[.coloredSpectrogram] + ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.5) + : Color.gray + ) + .frame(width: 16, height: 12) } .frame( width: max( 0, - vm.effectiveClosedNotchHeight - 12 + displayClosedNotchHeight - 12 + gestureProgress / 2 ), height: max( 0, - vm.effectiveClosedNotchHeight - 12 + displayClosedNotchHeight - 12 ), alignment: .center ) } .frame( - height: vm.effectiveClosedNotchHeight, + height: displayClosedNotchHeight, alignment: .center ) } diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index fa343990..451fe2e5 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -102,6 +102,7 @@ } }, " – %lld" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1702,6 +1703,7 @@ } }, "Add" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1902,106 +1904,6 @@ } } }, - "Add new visualizer" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Add new visualizer" - } - }, - "cs" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Přidat nový vizualizér" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Neuen Visualisierer hinzufügen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add new visualizer" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add new visualiser" - } - }, - "es" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Añadir nuevo visualizador" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Ajouter un nouveau visualiseur" - } - }, - "hu" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Add new visualizer" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Aggiungi nuovo visualizzatore" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "새 시각화 도구 추가" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Dodaj nowy wizualizer" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Adicionar novo visualizador" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Добавить визуализатор" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Yeni görselleştirici ekle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Додати новий візуалізатор" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "添加新的可视化器" - } - } - } - }, "Additional features" : { "localizations" : { "ar" : { @@ -4203,6 +4105,7 @@ } }, "Cancel" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -6102,106 +6005,6 @@ } } }, - "Corner radius scaling" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Corner radius scaling" - } - }, - "cs" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Corner radius scaling" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Skalierung Eckenradius" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Corner radius scaling" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "Corner radius scaling" - } - }, - "es" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Escalado del radio de las esquinas" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Redimensionnement du rayon des angles" - } - }, - "hu" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Corner radius scaling" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Scala raggio angoli" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "모서리 반경 크기 조정" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Skalowanie zaokrąglenia narożników" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Corner radius scaling" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Corner radius scaling" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Köşe yarıçap ölçeği" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Масштабування радіусу кутів" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "圆角半径" - } - } - } - }, "Currently selected: %@" : { "localizations" : { "ar" : { @@ -6502,524 +6305,324 @@ } } }, - "Custom music live activity animation" : { + "Custom notch size - %.0f" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "needs_review", - "value" : "Custom music live activity animation" + "value" : "Custom notch size - %.0f" } }, "cs" : { "stringUnit" : { "state" : "needs_review", - "value" : "Vlastní animace živé aktivity hudby" + "value" : "Vlastní velikost výřezu - %.0f" } }, "de" : { "stringUnit" : { "state" : "needs_review", - "value" : "Benutzerdefinierte Animation für Musik-Live-Aktivitäten" + "value" : "Benutzerdefinierte Notchgröße - %.0f" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Custom music live activity animation" + "value" : "Custom notch size - %.0f" } }, "en-GB" : { "stringUnit" : { "state" : "translated", - "value" : "Custom music live activity animation" + "value" : "Custom notch size - %.0f" } }, "es" : { "stringUnit" : { "state" : "needs_review", - "value" : "Animación de actividad de música en vivo personalizada" + "value" : "Tamaño personalizado del notch - %.0f" } }, "fr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Personnaliser l'animation d'activité musicale" + "value" : "Personnaliser la taille de l'encoche - %.0f" } }, "hu" : { "stringUnit" : { "state" : "needs_review", - "value" : "Custom music live activity animation" + "value" : "Custom notch size - %.0f" } }, "it" : { "stringUnit" : { "state" : "needs_review", - "value" : "Animazione attività musicale personalizzata" + "value" : "Dimensione notch personalizzata - %.0f" } }, "ko" : { "stringUnit" : { "state" : "needs_review", - "value" : "사용자 정의 음악 재생 애니메이션" + "value" : "사용자 정의 노치 사이즈 - %.0f" } }, "pl" : { "stringUnit" : { "state" : "needs_review", - "value" : "Niestandardowa animacja aktywności muzyki" + "value" : "Niestandardowy rozmiar notcha - %.0f" } }, "pt-BR" : { "stringUnit" : { "state" : "needs_review", - "value" : "Customizar animação da música" + "value" : "Descrição" } }, "ru" : { "stringUnit" : { "state" : "needs_review", - "value" : "Custom music live activity animation" + "value" : "Custom notch size - %.0f" } }, "tr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Özel canlı müzik animasyonu" + "value" : "Özel çentik boyutu - %.0f" } }, "uk" : { "stringUnit" : { "state" : "needs_review", - "value" : "Власна анімація музики в реальному часі" + "value" : "Користувацький розмір брові - %.0f" } }, "zh-Hans" : { "stringUnit" : { "state" : "needs_review", - "value" : "自定义音乐实时动画" + "value" : "自定义Notch大小 - %.0f" } } } }, - "Custom notch size - %.0f" : { + "Customize in Settings" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "needs_review", - "value" : "Custom notch size - %.0f" + "value" : "Customize in Settings" } }, "cs" : { "stringUnit" : { "state" : "needs_review", - "value" : "Vlastní velikost výřezu - %.0f" + "value" : "Přizpůsobit v nastavení" } }, "de" : { "stringUnit" : { "state" : "needs_review", - "value" : "Benutzerdefinierte Notchgröße - %.0f" + "value" : "In den Einstellungen anpassen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Custom notch size - %.0f" + "value" : "Customize in Settings" } }, "en-GB" : { "stringUnit" : { "state" : "translated", - "value" : "Custom notch size - %.0f" + "value" : "Customise in Settings" } }, "es" : { "stringUnit" : { "state" : "needs_review", - "value" : "Tamaño personalizado del notch - %.0f" + "value" : "Personalizar en Ajustes" } }, "fr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Personnaliser la taille de l'encoche - %.0f" + "value" : "Personnaliser dans les paramètres" } }, "hu" : { "stringUnit" : { "state" : "needs_review", - "value" : "Custom notch size - %.0f" + "value" : "Customize in Settings" } }, "it" : { "stringUnit" : { "state" : "needs_review", - "value" : "Dimensione notch personalizzata - %.0f" + "value" : "Personalizza nelle impostazioni" } }, "ko" : { "stringUnit" : { "state" : "needs_review", - "value" : "사용자 정의 노치 사이즈 - %.0f" + "value" : "설정에서 커스텀하기" } }, "pl" : { "stringUnit" : { "state" : "needs_review", - "value" : "Niestandardowy rozmiar notcha - %.0f" + "value" : "Dostosuj w ustawieniach" } }, "pt-BR" : { "stringUnit" : { "state" : "needs_review", - "value" : "Descrição" + "value" : "Customizar em Configurações" } }, "ru" : { "stringUnit" : { "state" : "needs_review", - "value" : "Custom notch size - %.0f" + "value" : "Customize in Settings" } }, "tr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Özel çentik boyutu - %.0f" + "value" : "Ayarlarda özelleştir" } }, "uk" : { "stringUnit" : { "state" : "needs_review", - "value" : "Користувацький розмір брові - %.0f" + "value" : "Змінити в Налаштуваннях" } }, "zh-Hans" : { "stringUnit" : { "state" : "needs_review", - "value" : "自定义Notch大小 - %.0f" + "value" : "在设置中自定义" } } } }, - "Custom vizualizers (Lottie)" : { + "Customize which controls appear in the music player. Volume expands when active." : { "localizations" : { "ar" : { "stringUnit" : { "state" : "needs_review", - "value" : "Custom vizualizers (Lottie)" + "value" : "Customize which controls appear in the music player. Volume expands when active." } }, "cs" : { "stringUnit" : { "state" : "needs_review", - "value" : "Vlastní vizualizátory (Lottie)" + "value" : "Customize which controls appear in the music player. Volume expands when active." } }, "de" : { "stringUnit" : { "state" : "needs_review", - "value" : "Eigene Audiovisualisierung (via Lottie)" + "value" : "Passen Sie an, welche Steuerelemente im Musik-Player angezeigt werden. Die Lautstärke wird erweitert, wenn aktiviert." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Custom vizualizers (Lottie)" + "value" : "Customize which controls appear in the music player. Volume expands when active." } }, "en-GB" : { "stringUnit" : { - "state" : "translated", - "value" : "Custom visualisers (Lottie)" + "state" : "needs_review", + "value" : "Customize which controls appear in the music player. Volume expands when active." } }, "es" : { "stringUnit" : { "state" : "needs_review", - "value" : "Visualizadores personalizados (Lottie)" + "value" : "Customize which controls appear in the music player. Volume expands when active." } }, "fr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Visualiseurs personnalisés (Lottie)" + "value" : "Customize which controls appear in the music player. Volume expands when active." } }, "hu" : { "stringUnit" : { "state" : "needs_review", - "value" : "Custom vizualizers (Lottie)" + "value" : "Customize which controls appear in the music player. Volume expands when active." } }, "it" : { "stringUnit" : { "state" : "needs_review", - "value" : "Visualizzatori personalizzati (Lottie)" + "value" : "Customize which controls appear in the music player. Volume expands when active." } }, "ko" : { "stringUnit" : { "state" : "needs_review", - "value" : "사용자 지정 시각화 도구(Lottie)" + "value" : "음악 플레이어에 표시할 컨트롤을 사용자 지정하세요. 볼륨은 활성화되면 확장됩니다." } }, "pl" : { "stringUnit" : { "state" : "needs_review", - "value" : "Niestandardowe wizualizatory (Lottie)" + "value" : "Dostosuj które ustawienia wyświetlane w odtwarzaczu muzyki. Głośność rozwija się, gdy jest aktywna." } }, "pt-BR" : { "stringUnit" : { "state" : "needs_review", - "value" : "Customizar visualizadores (Lottie)" + "value" : "Customize which controls appear in the music player. Volume expands when active." } }, "ru" : { "stringUnit" : { "state" : "needs_review", - "value" : "Custom vizualizers (Lottie)" + "value" : "Customize which controls appear in the music player. Volume expands when active." } }, "tr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Özel görselleştirmeler (Lottie)" + "value" : "Customize which controls appear in the music player. Volume expands when active." } }, "uk" : { "stringUnit" : { "state" : "needs_review", - "value" : "Користувацькі візуалізатори (Лотті)" + "value" : "Customize which controls appear in the music player. Volume expands when active." } }, "zh-Hans" : { "stringUnit" : { "state" : "needs_review", - "value" : "自定义可视化器(Lottie)" + "value" : "Customize which controls appear in the music player. Volume expands when active." } } } }, - "Customize in Settings" : { + "Default" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "needs_review", - "value" : "Customize in Settings" + "value" : "Default" } }, "cs" : { "stringUnit" : { "state" : "needs_review", - "value" : "Přizpůsobit v nastavení" + "value" : "Výchozí" } }, "de" : { "stringUnit" : { "state" : "needs_review", - "value" : "In den Einstellungen anpassen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Customize in Settings" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "Customise in Settings" - } - }, - "es" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Personalizar en Ajustes" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Personnaliser dans les paramètres" - } - }, - "hu" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize in Settings" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Personalizza nelle impostazioni" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "설정에서 커스텀하기" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Dostosuj w ustawieniach" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customizar em Configurações" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize in Settings" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Ayarlarda özelleştir" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Змінити в Налаштуваннях" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "在设置中自定义" - } - } - } - }, - "Customize which controls appear in the music player. Volume expands when active." : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize which controls appear in the music player. Volume expands when active." - } - }, - "cs" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize which controls appear in the music player. Volume expands when active." - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Passen Sie an, welche Steuerelemente im Musik-Player angezeigt werden. Die Lautstärke wird erweitert, wenn aktiviert." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Customize which controls appear in the music player. Volume expands when active." - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize which controls appear in the music player. Volume expands when active." - } - }, - "es" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize which controls appear in the music player. Volume expands when active." - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize which controls appear in the music player. Volume expands when active." - } - }, - "hu" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize which controls appear in the music player. Volume expands when active." - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize which controls appear in the music player. Volume expands when active." - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "음악 플레이어에 표시할 컨트롤을 사용자 지정하세요. 볼륨은 활성화되면 확장됩니다." - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Dostosuj które ustawienia wyświetlane w odtwarzaczu muzyki. Głośność rozwija się, gdy jest aktywna." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize which controls appear in the music player. Volume expands when active." - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize which controls appear in the music player. Volume expands when active." - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize which controls appear in the music player. Volume expands when active." - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize which controls appear in the music player. Volume expands when active." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Customize which controls appear in the music player. Volume expands when active." - } - } - } - }, - "Default" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Default" - } - }, - "cs" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Výchozí" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Standard" + "value" : "Standard" } }, "en" : { @@ -8306,107 +7909,6 @@ } } }, - "Enable colored spectrograms" : { - "extractionState" : "stale", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Enable colored spectrograms" - } - }, - "cs" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Povolit barevné spektrogramy" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Farbe für Audiovisualisierung aktivieren" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enable colored spectrograms" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enable coloured spectrograms" - } - }, - "es" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Habilitar espectrogramas de colores" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Activer les spectrogrammes de couleurs" - } - }, - "hu" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Enable colored spectrograms" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Abilita spettrogramma colorato" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "컬러 스펙트로그램을 활성화하세요" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Włącz kolorowe spektrogramy" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Enable colored spectrograms" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Enable colored spectrograms" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Renkli spektrogramları aktifleştir" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Enable colored spectrograms" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "启用彩色谱图" - } - } - } - }, "Enable gestures" : { "localizations" : { "ar" : { @@ -11007,6 +10509,9 @@ } } } + }, + "Hide windows on non-notch displays from Mission Control" : { + }, "Hierarchical" : { "localizations" : { @@ -11767,344 +11272,244 @@ "ko" : { "stringUnit" : { "state" : "needs_review", - "value" : "인라인" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Wbudowany" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Em linha" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Inline" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Hiza" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Inline" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "内嵌" - } - } - } - }, - "Installed extensions" : { - "extractionState" : "stale", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Installed extensions" - } - }, - "cs" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Nainstalovaná rozšíření" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Installierte Erweiterungen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Installed extensions" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "Installed extensions" - } - }, - "es" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Extensiones instaladas" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Extensions installées" - } - }, - "hu" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Installed extensions" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Estensioni installate" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "설치된 확장기능들" + "value" : "인라인" } }, "pl" : { "stringUnit" : { "state" : "needs_review", - "value" : "Zainstalowane rozszerzenia" + "value" : "Wbudowany" } }, "pt-BR" : { "stringUnit" : { "state" : "needs_review", - "value" : "Installed extensions" + "value" : "Em linha" } }, "ru" : { "stringUnit" : { "state" : "needs_review", - "value" : "Installed extensions" + "value" : "Inline" } }, "tr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Yüklenmiş eklentiler" + "value" : "Hiza" } }, "uk" : { "stringUnit" : { "state" : "needs_review", - "value" : "Встановлені розширення" + "value" : "Inline" } }, "zh-Hans" : { "stringUnit" : { "state" : "needs_review", - "value" : "已安装扩展" + "value" : "内嵌" } } } }, - "Layout Preview" : { + "Installed extensions" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { "state" : "needs_review", - "value" : "Layout Preview" + "value" : "Installed extensions" } }, "cs" : { "stringUnit" : { "state" : "needs_review", - "value" : "Layout Preview" + "value" : "Nainstalovaná rozšíření" } }, "de" : { "stringUnit" : { "state" : "needs_review", - "value" : "Layoutvorschau" + "value" : "Installierte Erweiterungen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Layout Preview" + "value" : "Installed extensions" } }, "en-GB" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Layout Preview" + "state" : "translated", + "value" : "Installed extensions" } }, "es" : { "stringUnit" : { "state" : "needs_review", - "value" : "Layout Preview" + "value" : "Extensiones instaladas" } }, "fr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Layout Preview" + "value" : "Extensions installées" } }, "hu" : { "stringUnit" : { "state" : "needs_review", - "value" : "Layout Preview" + "value" : "Installed extensions" } }, "it" : { "stringUnit" : { "state" : "needs_review", - "value" : "Layout Preview" + "value" : "Estensioni installate" } }, "ko" : { "stringUnit" : { "state" : "needs_review", - "value" : "레이아웃 미리보기" + "value" : "설치된 확장기능들" } }, "pl" : { "stringUnit" : { "state" : "needs_review", - "value" : "Podgląd układu" + "value" : "Zainstalowane rozszerzenia" } }, "pt-BR" : { "stringUnit" : { "state" : "needs_review", - "value" : "Layout Preview" + "value" : "Installed extensions" } }, "ru" : { "stringUnit" : { "state" : "needs_review", - "value" : "Layout Preview" + "value" : "Installed extensions" } }, "tr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Layout Preview" + "value" : "Yüklenmiş eklentiler" } }, "uk" : { "stringUnit" : { "state" : "needs_review", - "value" : "Layout Preview" + "value" : "Встановлені розширення" } }, "zh-Hans" : { "stringUnit" : { "state" : "needs_review", - "value" : "Layout Preview" + "value" : "已安装扩展" } } } }, - "Lottie JSON URL" : { + "Layout Preview" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "needs_review", - "value" : "Lottie JSON URL" + "value" : "Layout Preview" } }, "cs" : { "stringUnit" : { "state" : "needs_review", - "value" : "Lottie JSON URL" + "value" : "Layout Preview" } }, "de" : { "stringUnit" : { "state" : "needs_review", - "value" : "Lottie JSON URL" + "value" : "Layoutvorschau" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Lottie JSON URL" + "value" : "Layout Preview" } }, "en-GB" : { "stringUnit" : { - "state" : "translated", - "value" : "Lottie JSON URL" + "state" : "needs_review", + "value" : "Layout Preview" } }, "es" : { "stringUnit" : { "state" : "needs_review", - "value" : "Lottie JSON URL" + "value" : "Layout Preview" } }, "fr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Lien URL Lottie JSON" + "value" : "Layout Preview" } }, "hu" : { "stringUnit" : { "state" : "needs_review", - "value" : "Lottie JSON URL" + "value" : "Layout Preview" } }, "it" : { "stringUnit" : { "state" : "needs_review", - "value" : "URL Lottie JSON" + "value" : "Layout Preview" } }, "ko" : { "stringUnit" : { "state" : "needs_review", - "value" : "Lottie JSON URL" + "value" : "레이아웃 미리보기" } }, "pl" : { "stringUnit" : { "state" : "needs_review", - "value" : "Lottie JSON URL" + "value" : "Podgląd układu" } }, "pt-BR" : { "stringUnit" : { "state" : "needs_review", - "value" : "Lottie JSON URL" + "value" : "Layout Preview" } }, "ru" : { "stringUnit" : { "state" : "needs_review", - "value" : "Lottie JSON URL" + "value" : "Layout Preview" } }, "tr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Lottie JSON URL" + "value" : "Layout Preview" } }, "uk" : { "stringUnit" : { "state" : "needs_review", - "value" : "Lottie JSON URL" + "value" : "Layout Preview" } }, "zh-Hans" : { "stringUnit" : { "state" : "needs_review", - "value" : "Lottie JSON 链接" + "value" : "Layout Preview" } } } @@ -14310,6 +13715,7 @@ } }, "Name" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -14398,313 +13804,113 @@ "uk" : { "stringUnit" : { "state" : "needs_review", - "value" : "Name" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "名称" - } - } - } - }, - "Never hide" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Never hide" - } - }, - "cs" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Nikdy neskrývat" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Niemals ausblenden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Never hide" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "Never hide" - } - }, - "es" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Nunca ocultar" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Ne jamais masquer" - } - }, - "hu" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Never hide" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Non nascondere mai" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "숨기지 않기" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Nigdy nie ukrywaj" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Never hide" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Never hide" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Asla gizleme" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Never hide" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "永不隐藏" - } - } - } - }, - "No custom animation available" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "No custom animation available" - } - }, - "cs" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Žádná vlastní animace není k dispozici" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Keine benutzerdefinierte Animation verfügbar" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No custom animation available" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "No custom animation available" - } - }, - "es" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Sin animación personalizada" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Aucune animation personnalisée disponible" - } - }, - "hu" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "No custom animation available" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Nessuna animazione personalizzata disponibile" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "사용자 정의 애니메이션 없음" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Brak dostępnych niestandardowych animacji" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "No custom animation available" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "No custom animation available" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Özel animasyon mevcut değil" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "No custom animation available" + "value" : "Name" } }, "zh-Hans" : { "stringUnit" : { "state" : "needs_review", - "value" : "没有可用的自定义动画" + "value" : "名称" } } } }, - "No custom visualizer" : { + "Never hide" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "needs_review", - "value" : "No custom visualizer" + "value" : "Never hide" } }, "cs" : { "stringUnit" : { "state" : "needs_review", - "value" : "Žádný vlastní vizualizér není k dispozici" + "value" : "Nikdy neskrývat" } }, "de" : { "stringUnit" : { "state" : "needs_review", - "value" : "Keine benutzerdefinierte Audiovisualisierung" + "value" : "Niemals ausblenden" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "No custom visualizer" + "value" : "Never hide" } }, "en-GB" : { "stringUnit" : { "state" : "translated", - "value" : "No custom visualiser" + "value" : "Never hide" } }, "es" : { "stringUnit" : { "state" : "needs_review", - "value" : "Sin visualizador personalizado" + "value" : "Nunca ocultar" } }, "fr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Aucun visualiseur personnalisé" + "value" : "Ne jamais masquer" } }, "hu" : { "stringUnit" : { "state" : "needs_review", - "value" : "No custom visualizer" + "value" : "Never hide" } }, "it" : { "stringUnit" : { "state" : "needs_review", - "value" : "Nessun visualizzatore personalizzato" + "value" : "Non nascondere mai" } }, "ko" : { "stringUnit" : { "state" : "needs_review", - "value" : "사용자 지정 시각화 도구 없음" + "value" : "숨기지 않기" } }, "pl" : { "stringUnit" : { "state" : "needs_review", - "value" : "Brak niestandardowych wizualizerów" + "value" : "Nigdy nie ukrywaj" } }, "pt-BR" : { "stringUnit" : { "state" : "needs_review", - "value" : "No custom visualizer" + "value" : "Never hide" } }, "ru" : { "stringUnit" : { "state" : "needs_review", - "value" : "No custom visualizer" + "value" : "Never hide" } }, "tr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Özel görselleştirici bulunmuyor" + "value" : "Asla gizleme" } }, "uk" : { "stringUnit" : { "state" : "needs_review", - "value" : "No custom visualizer" + "value" : "Never hide" } }, "zh-Hans" : { "stringUnit" : { "state" : "needs_review", - "value" : "没有自定义可视化器" + "value" : "永不隐藏" } } } @@ -18112,6 +17318,76 @@ } } }, + "Scale corner radius for closed notch" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Skalierung Eckenradius" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scale corner radius for closed notch" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scale corner radius for closed notch" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Escalado del radio de las esquinas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Redimensionnement du rayon des angles" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Scala raggio angoli" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "모서리 반경 크기 조정" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Skalowanie zaokrąglenia narożników" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Köşe yarıçap ölçeği" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Масштабування радіусу кутів" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "圆角半径" + } + } + } + }, "Select the music source you want to use. You can change this later in the app settings." : { "localizations" : { "ar" : { @@ -18213,6 +17489,7 @@ } }, "selected" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -18313,6 +17590,7 @@ } }, "Selected animation" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -20613,6 +19891,7 @@ } }, "Speed" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -20913,107 +20192,6 @@ } } }, - "Support Us" : { - "extractionState" : "stale", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Support Us" - } - }, - "cs" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Podpořte nás" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Unterstütze uns" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Support Us" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "Support Us" - } - }, - "es" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Apóyenos" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Soutenez-nous" - } - }, - "hu" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Support Us" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Supportaci" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "후원하기" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Wesprzyj nas" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Support Us" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Support Us" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Bizi Destekle" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Support Us" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "支持我们" - } - } - } - }, "System" : { "localizations" : { "ar" : { @@ -22215,106 +21393,6 @@ } } }, - "Use music visualizer spectrogram" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Use music visualizer spectrogram" - } - }, - "cs" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Použít hudební vizualizér" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Musik-Visualizer Spektrogramm verwenden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Use music visualizer spectrogram" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "Use music visualiser spectrogram" - } - }, - "es" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Usar espectrograma del visualizador musical" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Utiliser le spectrogramme du visualiseur de musique" - } - }, - "hu" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Use music visualizer spectrogram" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Usa spettrogramma per la visualizzatore musicale" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "음악 시각화 스펙트로그램 사용" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Użyj spektrogramu wizualizera muzyki" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Use music visualizer spectrogram" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Use music visualizer spectrogram" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Müzik görselleştirici spektrogram kullanın" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "Use music visualizer spectrogram" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "使用音乐可视化器谱图" - } - } - } - }, "Use your macOS system accent color" : { "localizations" : { "ar" : { diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index f8c6e232..629a2065 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -243,8 +243,3 @@ final class XPCHelperClient: NSObject { } } -extension Notification.Name { - static let accessibilityAuthorizationChanged = Notification.Name("accessibilityAuthorizationChanged") -} - - diff --git a/boringNotch/animations/drop.swift b/boringNotch/animations/drop.swift index ee8eb5ae..95b86955 100644 --- a/boringNotch/animations/drop.swift +++ b/boringNotch/animations/drop.swift @@ -8,6 +8,30 @@ import Foundation import SwiftUI +// MARK: - Standardized Animations +/// Centralized animation definitions for consistent UI behavior across the app. +enum StandardAnimations { + /// Interactive spring for responsive UI (used for notch interactions) + static let interactive = Animation.interactiveSpring(response: 0.38, dampingFraction: 0.8, blendDuration: 0) + + /// Spring animation for opening the notch + static let open = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0) + + /// Spring animation for closing the notch + static let close = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0) + + /// Bouncy spring for playful animations + @available(macOS 14.0, *) + static var bouncy: Animation { + Animation.spring(.bouncy(duration: 0.4)) + } + + /// Smooth animation for general transitions + static let smooth = Animation.smooth + + /// Timing curve fallback for older macOS versions + static let timingCurve = Animation.timingCurve(0.16, 1, 0.3, 1, duration: 0.7) +} public class BoringAnimations { @Published var notchStyle: Style = .notch @@ -18,12 +42,9 @@ public class BoringAnimations { var animation: Animation { if #available(macOS 14.0, *), notchStyle == .notch { - Animation.spring(.bouncy(duration: 0.4)) + StandardAnimations.bouncy } else { - Animation.timingCurve(0.16, 1, 0.3, 1, duration: 0.7) + StandardAnimations.timingCurve } } - - // TODO: Move all animations to this file - } diff --git a/boringNotch/boringNotchApp.swift b/boringNotch/boringNotchApp.swift index 94b4ac45..114e347b 100644 --- a/boringNotch/boringNotchApp.swift +++ b/boringNotch/boringNotchApp.swift @@ -65,12 +65,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var isScreenLocked: Bool = false private var windowScreenDidChangeObserver: Any? private var dragDetectors: [String: DragDetector] = [:] // UUID -> DragDetector + private var observers: [Any] = [] func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false } func applicationWillTerminate(_ notification: Notification) { + // Flush debounced shelf persistence to avoid losing recent changes + ShelfStateViewModel.shared.flushSync() + NotificationCenter.default.removeObserver(self) if let observer = screenLockedObserver { DistributedNotificationCenter.default().removeObserver(observer) @@ -84,6 +88,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { cleanupDragDetectors() cleanupWindows() XPCHelperClient.shared.stopMonitoringAccessibilityAuthorization() + + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() } @MainActor @@ -286,34 +293,34 @@ class AppDelegate: NSObject, NSApplicationDelegate { object: nil ) - NotificationCenter.default.addObserver( + observers.append(NotificationCenter.default.addObserver( forName: Notification.Name.selectedScreenChanged, object: nil, queue: nil ) { [weak self] _ in Task { @MainActor in self?.adjustWindowPosition(changeAlpha: true) self?.setupDragDetectors() } - } + }) - NotificationCenter.default.addObserver( + observers.append(NotificationCenter.default.addObserver( forName: Notification.Name.notchHeightChanged, object: nil, queue: nil ) { [weak self] _ in Task { @MainActor in self?.adjustWindowPosition() self?.setupDragDetectors() } - } + }) - NotificationCenter.default.addObserver( + observers.append(NotificationCenter.default.addObserver( forName: Notification.Name.automaticallySwitchDisplayChanged, object: nil, queue: nil ) { [weak self] _ in guard let self = self, let window = self.window else { return } Task { @MainActor in window.alphaValue = self.coordinator.selectedScreenUUID == self.coordinator.preferredScreenUUID ? 1 : 0 } - } + }) - NotificationCenter.default.addObserver( + observers.append(NotificationCenter.default.addObserver( forName: Notification.Name.showOnAllDisplaysChanged, object: nil, queue: nil ) { [weak self] _ in Task { @MainActor in @@ -322,15 +329,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { self.adjustWindowPosition(changeAlpha: true) self.setupDragDetectors() } - } + }) - NotificationCenter.default.addObserver( + observers.append(NotificationCenter.default.addObserver( forName: Notification.Name.expandedDragDetectionChanged, object: nil, queue: nil ) { [weak self] _ in Task { @MainActor in self?.setupDragDetectors() } - } + }) // Use closure-based observers for DistributedNotificationCenter and keep tokens for removal screenLockedObserver = DistributedNotificationCenter.default().addObserver( @@ -408,6 +415,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + // Sync notch height with real value on app launch if mode is matchRealNotchSize + syncNotchHeightIfNeeded() + if !Defaults[.showOnAllDisplays] { let viewModel = self.vm let window = createBoringNotchWindow( @@ -465,6 +475,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { if screensChanged { DispatchQueue.main.async { [weak self] in + // Sync notch height with real value if mode is matchRealNotchSize + syncNotchHeightIfNeeded() + self?.cleanupWindows() self?.adjustWindowPosition() self?.setupDragDetectors() @@ -594,24 +607,3 @@ class AppDelegate: NSObject, NSApplicationDelegate { onboardingWindowController?.window?.orderFrontRegardless() } } - -extension Notification.Name { - static let selectedScreenChanged = Notification.Name("SelectedScreenChanged") - static let notchHeightChanged = Notification.Name("NotchHeightChanged") - static let showOnAllDisplaysChanged = Notification.Name("showOnAllDisplaysChanged") - static let automaticallySwitchDisplayChanged = Notification.Name("automaticallySwitchDisplayChanged") - static let expandedDragDetectionChanged = Notification.Name("expandedDragDetectionChanged") -} - -extension CGRect: @retroactive Hashable { - public func hash(into hasher: inout Hasher) { - hasher.combine(origin.x) - hasher.combine(origin.y) - hasher.combine(size.width) - hasher.combine(size.height) - } - - public static func == (lhs: CGRect, rhs: CGRect) -> Bool { - return lhs.origin == rhs.origin && lhs.size == rhs.size - } -} diff --git a/boringNotch/components/AnimatedFace.swift b/boringNotch/components/AnimatedFace.swift index 51913b79..a89c10f9 100644 --- a/boringNotch/components/AnimatedFace.swift +++ b/boringNotch/components/AnimatedFace.swift @@ -46,11 +46,17 @@ struct MinimalFaceFeatures: View { } func startBlinking() { - Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in - withAnimation(.spring(duration: 0.2)) { - isBlinking = true - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(3)) + if Task.isCancelled { break } + + withAnimation(.spring(duration: 0.2)) { + isBlinking = true + } + + try? await Task.sleep(for: .milliseconds(100)) + withAnimation(.spring(duration: 0.2)) { isBlinking = false } diff --git a/boringNotch/components/Live activities/DownloadView.swift b/boringNotch/components/Live activities/DownloadView.swift index 2e6104b5..bc2ecbc6 100644 --- a/boringNotch/components/Live activities/DownloadView.swift +++ b/boringNotch/components/Live activities/DownloadView.swift @@ -33,7 +33,7 @@ struct DownloadArea: View { if watcher.downloadFiles.first!.browser == .safari { AppIcon(for: "com.apple.safari") } else { - Image(.chrome).resizable().scaledToFit().frame(width: 30, height: 30) + AppIcon(for: "com.google.Chrome") } VStack(alignment: .leading) { Text("Download") diff --git a/boringNotch/components/Live activities/MarqueeTextView.swift b/boringNotch/components/Live activities/MarqueeTextView.swift index 4bb7bf32..4d4fa71a 100644 --- a/boringNotch/components/Live activities/MarqueeTextView.swift +++ b/boringNotch/components/Live activities/MarqueeTextView.swift @@ -23,25 +23,23 @@ struct MeasureSizeModifier: ViewModifier { } struct MarqueeText: View { - @Binding var text: String + let text: String let font: Font let nsFont: NSFont.TextStyle - let textColor: Color - let backgroundColor: Color - let minDuration: Double + let color: Color + let delayDuration: Double let frameWidth: CGFloat @State private var animate = false @State private var textSize: CGSize = .zero @State private var offset: CGFloat = 0 - init(_ text: Binding, font: Font = .body, nsFont: NSFont.TextStyle = .body, textColor: Color = .primary, backgroundColor: Color = .clear, minDuration: Double = 3.0, frameWidth: CGFloat = 200) { - _text = text + init(_ text: String, font: Font = .body, nsFont: NSFont.TextStyle = .body, color: Color = .primary, delayDuration: Double = 3.0, frameWidth: CGFloat) { + self.text = text self.font = font self.nsFont = nsFont - self.textColor = textColor - self.backgroundColor = backgroundColor - self.minDuration = minDuration + self.color = color + self.delayDuration = delayDuration self.frameWidth = frameWidth } @@ -59,17 +57,16 @@ struct MarqueeText: View { } .id(text) .font(font) - .foregroundColor(textColor) + .foregroundColor(color) .fixedSize(horizontal: true, vertical: false) .offset(x: self.animate ? offset : 0) .animation( self.animate ? .linear(duration: Double(textSize.width / 30)) - .delay(minDuration) + .delay(delayDuration) .repeatForever(autoreverses: false) : .none, value: self.animate ) - .background(backgroundColor) .modifier(MeasureSizeModifier()) .onPreferenceChange(SizePreferenceKey.self) { size in self.textSize = CGSize(width: size.width / 2, height: NSFont.preferredFont(forTextStyle: nsFont).pointSize) diff --git a/boringNotch/components/Music/MusicVisualizer.swift b/boringNotch/components/Music/MusicVisualizer.swift index f1913c35..fc12cbc8 100644 --- a/boringNotch/components/Music/MusicVisualizer.swift +++ b/boringNotch/components/Music/MusicVisualizer.swift @@ -9,10 +9,14 @@ import Cocoa import SwiftUI class AudioSpectrum: NSView { - private var barLayers: [CAShapeLayer] = [] - private var barScales: [CGFloat] = [] - private var isPlaying: Bool = true - private var animationTimer: Timer? + private var barLayers: [CAGradientLayer] = [] + private var isPlaying = false + private var tintColor: NSColor = .systemBlue + + private let barWidth: CGFloat = 2 + private let barCount = 4 + private let spacing: CGFloat = 2 + private let totalHeight: CGFloat = 14 override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -27,99 +31,115 @@ class AudioSpectrum: NSView { } private func setupBars() { - let barWidth: CGFloat = 2 - let barCount = 4 - let spacing: CGFloat = barWidth let totalWidth = CGFloat(barCount) * (barWidth + spacing) - let totalHeight: CGFloat = 14 - frame.size = CGSize(width: totalWidth, height: totalHeight) - - for i in 0 ..< barCount { + if frame.width < totalWidth { + frame.size = CGSize(width: totalWidth, height: totalHeight) + } + let scale = NSScreen.main?.backingScaleFactor ?? 2.0 + for i in 0.. AudioSpectrum { let spectrum = AudioSpectrum() + spectrum.setTintColor(NSColor(tintColor)) spectrum.setPlaying(isPlaying) return spectrum } func updateNSView(_ nsView: AudioSpectrum, context: Context) { + nsView.setTintColor(NSColor(tintColor)) nsView.setPlaying(isPlaying) } } #Preview { - AudioSpectrumView(isPlaying: .constant(true)) - .frame(width: 16, height: 20) - .padding() + ZStack { + Color.black + AudioSpectrumView(isPlaying: true, tintColor: .green) + .frame(width: 20, height: 14) + } + .padding() } diff --git a/boringNotch/components/Notch/BoringNotchSkyLightWindow.swift b/boringNotch/components/Notch/BoringNotchSkyLightWindow.swift index 71227072..ec928717 100644 --- a/boringNotch/components/Notch/BoringNotchSkyLightWindow.swift +++ b/boringNotch/components/Notch/BoringNotchSkyLightWindow.swift @@ -65,12 +65,7 @@ class BoringNotchSkyLightWindow: NSPanel { // Force dark appearance regardless of system setting appearance = NSAppearance(named: .darkAqua) - collectionBehavior = [ - .fullScreenAuxiliary, - .stationary, - .canJoinAllSpaces, - .ignoresCycle, - ] + updateCollectionBehavior() // Apply initial sharing type setting updateSharingType() @@ -83,6 +78,35 @@ class BoringNotchSkyLightWindow: NSPanel { self?.updateSharingType() } .store(in: &observers) + + Defaults.publisher(.hideNonNotchedFromMissionControl) + .sink { [weak self] _ in + self?.updateCollectionBehavior() + } + .store(in: &observers) + + NotificationCenter.default.publisher(for: NSWindow.didChangeScreenNotification, object: self) + .sink { [weak self] _ in + self?.updateCollectionBehavior() + } + .store(in: &observers) + } + + private func updateCollectionBehavior() { + var newBehavior: NSWindow.CollectionBehavior = [ + .fullScreenAuxiliary, + .stationary, + .canJoinAllSpaces, + .ignoresCycle, + ] + + let hasNotch = (self.screen?.safeAreaInsets.top ?? 0) > 0 + + if Defaults[.hideNonNotchedFromMissionControl] && !hasNotch { + newBehavior.insert(.transient) + } + + collectionBehavior = newBehavior } private func updateSharingType() { diff --git a/boringNotch/components/Notch/NotchHomeView.swift b/boringNotch/components/Notch/NotchHomeView.swift index 0c29b8a4..1e54f41d 100644 --- a/boringNotch/components/Notch/NotchHomeView.swift +++ b/boringNotch/components/Notch/NotchHomeView.swift @@ -18,7 +18,7 @@ struct MusicPlayerView: View { var body: some View { HStack { - AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).padding(.all, 5) + AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).frame(width: 120).padding(.all, 5) MusicControlsView().drawingGroup().compositingGroup() } } @@ -41,14 +41,11 @@ struct AlbumArtView: View { private var albumArtBackground: some View { Image(nsImage: musicManager.albumArt) .resizable() - .clipped() + .aspectRatio(contentMode: .fit) .clipShape( RoundedRectangle( - cornerRadius: Defaults[.cornerRadiusScaling] - ? MusicPlayerImageSizes.cornerRadiusInset.opened - : MusicPlayerImageSizes.cornerRadiusInset.closed) + cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.opened) ) - .aspectRatio(1, contentMode: .fit) .scaleEffect(x: 1.3, y: 1.4) .rotationEffect(.degrees(92)) .blur(radius: 40) @@ -74,7 +71,6 @@ struct AlbumArtView: View { private var albumArtDarkOverlay: some View { Rectangle() - .aspectRatio(1, contentMode: .fit) .foregroundColor(Color.black) .opacity(musicManager.isPlaying ? 0 : 0.8) .blur(radius: 50) @@ -84,15 +80,12 @@ struct AlbumArtView: View { private var albumArtImage: some View { Image(nsImage: musicManager.albumArt) .resizable() - .aspectRatio(1, contentMode: .fit) - .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) - .clipped() + .aspectRatio(contentMode: .fit) .clipShape( RoundedRectangle( - cornerRadius: Defaults[.cornerRadiusScaling] - ? MusicPlayerImageSizes.cornerRadiusInset.opened - : MusicPlayerImageSizes.cornerRadiusInset.closed) + cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.opened) ) + .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) } @ViewBuilder @@ -100,7 +93,7 @@ struct AlbumArtView: View { if vm.notchState == .open && !musicManager.usingAppIconForArtwork { AppIcon(for: musicManager.bundleIdentifier ?? "com.apple.Music") .resizable() - .aspectRatio(contentMode: .fill) + .aspectRatio(contentMode: .fit) .frame(width: 30, height: 30) .offset(x: 10, y: 10) .transition(.scale.combined(with: .opacity)) @@ -140,14 +133,11 @@ struct MusicControlsView: View { private func songInfo(width: CGFloat) -> some View { VStack(alignment: .leading, spacing: 0) { + MarqueeText(musicManager.songTitle, font: .headline, color: .white, frameWidth: width) MarqueeText( - $musicManager.songTitle, font: .headline, nsFont: .headline, textColor: .white, - frameWidth: width) - MarqueeText( - $musicManager.artistName, + musicManager.artistName, font: .headline, - nsFont: .headline, - textColor: Defaults[.playerColorTinting] + color: Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor) .ensureMinimumBrightness(factor: 0.6) : .gray, frameWidth: width @@ -162,9 +152,9 @@ struct MusicControlsView: View { return min(max(progressed, 0), musicManager.songDuration) }() let line: String = { - if musicManager.isFetchingLyrics { return "Loading lyrics…" } - if !musicManager.syncedLyrics.isEmpty { - return musicManager.lyricLine(at: currentElapsed) + if LyricsService.shared.isFetchingLyrics { return "Loading lyrics…" } + if !LyricsService.shared.syncedLyrics.isEmpty { + return LyricsService.shared.lyricLine(at: currentElapsed) } let trimmed = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "No lyrics found" : trimmed.replacingOccurrences(of: "\n", with: " ") @@ -174,10 +164,9 @@ struct MusicControlsView: View { return v >= 0x0600 && v <= 0x06FF } MarqueeText( - .constant(line), + line, font: .subheadline, - nsFont: .subheadline, - textColor: musicManager.isFetchingLyrics ? .gray.opacity(0.7) : .gray, + color: musicManager.isFetchingLyrics ? .gray.opacity(0.7) : .gray, frameWidth: width ) .font(isPersian ? .custom("Vazirmatn-Regular", size: NSFont.preferredFont(forTextStyle: .subheadline).pointSize) : .subheadline) diff --git a/boringNotch/components/Settings/SettingsView.swift b/boringNotch/components/Settings/SettingsView.swift index cff23f33..a5ae7212 100644 --- a/boringNotch/components/Settings/SettingsView.swift +++ b/boringNotch/components/Settings/SettingsView.swift @@ -5,11 +5,6 @@ // Created by Richard Kunkli on 07/08/2024. // -import AVFoundation -import Defaults -import EventKit -import KeyboardShortcuts -import LaunchAtLogin import Sparkle import SwiftUI import SwiftUIIntrospect @@ -45,18 +40,12 @@ struct SettingsView: View { NavigationLink(value: "Battery") { Label("Battery", systemImage: "battery.100.bolt") } -// NavigationLink(value: "Downloads") { -// Label("Downloads", systemImage: "square.and.arrow.down") -// } NavigationLink(value: "Shelf") { Label("Shelf", systemImage: "books.vertical") } NavigationLink(value: "Shortcuts") { Label("Shortcuts", systemImage: "keyboard") } - // NavigationLink(value: "Extensions") { - // Label("Extensions", systemImage: "puzzlepiece.extension") - // } NavigationLink(value: "Advanced") { Label("Advanced", systemImage: "gearshape.2") } @@ -87,8 +76,6 @@ struct SettingsView: View { Shelf() case "Shortcuts": Shortcuts() - case "Extensions": - GeneralSettings() case "Advanced": Advanced() case "About": @@ -121,1679 +108,12 @@ struct SettingsView: View { .background(Color(NSColor.windowBackgroundColor)) .tint(.effectiveAccent) .id(accentColorUpdateTrigger) - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("AccentColorChanged"))) { _ in + .onReceive(NotificationCenter.default.publisher(for: .accentColorChanged)) { _ in accentColorUpdateTrigger = UUID() } } } -struct GeneralSettings: View { - @State private var screens: [(uuid: String, name: String)] = NSScreen.screens.compactMap { screen in - guard let uuid = screen.displayUUID else { return nil } - return (uuid, screen.localizedName) - } - @EnvironmentObject var vm: BoringViewModel - @ObservedObject var coordinator = BoringViewCoordinator.shared - - @Default(.mirrorShape) var mirrorShape - @Default(.showEmojis) var showEmojis - @Default(.gestureSensitivity) var gestureSensitivity - @Default(.minimumHoverDuration) var minimumHoverDuration - @Default(.nonNotchHeight) var nonNotchHeight - @Default(.nonNotchHeightMode) var nonNotchHeightMode - @Default(.notchHeight) var notchHeight - @Default(.notchHeightMode) var notchHeightMode - @Default(.showOnAllDisplays) var showOnAllDisplays - @Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay - @Default(.enableGestures) var enableGestures - @Default(.openNotchOnHover) var openNotchOnHover - - - var body: some View { - Form { - Section { - Toggle(isOn: Binding( - get: { Defaults[.menubarIcon] }, - set: { Defaults[.menubarIcon] = $0 } - )) { - Text("Show menu bar icon") - } - .tint(.effectiveAccent) - LaunchAtLogin.Toggle("Launch at login") - Defaults.Toggle(key: .showOnAllDisplays) { - Text("Show on all displays") - } - .onChange(of: showOnAllDisplays) { - NotificationCenter.default.post( - name: Notification.Name.showOnAllDisplaysChanged, object: nil) - } - Picker("Preferred display", selection: $coordinator.preferredScreenUUID) { - ForEach(screens, id: \.uuid) { screen in - Text(screen.name).tag(screen.uuid as String?) - } - } - .onChange(of: NSScreen.screens) { - screens = NSScreen.screens.compactMap { screen in - guard let uuid = screen.displayUUID else { return nil } - return (uuid, screen.localizedName) - } - } - .disabled(showOnAllDisplays) - - Defaults.Toggle(key: .automaticallySwitchDisplay) { - Text("Automatically switch displays") - } - .onChange(of: automaticallySwitchDisplay) { - NotificationCenter.default.post( - name: Notification.Name.automaticallySwitchDisplayChanged, object: nil) - } - .disabled(showOnAllDisplays) - } header: { - Text("System features") - } - - Section { - Picker( - selection: $notchHeightMode, - label: - Text("Notch height on notch displays") - ) { - Text("Match real notch height") - .tag(WindowHeightMode.matchRealNotchSize) - Text("Match menu bar height") - .tag(WindowHeightMode.matchMenuBar) - Text("Custom height") - .tag(WindowHeightMode.custom) - } - .onChange(of: notchHeightMode) { - switch notchHeightMode { - case .matchRealNotchSize: - notchHeight = 38 - case .matchMenuBar: - notchHeight = 44 - case .custom: - notchHeight = 38 - } - NotificationCenter.default.post( - name: Notification.Name.notchHeightChanged, object: nil) - } - if notchHeightMode == .custom { - Slider(value: $notchHeight, in: 15...45, step: 1) { - Text("Custom notch size - \(notchHeight, specifier: "%.0f")") - } - .onChange(of: notchHeight) { - NotificationCenter.default.post( - name: Notification.Name.notchHeightChanged, object: nil) - } - } - Picker("Notch height on non-notch displays", selection: $nonNotchHeightMode) { - Text("Match menubar height") - .tag(WindowHeightMode.matchMenuBar) - Text("Match real notch height") - .tag(WindowHeightMode.matchRealNotchSize) - Text("Custom height") - .tag(WindowHeightMode.custom) - } - .onChange(of: nonNotchHeightMode) { - switch nonNotchHeightMode { - case .matchMenuBar: - nonNotchHeight = 24 - case .matchRealNotchSize: - nonNotchHeight = 32 - case .custom: - nonNotchHeight = 32 - } - NotificationCenter.default.post( - name: Notification.Name.notchHeightChanged, object: nil) - } - if nonNotchHeightMode == .custom { - Slider(value: $nonNotchHeight, in: 0...40, step: 1) { - Text("Custom notch size - \(nonNotchHeight, specifier: "%.0f")") - } - .onChange(of: nonNotchHeight) { - NotificationCenter.default.post( - name: Notification.Name.notchHeightChanged, object: nil) - } - } - } header: { - Text("Notch sizing") - } - - NotchBehaviour() - - gestureControls() - } - .toolbar { - Button("Quit app") { - NSApp.terminate(self) - } - .controlSize(.extraLarge) - } - .accentColor(.effectiveAccent) - .navigationTitle("General") - .onChange(of: openNotchOnHover) { - if !openNotchOnHover { - enableGestures = true - } - } - } - - @ViewBuilder - func gestureControls() -> some View { - Section { - Defaults.Toggle(key: .enableGestures) { - Text("Enable gestures") - } - .disabled(!openNotchOnHover) - if enableGestures { - Toggle("Change media with horizontal gestures", isOn: .constant(false)) - .disabled(true) - Defaults.Toggle(key: .closeGestureEnabled) { - Text("Close gesture") - } - Slider(value: $gestureSensitivity, in: 100...300, step: 100) { - HStack { - Text("Gesture sensitivity") - Spacer() - Text( - Defaults[.gestureSensitivity] == 100 - ? "High" : Defaults[.gestureSensitivity] == 200 ? "Medium" : "Low" - ) - .foregroundStyle(.secondary) - } - } - } - } header: { - HStack { - Text("Gesture control") - customBadge(text: "Beta") - } - } footer: { - Text( - "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" - ) - .multilineTextAlignment(.trailing) - .foregroundStyle(.secondary) - .font(.caption) - } - } - - @ViewBuilder - func NotchBehaviour() -> some View { - Section { - Defaults.Toggle(key: .openNotchOnHover) { - Text("Open notch on hover") - } - Defaults.Toggle(key: .enableHaptics) { - Text("Enable haptic feedback") - } - Toggle("Remember last tab", isOn: $coordinator.openLastTabByDefault) - if openNotchOnHover { - Slider(value: $minimumHoverDuration, in: 0...1, step: 0.1) { - HStack { - Text("Hover delay") - Spacer() - Text("\(minimumHoverDuration, specifier: "%.1f")s") - .foregroundStyle(.secondary) - } - } - .onChange(of: minimumHoverDuration) { - NotificationCenter.default.post( - name: Notification.Name.notchHeightChanged, object: nil) - } - } - } header: { - Text("Notch behavior") - } - } -} - -struct Charge: View { - var body: some View { - Form { - Section { - Defaults.Toggle(key: .showBatteryIndicator) { - Text("Show battery indicator") - } - Defaults.Toggle(key: .showPowerStatusNotifications) { - Text("Show power status notifications") - } - } header: { - Text("General") - } - Section { - Defaults.Toggle(key: .showBatteryPercentage) { - Text("Show battery percentage") - } - Defaults.Toggle(key: .showPowerStatusIcons) { - Text("Show power status icons") - } - } header: { - Text("Battery Information") - } - } - .onAppear { - Task { @MainActor in - await XPCHelperClient.shared.isAccessibilityAuthorized() - } - } - .accentColor(.effectiveAccent) - .navigationTitle("Battery") - } -} - -//struct Downloads: View { -// @Default(.selectedDownloadIndicatorStyle) var selectedDownloadIndicatorStyle -// @Default(.selectedDownloadIconStyle) var selectedDownloadIconStyle -// var body: some View { -// Form { -// warningBadge("We don't support downloads yet", "It will be supported later on.") -// Section { -// Defaults.Toggle(key: .enableDownloadListener) { -// Text("Show download progress") -// } -// .disabled(true) -// Defaults.Toggle(key: .enableSafariDownloads) { -// Text("Enable Safari Downloads") -// } -// .disabled(!Defaults[.enableDownloadListener]) -// Picker("Download indicator style", selection: $selectedDownloadIndicatorStyle) { -// Text("Progress bar") -// .tag(DownloadIndicatorStyle.progress) -// Text("Percentage") -// .tag(DownloadIndicatorStyle.percentage) -// } -// Picker("Download icon style", selection: $selectedDownloadIconStyle) { -// Text("Only app icon") -// .tag(DownloadIconStyle.onlyAppIcon) -// Text("Only download icon") -// .tag(DownloadIconStyle.onlyIcon) -// Text("Both") -// .tag(DownloadIconStyle.iconAndAppIcon) -// } -// -// } header: { -// HStack { -// Text("Download indicators") -// comingSoonTag() -// } -// } -// Section { -// List { -// ForEach([].indices, id: \.self) { index in -// Text("\(index)") -// } -// } -// .frame(minHeight: 96) -// .overlay { -// if true { -// Text("No excluded apps") -// .foregroundStyle(Color(.secondaryLabelColor)) -// } -// } -// .actionBar(padding: 0) { -// Group { -// Button { -// } label: { -// Image(systemName: "plus") -// .frame(width: 25, height: 16, alignment: .center) -// .contentShape(Rectangle()) -// .foregroundStyle(.secondary) -// } -// -// Divider() -// Button { -// } label: { -// Image(systemName: "minus") -// .frame(width: 20, height: 16, alignment: .center) -// .contentShape(Rectangle()) -// .foregroundStyle(.secondary) -// } -// } -// } -// } header: { -// HStack(spacing: 4) { -// Text("Exclude apps") -// comingSoonTag() -// } -// } -// } -// .navigationTitle("Downloads") -// } -//} - -struct HUD: View { - @EnvironmentObject var vm: BoringViewModel - @Default(.inlineHUD) var inlineHUD - @Default(.enableGradient) var enableGradient - @Default(.optionKeyAction) var optionKeyAction - @Default(.hudReplacement) var hudReplacement - @ObservedObject var coordinator = BoringViewCoordinator.shared - @State private var accessibilityAuthorized = false - - var body: some View { - Form { - Section { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Replace system HUD") - .font(.headline) - Text("Replaces the standard macOS volume, display brightness, and keyboard brightness HUDs with a custom design.") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 40) - Defaults.Toggle("", key: .hudReplacement) - .labelsHidden() - .toggleStyle(.switch) - .controlSize(.large) - .disabled(!accessibilityAuthorized) - } - - if !accessibilityAuthorized { - VStack(alignment: .leading, spacing: 8) { - Text("Accessibility access is required to replace the system HUD.") - .font(.subheadline) - .foregroundStyle(.secondary) - - HStack(spacing: 12) { - Button("Request Accessibility") { - XPCHelperClient.shared.requestAccessibilityAuthorization() - } - .buttonStyle(.borderedProminent) - } - } - .padding(.top, 6) - } - } - - Section { - Picker("Option key behaviour", selection: $optionKeyAction) { - ForEach(OptionKeyAction.allCases) { opt in - Text(opt.rawValue).tag(opt) - } - } - - Picker("Progress bar style", selection: $enableGradient) { - Text("Hierarchical") - .tag(false) - Text("Gradient") - .tag(true) - } - Defaults.Toggle(key: .systemEventIndicatorShadow) { - Text("Enable glowing effect") - } - Defaults.Toggle(key: .systemEventIndicatorUseAccent) { - Text("Tint progress bar with accent color") - } - } header: { - Text("General") - } - .disabled(!hudReplacement) - - Section { - Defaults.Toggle(key: .showOpenNotchHUD) { - Text("Show HUD in open notch") - } - Defaults.Toggle(key: .showOpenNotchHUDPercentage) { - Text("Show percentage") - } - .disabled(!Defaults[.showOpenNotchHUD]) - } header: { - HStack { - Text("Open Notch") - customBadge(text: "Beta") - } - } - .disabled(!hudReplacement) - - Section { - Picker("HUD style", selection: $inlineHUD) { - Text("Default") - .tag(false) - Text("Inline") - .tag(true) - } - .onChange(of: Defaults[.inlineHUD]) { - if Defaults[.inlineHUD] { - withAnimation { - Defaults[.systemEventIndicatorShadow] = false - Defaults[.enableGradient] = false - } - } - } - - Defaults.Toggle(key: .showClosedNotchHUDPercentage) { - Text("Show percentage") - } - } header: { - Text("Closed Notch") - } - .disabled(!Defaults[.hudReplacement]) - } - .accentColor(.effectiveAccent) - .navigationTitle("HUDs") - .task { - accessibilityAuthorized = await XPCHelperClient.shared.isAccessibilityAuthorized() - } - .onAppear { - XPCHelperClient.shared.startMonitoringAccessibilityAuthorization() - } - .onDisappear { - XPCHelperClient.shared.stopMonitoringAccessibilityAuthorization() - } - .onReceive(NotificationCenter.default.publisher(for: .accessibilityAuthorizationChanged)) { notification in - if let granted = notification.userInfo?["granted"] as? Bool { - accessibilityAuthorized = granted - } - } - } -} - -struct Media: View { - @Default(.waitInterval) var waitInterval - @Default(.mediaController) var mediaController - @ObservedObject var coordinator = BoringViewCoordinator.shared - @Default(.hideNotchOption) var hideNotchOption - @Default(.enableSneakPeek) private var enableSneakPeek - @Default(.sneakPeekStyles) var sneakPeekStyles - - @Default(.enableLyrics) var enableLyrics - - var body: some View { - Form { - Section { - Picker("Music Source", selection: $mediaController) { - ForEach(availableMediaControllers) { controller in - Text(controller.rawValue).tag(controller) - } - } - .onChange(of: mediaController) { _, _ in - NotificationCenter.default.post( - name: Notification.Name.mediaControllerChanged, - object: nil - ) - } - } header: { - Text("Media Source") - } footer: { - if MusicManager.shared.isNowPlayingDeprecated { - HStack { - Text("YouTube Music requires this third-party app to be installed: ") - .foregroundStyle(.secondary) - .font(.caption) - Link( - "https://github.com/pear-devs/pear-desktop", - destination: URL(string: "https://github.com/pear-devs/pear-desktop")! - ) - .font(.caption) - .foregroundColor(.blue) // Ensures it's visibly a link - } - } else { - Text( - "'Now Playing' was the only option on previous versions and works with all media apps." - ) - .foregroundStyle(.secondary) - .font(.caption) - } - } - - Section { - Toggle( - "Show music live activity", - isOn: $coordinator.musicLiveActivityEnabled.animation() - ) - Toggle("Show sneak peek on playback changes", isOn: $enableSneakPeek) - Picker("Sneak Peek Style", selection: $sneakPeekStyles) { - ForEach(SneakPeekStyle.allCases) { style in - Text(style.rawValue).tag(style) - } - } - HStack { - Stepper(value: $waitInterval, in: 0...10, step: 1) { - HStack { - Text("Media inactivity timeout") - Spacer() - Text("\(Defaults[.waitInterval], specifier: "%.0f") seconds") - .foregroundStyle(.secondary) - } - } - } - Picker( - selection: $hideNotchOption, - label: - HStack { - Text("Full screen behavior") - customBadge(text: "Beta") - } - ) { - Text("Hide for all apps").tag(HideNotchOption.always) - Text("Hide for media app only").tag( - HideNotchOption.nowPlayingOnly) - Text("Never hide").tag(HideNotchOption.never) - } - } header: { - Text("Media playback live activity") - } - - Section { - MusicSlotConfigurationView() - Defaults.Toggle(key: .enableLyrics) { - HStack { - Text("Show lyrics below artist name") - customBadge(text: "Beta") - } - } - } header: { - Text("Media controls") - } footer: { - Text("Customize which controls appear in the music player. Volume expands when active.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .accentColor(.effectiveAccent) - .navigationTitle("Media") - } - - // Only show controller options that are available on this macOS version - private var availableMediaControllers: [MediaControllerType] { - if MusicManager.shared.isNowPlayingDeprecated { - return MediaControllerType.allCases.filter { $0 != .nowPlaying } - } else { - return MediaControllerType.allCases - } - } -} - -struct CalendarSettings: View { - @ObservedObject private var calendarManager = CalendarManager.shared - @Default(.showCalendar) var showCalendar: Bool - @Default(.hideCompletedReminders) var hideCompletedReminders - @Default(.hideAllDayEvents) var hideAllDayEvents - @Default(.autoScrollToNextEvent) var autoScrollToNextEvent - - var body: some View { - Form { - Defaults.Toggle(key: .showCalendar) { - Text("Show calendar") - } - Defaults.Toggle(key: .hideCompletedReminders) { - Text("Hide completed reminders") - } - Defaults.Toggle(key: .hideAllDayEvents) { - Text("Hide all-day events") - } - Defaults.Toggle(key: .autoScrollToNextEvent) { - Text("Auto-scroll to next event") - } - Defaults.Toggle(key: .showFullEventTitles) { - Text("Always show full event titles") - } - Section(header: Text("Calendars")) { - if calendarManager.calendarAuthorizationStatus != .fullAccess { - Text("Calendar access is denied. Please enable it in System Settings.") - .foregroundColor(.red) - .multilineTextAlignment(.center) - .padding() - Button("Open Calendar Settings") { - if let settingsURL = URL( - string: - "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars" - ) { - NSWorkspace.shared.open(settingsURL) - } - } - } else { - List { - ForEach(calendarManager.eventCalendars, id: \.id) { calendar in - Toggle( - isOn: Binding( - get: { calendarManager.getCalendarSelected(calendar) }, - set: { isSelected in - Task { - await calendarManager.setCalendarSelected( - calendar, isSelected: isSelected) - } - } - ) - ) { - Text(calendar.title) - } - .accentColor(lighterColor(from: calendar.color)) - .disabled(!showCalendar) - } - } - } - } - Section(header: Text("Reminders")) { - if calendarManager.reminderAuthorizationStatus != .fullAccess { - Text("Reminder access is denied. Please enable it in System Settings.") - .foregroundColor(.red) - .multilineTextAlignment(.center) - .padding() - Button("Open Reminder Settings") { - if let settingsURL = URL( - string: - "x-apple.systempreferences:com.apple.preference.security?Privacy_Reminders" - ) { - NSWorkspace.shared.open(settingsURL) - } - } - } else { - List { - ForEach(calendarManager.reminderLists, id: \.id) { calendar in - Toggle( - isOn: Binding( - get: { calendarManager.getCalendarSelected(calendar) }, - set: { isSelected in - Task { - await calendarManager.setCalendarSelected( - calendar, isSelected: isSelected) - } - } - ) - ) { - Text(calendar.title) - } - .accentColor(lighterColor(from: calendar.color)) - .disabled(!showCalendar) - } - } - } - } - } - .accentColor(.effectiveAccent) - .navigationTitle("Calendar") - .onAppear { - Task { - await calendarManager.checkCalendarAuthorization() - await calendarManager.checkReminderAuthorization() - } - } - } -} - -func lighterColor(from nsColor: NSColor, amount: CGFloat = 0.14) -> Color { - let srgb = nsColor.usingColorSpace(.sRGB) ?? nsColor - var (r, g, b, a): (CGFloat, CGFloat, CGFloat, CGFloat) = (0,0,0,0) - srgb.getRed(&r, green: &g, blue: &b, alpha: &a) - - func lighten(_ c: CGFloat) -> CGFloat { - let increased = c + (1.0 - c) * amount - return min(max(increased, 0), 1) - } - - let nr = lighten(r) - let ng = lighten(g) - let nb = lighten(b) - - return Color(red: Double(nr), green: Double(ng), blue: Double(nb), opacity: Double(a)) -} - -struct About: View { - @State private var showBuildNumber: Bool = false - let updaterController: SPUStandardUpdaterController - @Environment(\.openWindow) var openWindow - var body: some View { - VStack { - Form { - Section { - HStack { - Text("Release name") - Spacer() - Text(Defaults[.releaseName]) - .foregroundStyle(.secondary) - } - HStack { - Text("Version") - Spacer() - if showBuildNumber { - Text("(\(Bundle.main.buildVersionNumber ?? ""))") - .foregroundStyle(.secondary) - } - Text(Bundle.main.releaseVersionNumber ?? "unkown") - .foregroundStyle(.secondary) - } - .onTapGesture { - withAnimation { - showBuildNumber.toggle() - } - } - } header: { - Text("Version info") - } - - UpdaterSettingsView(updater: updaterController.updater) - - HStack(spacing: 30) { - Spacer(minLength: 0) - Button { - if let url = URL(string: "https://github.com/TheBoredTeam/boring.notch") { - NSWorkspace.shared.open(url) - } - } label: { - VStack(spacing: 5) { - Image("Github") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 18) - Text("GitHub") - } - .contentShape(Rectangle()) - } - Spacer(minLength: 0) - } - .buttonStyle(PlainButtonStyle()) - } - VStack(spacing: 0) { - Divider() - Text("Made with 🫶🏻 by not so boring not.people") - .foregroundStyle(.secondary) - .padding(.top, 5) - .padding(.bottom, 7) - .multilineTextAlignment(.center) - .padding(.horizontal, 10) - } - .frame(maxWidth: .infinity, alignment: .center) - } - .toolbar { - // Button("Welcome window") { - // openWindow(id: "onboarding") - // } - // .controlSize(.extraLarge) - CheckForUpdatesView(updater: updaterController.updater) - } - .navigationTitle("About") - } -} - -struct Shelf: View { - - @Default(.shelfTapToOpen) var shelfTapToOpen: Bool - @Default(.quickShareProvider) var quickShareProvider - @Default(.expandedDragDetection) var expandedDragDetection: Bool - @StateObject private var quickShareService = QuickShareService.shared - - private var selectedProvider: QuickShareProvider? { - quickShareService.availableProviders.first(where: { $0.id == quickShareProvider }) - } - - init() { - Task { await QuickShareService.shared.discoverAvailableProviders() } - } - - var body: some View { - Form { - Section { - Defaults.Toggle(key: .boringShelf) { - Text("Enable shelf") - } - Defaults.Toggle(key: .openShelfByDefault) { - Text("Open shelf by default if items are present") - } - Defaults.Toggle(key: .expandedDragDetection) { - Text("Expanded drag detection area") - } - .onChange(of: expandedDragDetection) { - NotificationCenter.default.post( - name: Notification.Name.expandedDragDetectionChanged, - object: nil - ) - } - Defaults.Toggle(key: .copyOnDrag) { - Text("Copy items on drag") - } - Defaults.Toggle(key: .autoRemoveShelfItems) { - Text("Remove from shelf after dragging") - } - - } header: { - HStack { - Text("General") - } - } - - Section { - Picker("Quick Share Service", selection: $quickShareProvider) { - ForEach(quickShareService.availableProviders, id: \.id) { provider in - HStack { - Group { - if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) { - Image(nsImage: nsImg) - .resizable() - .aspectRatio(contentMode: .fit) - } else { - Image(systemName: "square.and.arrow.up") - } - } - .frame(width: 16, height: 16) - .foregroundColor(.accentColor) - Text(provider.id) - } - .tag(provider.id) - } - } - .pickerStyle(.menu) - - if let selectedProvider = selectedProvider { - HStack { - Group { - if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) { - Image(nsImage: nsImg) - .resizable() - .aspectRatio(contentMode: .fit) - } else { - Image(systemName: "square.and.arrow.up") - } - } - .frame(width: 16, height: 16) - .foregroundColor(.accentColor) - VStack(alignment: .leading, spacing: 2) { - Text("Currently selected: \(selectedProvider.id)") - .font(.caption) - .foregroundColor(.secondary) - Text("Files dropped on the shelf will be shared via this service") - .font(.caption2) - .foregroundColor(.secondary) - } - } - .padding(.vertical, 4) - } - // Providers are always enabled; user can pick default service above. - - } header: { - HStack { - Text("Quick Share") - } - } footer: { - Text("Choose which service to use when sharing files from the shelf. Click the shelf button to select files, or drag files onto it to share immediately.") - .font(.caption) - .foregroundColor(.secondary) - } - } - .accentColor(.effectiveAccent) - .navigationTitle("Shelf") - } -} - -//struct Extensions: View { -// @State private var effectTrigger: Bool = false -// var body: some View { -// Form { -// Section { -// List { -// ForEach(extensionManager.installedExtensions.indices, id: \.self) { index in -// let item = extensionManager.installedExtensions[index] -// HStack { -// AppIcon(for: item.bundleIdentifier) -// .resizable() -// .frame(width: 24, height: 24) -// Text(item.name) -// ListItemPopover { -// Text("Description") -// } -// Spacer(minLength: 0) -// HStack(spacing: 6) { -// Circle() -// .frame(width: 6, height: 6) -// .foregroundColor( -// isExtensionRunning(item.bundleIdentifier) -// ? .green : item.status == .disabled ? .gray : .red -// ) -// .conditionalModifier(isExtensionRunning(item.bundleIdentifier)) -// { view in -// view -// .shadow(color: .green, radius: 3) -// } -// Text( -// isExtensionRunning(item.bundleIdentifier) -// ? "Running" -// : item.status == .disabled ? "Disabled" : "Stopped" -// ) -// .contentTransition(.numericText()) -// .foregroundStyle(.secondary) -// .font(.footnote) -// } -// .frame(width: 60, alignment: .leading) -// -// Menu( -// content: { -// Button("Restart") { -// let ws = NSWorkspace.shared -// -// if let ext = ws.runningApplications.first(where: { -// $0.bundleIdentifier == item.bundleIdentifier -// }) { -// ext.terminate() -// } -// -// if let appURL = ws.urlForApplication( -// withBundleIdentifier: item.bundleIdentifier) -// { -// ws.openApplication( -// at: appURL, configuration: .init(), -// completionHandler: nil) -// } -// } -// .keyboardShortcut("R", modifiers: .command) -// Button("Disable") { -// if let ext = NSWorkspace.shared.runningApplications.first( -// where: { $0.bundleIdentifier == item.bundleIdentifier }) -// { -// ext.terminate() -// } -// extensionManager.installedExtensions[index].status = -// .disabled -// } -// .keyboardShortcut("D", modifiers: .command) -// Divider() -// Button("Uninstall", role: .destructive) { -// // -// } -// }, -// label: { -// Image(systemName: "ellipsis.circle") -// .foregroundStyle(.secondary) -// } -// ) -// .controlSize(.regular) -// } -// .buttonStyle(PlainButtonStyle()) -// .padding(.vertical, 5) -// } -// } -// .frame(minHeight: 120) -// .actionBar { -// Button { -// } label: { -// HStack(spacing: 3) { -// Image(systemName: "plus") -// Text("Add manually") -// } -// .foregroundStyle(.secondary) -// } -// .disabled(true) -// Spacer() -// Button { -// withAnimation(.linear(duration: 1)) { -// effectTrigger.toggle() -// } completion: { -// effectTrigger.toggle() -// } -// extensionManager.checkIfExtensionsAreInstalled() -// } label: { -// HStack(spacing: 3) { -// Image(systemName: "arrow.triangle.2.circlepath") -// .rotationEffect(effectTrigger ? .degrees(360) : .zero) -// } -// .foregroundStyle(.secondary) -// } -// } -// .controlSize(.small) -// .buttonStyle(PlainButtonStyle()) -// .overlay { -// if extensionManager.installedExtensions.isEmpty { -// Text("No extension installed") -// .foregroundStyle(Color(.secondaryLabelColor)) -// .padding(.bottom, 22) -// } -// } -// } header: { -// HStack(spacing: 0) { -// Text("Installed extensions") -// if !extensionManager.installedExtensions.isEmpty { -// Text(" – \(extensionManager.installedExtensions.count)") -// .foregroundStyle(.secondary) -// } -// } -// } -// } -// .accentColor(.effectiveAccent) -// .navigationTitle("Extensions") -// // TipsView() -// // .padding(.horizontal, 19) -// } -//} - -struct Appearance: View { - @ObservedObject var coordinator = BoringViewCoordinator.shared - @Default(.mirrorShape) var mirrorShape - @Default(.sliderColor) var sliderColor - @Default(.useMusicVisualizer) var useMusicVisualizer - @Default(.customVisualizers) var customVisualizers - @Default(.selectedVisualizer) var selectedVisualizer - - let icons: [String] = ["logo2"] - @State private var selectedIcon: String = "logo2" - @State private var selectedListVisualizer: CustomVisualizer? = nil - @State private var isPresented: Bool = false - @State private var name: String = "" - @State private var url: String = "" - @State private var speed: CGFloat = 1.0 - var body: some View { - Form { - Section { - Toggle("Always show tabs", isOn: $coordinator.alwaysShowTabs) - Defaults.Toggle(key: .settingsIconInNotch) { - Text("Show settings icon in notch") - } - - } header: { - Text("General") - } - - Section { - Defaults.Toggle(key: .coloredSpectrogram) { - Text("Colored spectrogram") - } - Defaults - .Toggle("Player tinting", key: .playerColorTinting) - Defaults.Toggle(key: .lightingEffect) { - Text("Enable blur effect behind album art") - } - Picker("Slider color", selection: $sliderColor) { - ForEach(SliderColorEnum.allCases, id: \.self) { option in - Text(option.rawValue) - } - } - } header: { - Text("Media") - } - - Section { - Toggle( - "Use music visualizer spectrogram", - isOn: $useMusicVisualizer.animation() - ) - .disabled(true) - if !useMusicVisualizer { - if customVisualizers.count > 0 { - Picker( - "Selected animation", - selection: $selectedVisualizer - ) { - ForEach( - customVisualizers, - id: \.self - ) { visualizer in - Text(visualizer.name) - .tag(visualizer) - } - } - } else { - HStack { - Text("Selected animation") - Spacer() - Text("No custom animation available") - .foregroundStyle(.secondary) - } - } - } - } header: { - HStack { - Text("Custom music live activity animation") - customBadge(text: "Coming soon") - } - } - - Section { - List { - ForEach(customVisualizers, id: \.self) { visualizer in - HStack { - LottieView( - url: visualizer.url, speed: visualizer.speed, - loopMode: .loop - ) - .frame(width: 30, height: 30, alignment: .center) - Text(visualizer.name) - Spacer(minLength: 0) - if selectedVisualizer == visualizer { - Text("selected") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .padding(.trailing, 8) - } - } - .buttonStyle(PlainButtonStyle()) - .padding(.vertical, 2) - .background( - selectedListVisualizer != nil - ? selectedListVisualizer == visualizer - ? Color.effectiveAccent : Color.clear : Color.clear, - in: RoundedRectangle(cornerRadius: 5) - ) - .contentShape(Rectangle()) - .onTapGesture { - if selectedListVisualizer == visualizer { - selectedListVisualizer = nil - return - } - selectedListVisualizer = visualizer - } - } - } - .safeAreaPadding( - EdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0) - ) - .frame(minHeight: 120) - .actionBar { - HStack(spacing: 5) { - Button { - name = "" - url = "" - speed = 1.0 - isPresented.toggle() - } label: { - Image(systemName: "plus") - .foregroundStyle(.secondary) - .contentShape(Rectangle()) - } - Divider() - Button { - if selectedListVisualizer != nil { - let visualizer = selectedListVisualizer! - selectedListVisualizer = nil - customVisualizers.remove( - at: customVisualizers.firstIndex(of: visualizer)!) - if visualizer == selectedVisualizer && customVisualizers.count > 0 { - selectedVisualizer = customVisualizers[0] - } - } - } label: { - Image(systemName: "minus") - .foregroundStyle(.secondary) - .contentShape(Rectangle()) - } - } - } - .controlSize(.small) - .buttonStyle(PlainButtonStyle()) - .overlay { - if customVisualizers.isEmpty { - Text("No custom visualizer") - .foregroundStyle(Color(.secondaryLabelColor)) - .padding(.bottom, 22) - } - } - .sheet(isPresented: $isPresented) { - VStack(alignment: .leading) { - Text("Add new visualizer") - .font(.largeTitle.bold()) - .padding(.vertical) - TextField("Name", text: $name) - TextField("Lottie JSON URL", text: $url) - HStack { - Text("Speed") - Spacer(minLength: 80) - Text("\(speed, specifier: "%.1f")s") - .multilineTextAlignment(.trailing) - .foregroundStyle(.secondary) - Slider(value: $speed, in: 0...2, step: 0.1) - } - .padding(.vertical) - HStack { - Button { - isPresented.toggle() - } label: { - Text("Cancel") - .frame(maxWidth: .infinity, alignment: .center) - } - - Button { - let visualizer: CustomVisualizer = .init( - UUID: UUID(), - name: name, - url: URL(string: url)!, - speed: speed - ) - - if !customVisualizers.contains(visualizer) { - customVisualizers.append(visualizer) - } - - isPresented.toggle() - } label: { - Text("Add") - .frame(maxWidth: .infinity, alignment: .center) - } - .buttonStyle(BorderedProminentButtonStyle()) - } - } - .textFieldStyle(RoundedBorderTextFieldStyle()) - .controlSize(.extraLarge) - .padding() - } - } header: { - HStack(spacing: 0) { - Text("Custom vizualizers (Lottie)") - if !Defaults[.customVisualizers].isEmpty { - Text(" – \(Defaults[.customVisualizers].count)") - .foregroundStyle(.secondary) - } - } - } - - Section { - Defaults.Toggle(key: .showMirror) { - Text("Enable boring mirror") - } - .disabled(!checkVideoInput()) - Picker("Mirror shape", selection: $mirrorShape) { - Text("Circle") - .tag(MirrorShapeEnum.circle) - Text("Square") - .tag(MirrorShapeEnum.rectangle) - } - Defaults.Toggle(key: .showNotHumanFace) { - Text("Show cool face animation while inactive") - } - } header: { - HStack { - Text("Additional features") - } - } - } - .accentColor(.effectiveAccent) - .navigationTitle("Appearance") - } - - func checkVideoInput() -> Bool { - if AVCaptureDevice.default(for: .video) != nil { - return true - } - - return false - } -} - -struct Advanced: View { - @Default(.useCustomAccentColor) var useCustomAccentColor - @Default(.customAccentColorData) var customAccentColorData - @Default(.extendHoverArea) var extendHoverArea - @Default(.showOnLockScreen) var showOnLockScreen - @Default(.hideFromScreenRecording) var hideFromScreenRecording - - @State private var customAccentColor: Color = .accentColor - @State private var selectedPresetColor: PresetAccentColor? = nil - let icons: [String] = ["logo2"] - @State private var selectedIcon: String = "logo2" - - // macOS accent colors - enum PresetAccentColor: String, CaseIterable, Identifiable { - case blue = "Blue" - case purple = "Purple" - case pink = "Pink" - case red = "Red" - case orange = "Orange" - case yellow = "Yellow" - case green = "Green" - case graphite = "Graphite" - - var id: String { self.rawValue } - - var color: Color { - switch self { - case .blue: return Color(red: 0.0, green: 0.478, blue: 1.0) - case .purple: return Color(red: 0.686, green: 0.322, blue: 0.871) - case .pink: return Color(red: 1.0, green: 0.176, blue: 0.333) - case .red: return Color(red: 1.0, green: 0.271, blue: 0.227) - case .orange: return Color(red: 1.0, green: 0.584, blue: 0.0) - case .yellow: return Color(red: 1.0, green: 0.8, blue: 0.0) - case .green: return Color(red: 0.4, green: 0.824, blue: 0.176) - case .graphite: return Color(red: 0.557, green: 0.557, blue: 0.576) - } - } - } - - var body: some View { - Form { - Section { - VStack(alignment: .leading, spacing: 16) { - // Toggle between system and custom - Picker("Accent color", selection: $useCustomAccentColor) { - Text("System").tag(false) - Text("Custom").tag(true) - } - .pickerStyle(.segmented) - - if !useCustomAccentColor { - // System accent info - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - AccentCircleButton( - isSelected: true, - color: .accentColor, - isSystemDefault: true - ) {} - - VStack(alignment: .leading, spacing: 2) { - Text("Using System Accent") - .font(.body) - Text("Your macOS system accent color") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - } - } - } else { - // Custom color options - VStack(alignment: .leading, spacing: 12) { - Text("Color Presets") - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - - HStack(spacing: 12) { - ForEach(PresetAccentColor.allCases) { preset in - AccentCircleButton( - isSelected: selectedPresetColor == preset, - color: preset.color, - isMulticolor: false - ) { - selectedPresetColor = preset - customAccentColor = preset.color - saveCustomColor(preset.color) - forceUiUpdate() - } - } - Spacer() - } - - Divider() - .padding(.vertical, 4) - - // Custom color picker - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text("Pick a Color") - .font(.body) - Text("Choose any color") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - ColorPicker(selection: Binding( - get: { customAccentColor }, - set: { newColor in - customAccentColor = newColor - selectedPresetColor = nil - saveCustomColor(newColor) - forceUiUpdate() - } - ), supportsOpacity: false) { - ZStack { - Circle() - .fill(customAccentColor) - .frame(width: 32, height: 32) - - if selectedPresetColor == nil { - Circle() - .strokeBorder(.primary.opacity(0.3), lineWidth: 2) - .frame(width: 32, height: 32) - } - } - } - .labelsHidden() - } - } - } - } - .padding(.vertical, 4) - } header: { - Text("Accent color") - } footer: { - Text("Choose between your system accent color or customize it with your own selection.") - .multilineTextAlignment(.trailing) - .foregroundStyle(.secondary) - .font(.caption) - } - .onAppear { - initializeAccentColorState() - } - - Section { - Defaults.Toggle(key: .enableShadow) { - Text("Enable window shadow") - } - Defaults.Toggle(key: .cornerRadiusScaling) { - Text("Corner radius scaling") - } - } header: { - Text("Window Appearance") - } - - Section { - HStack { - ForEach(icons, id: \.self) { icon in - Spacer() - VStack { - Image(icon) - .resizable() - .frame(width: 80, height: 80) - .background( - RoundedRectangle(cornerRadius: 20, style: .circular) - .strokeBorder( - icon == selectedIcon ? Color.effectiveAccent : .clear, - lineWidth: 2.5 - ) - ) - - Text("Default") - .fontWeight(.medium) - .font(.caption) - .foregroundStyle(icon == selectedIcon ? .white : .secondary) - .padding(.horizontal, 10) - .padding(.vertical, 3) - .background( - Capsule() - .fill(icon == selectedIcon ? Color.effectiveAccent : .clear) - ) - } - .onTapGesture { - withAnimation { - selectedIcon = icon - } - NSApp.applicationIconImage = NSImage(named: icon) - } - Spacer() - } - } - .disabled(true) - } header: { - HStack { - Text("App icon") - customBadge(text: "Coming soon") - } - } - - Section { - Defaults.Toggle(key: .extendHoverArea) { - Text("Extend hover area") - } - Defaults.Toggle(key: .hideTitleBar) { - Text("Hide title bar") - } - Defaults.Toggle(key: .showOnLockScreen) { - Text("Show notch on lock screen") - } - Defaults.Toggle(key: .hideFromScreenRecording) { - Text("Hide from screen recording") - } - } header: { - Text("Window Behavior") - } - } - .accentColor(.effectiveAccent) - .navigationTitle("Advanced") - .onAppear { - loadCustomColor() - } - } - - private func forceUiUpdate() { - // Force refresh the UI - DispatchQueue.main.async { - NotificationCenter.default.post(name: Notification.Name("AccentColorChanged"), object: nil) - } - } - - private func saveCustomColor(_ color: Color) { - let nsColor = NSColor(color) - if let colorData = try? NSKeyedArchiver.archivedData(withRootObject: nsColor, requiringSecureCoding: false) { - Defaults[.customAccentColorData] = colorData - forceUiUpdate() - } - } - - private func loadCustomColor() { - if let colorData = Defaults[.customAccentColorData], - let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) { - customAccentColor = Color(nsColor: nsColor) - - // Check if loaded color matches a preset - selectedPresetColor = nil - for preset in PresetAccentColor.allCases { - if colorsAreEqual(Color(nsColor: nsColor), preset.color) { - selectedPresetColor = preset - break - } - } - } - } - - private func colorsAreEqual(_ color1: Color, _ color2: Color) -> Bool { - let nsColor1 = NSColor(color1).usingColorSpace(.sRGB) ?? NSColor(color1) - let nsColor2 = NSColor(color2).usingColorSpace(.sRGB) ?? NSColor(color2) - - return abs(nsColor1.redComponent - nsColor2.redComponent) < 0.01 && - abs(nsColor1.greenComponent - nsColor2.greenComponent) < 0.01 && - abs(nsColor1.blueComponent - nsColor2.blueComponent) < 0.01 - } - - private func initializeAccentColorState() { - if !useCustomAccentColor { - selectedPresetColor = nil // Multicolor is selected when useCustomAccentColor is false - } else { - loadCustomColor() - } - } -} - -// MARK: - Accent Circle Button Component -struct AccentCircleButton: View { - let isSelected: Bool - let color: Color - var isSystemDefault: Bool = false - var isMulticolor: Bool = false - let action: () -> Void - - var body: some View { - Button(action: action) { - ZStack { - // Color circle - Circle() - .fill(color) - .frame(width: 32, height: 32) - - // Subtle border - Circle() - .strokeBorder(Color.primary.opacity(0.15), lineWidth: 1) - .frame(width: 32, height: 32) - - // Apple-style highlight ring around the middle when selected - if isSelected { - Circle() - .strokeBorder( - Color.white.opacity(0.5), - lineWidth: 2 - ) - .frame(width: 28, height: 28) - } - } - } - .buttonStyle(.plain) - .help(isSystemDefault ? "Use your macOS system accent color" : "") - } -} - -struct Shortcuts: View { - var body: some View { - Form { - Section { - KeyboardShortcuts.Recorder("Toggle Sneak Peek:", name: .toggleSneakPeek) - } header: { - Text("Media") - } footer: { - Text( - "Sneak Peek shows the media title and artist under the notch for a few seconds." - ) - .multilineTextAlignment(.trailing) - .foregroundStyle(.secondary) - .font(.caption) - } - Section { - KeyboardShortcuts.Recorder("Toggle Notch Open:", name: .toggleNotchOpen) - } - } - .accentColor(.effectiveAccent) - .navigationTitle("Shortcuts") - } -} - -func proFeatureBadge() -> some View { - Text("Upgrade to Pro") - .foregroundStyle(Color(red: 0.545, green: 0.196, blue: 0.98)) - .font(.footnote.bold()) - .padding(.vertical, 3) - .padding(.horizontal, 6) - .background( - RoundedRectangle(cornerRadius: 4).stroke( - Color(red: 0.545, green: 0.196, blue: 0.98), lineWidth: 1)) -} - -func comingSoonTag() -> some View { - Text("Coming soon") - .foregroundStyle(.secondary) - .font(.footnote.bold()) - .padding(.vertical, 3) - .padding(.horizontal, 6) - .background(Color(nsColor: .secondarySystemFill)) - .clipShape(.capsule) -} - -func customBadge(text: String) -> some View { - Text(text) - .foregroundStyle(.secondary) - .font(.footnote.bold()) - .padding(.vertical, 3) - .padding(.horizontal, 6) - .background(Color(nsColor: .secondarySystemFill)) - .clipShape(.capsule) -} - -func warningBadge(_ text: String, _ description: String) -> some View { - Section { - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 22)) - .foregroundStyle(.yellow) - VStack(alignment: .leading) { - Text(text) - .font(.headline) - Text(description) - .foregroundStyle(.secondary) - } - Spacer() - } - } -} - #Preview { HUD() } diff --git a/boringNotch/components/Settings/Views/AboutView.swift b/boringNotch/components/Settings/Views/AboutView.swift new file mode 100644 index 00000000..14f39381 --- /dev/null +++ b/boringNotch/components/Settings/Views/AboutView.swift @@ -0,0 +1,83 @@ +// +// AboutView.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import Defaults +import Sparkle +import SwiftUI + +struct About: View { + @State private var showBuildNumber: Bool = false + let updaterController: SPUStandardUpdaterController + @Environment(\.openWindow) var openWindow + var body: some View { + VStack { + Form { + Section { + HStack { + Text("Release name") + Spacer() + Text(Defaults[.releaseName]) + .foregroundStyle(.secondary) + } + HStack { + Text("Version") + Spacer() + if showBuildNumber { + Text("(\(Bundle.main.buildVersionNumber ?? ""))") + .foregroundStyle(.secondary) + } + Text(Bundle.main.releaseVersionNumber ?? "unkown") + .foregroundStyle(.secondary) + } + .onTapGesture { + withAnimation { + showBuildNumber.toggle() + } + } + } header: { + Text("Version info") + } + + UpdaterSettingsView(updater: updaterController.updater) + + HStack(spacing: 30) { + Spacer(minLength: 0) + Button { + if let url = URL(string: "https://github.com/TheBoredTeam/boring.notch") { + NSWorkspace.shared.open(url) + } + } label: { + VStack(spacing: 5) { + Image("Github") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18) + Text("GitHub") + } + .contentShape(Rectangle()) + } + Spacer(minLength: 0) + } + .buttonStyle(PlainButtonStyle()) + } + VStack(spacing: 0) { + Divider() + Text("Made with 🫶🏻 by not so boring not.people") + .foregroundStyle(.secondary) + .padding(.top, 5) + .padding(.bottom, 7) + .multilineTextAlignment(.center) + .padding(.horizontal, 10) + } + .frame(maxWidth: .infinity, alignment: .center) + } + .toolbar { + CheckForUpdatesView(updater: updaterController.updater) + } + .navigationTitle("About") + } +} diff --git a/boringNotch/components/Settings/Views/AdvancedSettingsView.swift b/boringNotch/components/Settings/Views/AdvancedSettingsView.swift new file mode 100644 index 00000000..73dea434 --- /dev/null +++ b/boringNotch/components/Settings/Views/AdvancedSettingsView.swift @@ -0,0 +1,325 @@ +// +// AdvancedSettingsView.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import Defaults +import SwiftUI + +struct Advanced: View { + @Default(.useCustomAccentColor) var useCustomAccentColor + @Default(.customAccentColorData) var customAccentColorData + @Default(.extendHoverArea) var extendHoverArea + @Default(.showOnLockScreen) var showOnLockScreen + @Default(.hideFromScreenRecording) var hideFromScreenRecording + + @State private var customAccentColor: Color = .accentColor + @State private var selectedPresetColor: PresetAccentColor? = nil + let icons: [String] = ["logo2"] + @State private var selectedIcon: String = "logo2" + + // macOS accent colors + enum PresetAccentColor: String, CaseIterable, Identifiable { + case blue = "Blue" + case purple = "Purple" + case pink = "Pink" + case red = "Red" + case orange = "Orange" + case yellow = "Yellow" + case green = "Green" + case graphite = "Graphite" + + var id: String { self.rawValue } + + var color: Color { + switch self { + case .blue: return Color(red: 0.0, green: 0.478, blue: 1.0) + case .purple: return Color(red: 0.686, green: 0.322, blue: 0.871) + case .pink: return Color(red: 1.0, green: 0.176, blue: 0.333) + case .red: return Color(red: 1.0, green: 0.271, blue: 0.227) + case .orange: return Color(red: 1.0, green: 0.584, blue: 0.0) + case .yellow: return Color(red: 1.0, green: 0.8, blue: 0.0) + case .green: return Color(red: 0.4, green: 0.824, blue: 0.176) + case .graphite: return Color(red: 0.557, green: 0.557, blue: 0.576) + } + } + } + + var body: some View { + Form { + Section { + VStack(alignment: .leading, spacing: 16) { + // Toggle between system and custom + Picker("Accent color", selection: $useCustomAccentColor) { + Text("System").tag(false) + Text("Custom").tag(true) + } + .pickerStyle(.segmented) + + if !useCustomAccentColor { + // System accent info + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + AccentCircleButton( + isSelected: true, + color: .accentColor, + isSystemDefault: true + ) {} + + VStack(alignment: .leading, spacing: 2) { + Text("Using System Accent") + .font(.body) + Text("Your macOS system accent color") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + } + } else { + // Custom color options + VStack(alignment: .leading, spacing: 12) { + Text("Color Presets") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + ForEach(PresetAccentColor.allCases) { preset in + AccentCircleButton( + isSelected: selectedPresetColor == preset, + color: preset.color, + isMulticolor: false + ) { + selectedPresetColor = preset + customAccentColor = preset.color + saveCustomColor(preset.color) + forceUiUpdate() + } + } + Spacer() + } + + Divider() + .padding(.vertical, 4) + + // Custom color picker + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Pick a Color") + .font(.body) + Text("Choose any color") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + ColorPicker(selection: Binding( + get: { customAccentColor }, + set: { newColor in + customAccentColor = newColor + selectedPresetColor = nil + saveCustomColor(newColor) + forceUiUpdate() + } + ), supportsOpacity: false) { + ZStack { + Circle() + .fill(customAccentColor) + .frame(width: 32, height: 32) + + if selectedPresetColor == nil { + Circle() + .strokeBorder(.primary.opacity(0.3), lineWidth: 2) + .frame(width: 32, height: 32) + } + } + } + .labelsHidden() + } + } + } + } + .padding(.vertical, 4) + } header: { + Text("Accent color") + } footer: { + Text("Choose between your system accent color or customize it with your own selection.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + .onAppear { + initializeAccentColorState() + } + + Section { + Defaults.Toggle(key: .enableShadow) { + Text("Enable window shadow") + } + Defaults.Toggle(key: .cornerRadiusScaling) { + Text("Scale corner radius for closed notch") + } + } header: { + Text("Window Appearance") + } + + Section { + HStack { + ForEach(icons, id: \.self) { icon in + Spacer() + VStack { + Image(icon) + .resizable() + .frame(width: 80, height: 80) + .background( + RoundedRectangle(cornerRadius: 20, style: .circular) + .strokeBorder( + icon == selectedIcon ? Color.effectiveAccent : .clear, + lineWidth: 2.5 + ) + ) + + Text("Default") + .fontWeight(.medium) + .font(.caption) + .foregroundStyle(icon == selectedIcon ? .white : .secondary) + .padding(.horizontal, 10) + .padding(.vertical, 3) + .background( + Capsule() + .fill(icon == selectedIcon ? Color.effectiveAccent : .clear) + ) + } + .onTapGesture { + withAnimation { + selectedIcon = icon + } + NSApp.applicationIconImage = NSImage(named: icon) + } + Spacer() + } + } + .disabled(true) + } header: { + HStack { + Text("App icon") + customBadge(text: "Coming soon") + } + } + + Section { + Defaults.Toggle(key: .extendHoverArea) { + Text("Extend hover area") + } + Defaults.Toggle(key: .hideTitleBar) { + Text("Hide title bar") + } + Defaults.Toggle(key: .showOnLockScreen) { + Text("Show notch on lock screen") + } + Defaults.Toggle(key: .hideFromScreenRecording) { + Text("Hide from screen recording") + } + Defaults.Toggle(key: .hideNonNotchedFromMissionControl) { + Text("Hide windows on non-notch displays from Mission Control") + } + } header: { + Text("Window Behavior") + } + } + .accentColor(.effectiveAccent) + .navigationTitle("Advanced") + .onAppear { + loadCustomColor() + } + } + + private func forceUiUpdate() { + // Force refresh the UI + DispatchQueue.main.async { + NotificationCenter.default.post(name: .accentColorChanged, object: nil) + } + } + + private func saveCustomColor(_ color: Color) { + let nsColor = NSColor(color) + if let colorData = try? NSKeyedArchiver.archivedData(withRootObject: nsColor, requiringSecureCoding: false) { + Defaults[.customAccentColorData] = colorData + forceUiUpdate() + } + } + + private func loadCustomColor() { + if let colorData = Defaults[.customAccentColorData], + let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) { + customAccentColor = Color(nsColor: nsColor) + + // Check if loaded color matches a preset + selectedPresetColor = nil + for preset in PresetAccentColor.allCases { + if colorsAreEqual(Color(nsColor: nsColor), preset.color) { + selectedPresetColor = preset + break + } + } + } + } + + private func colorsAreEqual(_ color1: Color, _ color2: Color) -> Bool { + let nsColor1 = NSColor(color1).usingColorSpace(.sRGB) ?? NSColor(color1) + let nsColor2 = NSColor(color2).usingColorSpace(.sRGB) ?? NSColor(color2) + + return abs(nsColor1.redComponent - nsColor2.redComponent) < 0.01 && + abs(nsColor1.greenComponent - nsColor2.greenComponent) < 0.01 && + abs(nsColor1.blueComponent - nsColor2.blueComponent) < 0.01 + } + + private func initializeAccentColorState() { + if !useCustomAccentColor { + selectedPresetColor = nil // Multicolor is selected when useCustomAccentColor is false + } else { + loadCustomColor() + } + } +} + +// MARK: - Accent Circle Button Component +struct AccentCircleButton: View { + let isSelected: Bool + let color: Color + var isSystemDefault: Bool = false + var isMulticolor: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + ZStack { + // Color circle + Circle() + .fill(color) + .frame(width: 32, height: 32) + + // Subtle border + Circle() + .strokeBorder(Color.primary.opacity(0.15), lineWidth: 1) + .frame(width: 32, height: 32) + + // Apple-style highlight ring around the middle when selected + if isSelected { + Circle() + .strokeBorder( + Color.white.opacity(0.5), + lineWidth: 2 + ) + .frame(width: 28, height: 28) + } + } + } + .buttonStyle(.plain) + .help(isSystemDefault ? "Use your macOS system accent color" : "") + } +} diff --git a/boringNotch/components/Settings/Views/AppearanceSettingsView.swift b/boringNotch/components/Settings/Views/AppearanceSettingsView.swift new file mode 100644 index 00000000..78ef8c0c --- /dev/null +++ b/boringNotch/components/Settings/Views/AppearanceSettingsView.swift @@ -0,0 +1,82 @@ +// +// AppearanceSettingsView.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import AVFoundation +import Defaults +import SwiftUI + +struct Appearance: View { + @ObservedObject var coordinator = BoringViewCoordinator.shared + @Default(.mirrorShape) var mirrorShape + @Default(.sliderColor) var sliderColor + + let icons: [String] = ["logo2"] + @State private var selectedIcon: String = "logo2" + var body: some View { + Form { + Section { + Toggle("Always show tabs", isOn: $coordinator.alwaysShowTabs) + Defaults.Toggle(key: .settingsIconInNotch) { + Text("Show settings icon in notch") + } + + } header: { + Text("General") + } + + Section { + Defaults.Toggle(key: .coloredSpectrogram) { + Text("Colored spectrogram") + } + Defaults + .Toggle("Player tinting", key: .playerColorTinting) + Defaults.Toggle(key: .lightingEffect) { + Text("Enable blur effect behind album art") + } + Picker("Slider color", selection: $sliderColor) { + ForEach(SliderColorEnum.allCases, id: \.self) { option in + Text(option.rawValue) + } + } + } header: { + Text("Media") + } + + + + Section { + Defaults.Toggle(key: .showMirror) { + Text("Enable boring mirror") + } + .disabled(!checkVideoInput()) + Picker("Mirror shape", selection: $mirrorShape) { + Text("Circle") + .tag(MirrorShapeEnum.circle) + Text("Square") + .tag(MirrorShapeEnum.rectangle) + } + Defaults.Toggle(key: .showNotHumanFace) { + Text("Show cool face animation while inactive") + } + } header: { + HStack { + Text("Additional features") + } + } + } + .accentColor(.effectiveAccent) + .navigationTitle("Appearance") + } + + func checkVideoInput() -> Bool { + if AVCaptureDevice.default(for: .video) != nil { + return true + } + + return false + } +} diff --git a/boringNotch/components/Settings/Views/BatterySettingsView.swift b/boringNotch/components/Settings/Views/BatterySettingsView.swift new file mode 100644 index 00000000..2b5fef70 --- /dev/null +++ b/boringNotch/components/Settings/Views/BatterySettingsView.swift @@ -0,0 +1,43 @@ +// +// BatterySettingsView.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import Defaults +import SwiftUI + +struct Charge: View { + var body: some View { + Form { + Section { + Defaults.Toggle(key: .showBatteryIndicator) { + Text("Show battery indicator") + } + Defaults.Toggle(key: .showPowerStatusNotifications) { + Text("Show power status notifications") + } + } header: { + Text("General") + } + Section { + Defaults.Toggle(key: .showBatteryPercentage) { + Text("Show battery percentage") + } + Defaults.Toggle(key: .showPowerStatusIcons) { + Text("Show power status icons") + } + } header: { + Text("Battery Information") + } + } + .onAppear { + Task { @MainActor in + await XPCHelperClient.shared.isAccessibilityAuthorized() + } + } + .accentColor(.effectiveAccent) + .navigationTitle("Battery") + } +} diff --git a/boringNotch/components/Settings/Views/CalendarSettingsView.swift b/boringNotch/components/Settings/Views/CalendarSettingsView.swift new file mode 100644 index 00000000..62c1d994 --- /dev/null +++ b/boringNotch/components/Settings/Views/CalendarSettingsView.swift @@ -0,0 +1,135 @@ +// +// CalendarSettingsView.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import Defaults +import EventKit +import SwiftUI + +struct CalendarSettings: View { + @ObservedObject private var calendarManager = CalendarManager.shared + @Default(.showCalendar) var showCalendar: Bool + @Default(.hideCompletedReminders) var hideCompletedReminders + @Default(.hideAllDayEvents) var hideAllDayEvents + @Default(.autoScrollToNextEvent) var autoScrollToNextEvent + + var body: some View { + Form { + Defaults.Toggle(key: .showCalendar) { + Text("Show calendar") + } + Defaults.Toggle(key: .hideCompletedReminders) { + Text("Hide completed reminders") + } + Defaults.Toggle(key: .hideAllDayEvents) { + Text("Hide all-day events") + } + Defaults.Toggle(key: .autoScrollToNextEvent) { + Text("Auto-scroll to next event") + } + Defaults.Toggle(key: .showFullEventTitles) { + Text("Always show full event titles") + } + Section(header: Text("Calendars")) { + if calendarManager.calendarAuthorizationStatus != .fullAccess { + Text("Calendar access is denied. Please enable it in System Settings.") + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding() + Button("Open Calendar Settings") { + if let settingsURL = URL( + string: + "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars" + ) { + NSWorkspace.shared.open(settingsURL) + } + } + } else { + List { + ForEach(calendarManager.eventCalendars, id: \.id) { calendar in + Toggle( + isOn: Binding( + get: { calendarManager.getCalendarSelected(calendar) }, + set: { isSelected in + Task { + await calendarManager.setCalendarSelected( + calendar, isSelected: isSelected) + } + } + ) + ) { + Text(calendar.title) + } + .accentColor(lighterColor(from: calendar.color)) + .disabled(!showCalendar) + } + } + } + } + Section(header: Text("Reminders")) { + if calendarManager.reminderAuthorizationStatus != .fullAccess { + Text("Reminder access is denied. Please enable it in System Settings.") + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding() + Button("Open Reminder Settings") { + if let settingsURL = URL( + string: + "x-apple.systempreferences:com.apple.preference.security?Privacy_Reminders" + ) { + NSWorkspace.shared.open(settingsURL) + } + } + } else { + List { + ForEach(calendarManager.reminderLists, id: \.id) { calendar in + Toggle( + isOn: Binding( + get: { calendarManager.getCalendarSelected(calendar) }, + set: { isSelected in + Task { + await calendarManager.setCalendarSelected( + calendar, isSelected: isSelected) + } + } + ) + ) { + Text(calendar.title) + } + .accentColor(lighterColor(from: calendar.color)) + .disabled(!showCalendar) + } + } + } + } + } + .accentColor(.effectiveAccent) + .navigationTitle("Calendar") + .onAppear { + Task { + await calendarManager.checkCalendarAuthorization() + await calendarManager.checkReminderAuthorization() + } + } + } +} + +func lighterColor(from nsColor: NSColor, amount: CGFloat = 0.14) -> Color { + let srgb = nsColor.usingColorSpace(.sRGB) ?? nsColor + var (r, g, b, a): (CGFloat, CGFloat, CGFloat, CGFloat) = (0,0,0,0) + srgb.getRed(&r, green: &g, blue: &b, alpha: &a) + + func lighten(_ c: CGFloat) -> CGFloat { + let increased = c + (1.0 - c) * amount + return min(max(increased, 0), 1) + } + + let nr = lighten(r) + let ng = lighten(g) + let nb = lighten(b) + + return Color(red: Double(nr), green: Double(ng), blue: Double(nb), opacity: Double(a)) +} diff --git a/boringNotch/components/Settings/Views/GeneralSettingsView.swift b/boringNotch/components/Settings/Views/GeneralSettingsView.swift new file mode 100644 index 00000000..abe989bd --- /dev/null +++ b/boringNotch/components/Settings/Views/GeneralSettingsView.swift @@ -0,0 +1,237 @@ +// +// GeneralSettingsView.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import Defaults +import LaunchAtLogin +import SwiftUI + +struct GeneralSettings: View { + @State private var screens: [(uuid: String, name: String)] = NSScreen.screens.compactMap { screen in + guard let uuid = screen.displayUUID else { return nil } + return (uuid, screen.localizedName) + } + @EnvironmentObject var vm: BoringViewModel + @ObservedObject var coordinator = BoringViewCoordinator.shared + + @Default(.mirrorShape) var mirrorShape + @Default(.gestureSensitivity) var gestureSensitivity + @Default(.minimumHoverDuration) var minimumHoverDuration + @Default(.nonNotchHeight) var nonNotchHeight + @Default(.nonNotchHeightMode) var nonNotchHeightMode + @Default(.notchHeight) var notchHeight + @Default(.notchHeightMode) var notchHeightMode + @Default(.showOnAllDisplays) var showOnAllDisplays + @Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay + @Default(.enableGestures) var enableGestures + @Default(.openNotchOnHover) var openNotchOnHover + + var body: some View { + Form { + Section { + Toggle(isOn: Binding( + get: { Defaults[.menubarIcon] }, + set: { Defaults[.menubarIcon] = $0 } + )) { + Text("Show menu bar icon") + } + .tint(.effectiveAccent) + LaunchAtLogin.Toggle("Launch at login") + Defaults.Toggle(key: .showOnAllDisplays) { + Text("Show on all displays") + } + .onChange(of: showOnAllDisplays) { + NotificationCenter.default.post( + name: Notification.Name.showOnAllDisplaysChanged, object: nil) + } + Picker("Preferred display", selection: $coordinator.preferredScreenUUID) { + ForEach(screens, id: \.uuid) { screen in + Text(screen.name).tag(screen.uuid as String?) + } + } + .onChange(of: NSScreen.screens) { + screens = NSScreen.screens.compactMap { screen in + guard let uuid = screen.displayUUID else { return nil } + return (uuid, screen.localizedName) + } + } + .disabled(showOnAllDisplays) + + Defaults.Toggle(key: .automaticallySwitchDisplay) { + Text("Automatically switch displays") + } + .onChange(of: automaticallySwitchDisplay) { + NotificationCenter.default.post( + name: Notification.Name.automaticallySwitchDisplayChanged, object: nil) + } + .disabled(showOnAllDisplays) + } header: { + Text("System features") + } + + Section { + Picker( + selection: $notchHeightMode, + label: + Text("Notch height on notch displays") + ) { + Text("Match real notch height") + .tag(WindowHeightMode.matchRealNotchSize) + Text("Match menu bar height") + .tag(WindowHeightMode.matchMenuBar) + Text("Custom height") + .tag(WindowHeightMode.custom) + } + .onChange(of: notchHeightMode) { + switch notchHeightMode { + case .matchRealNotchSize: + // Get the actual notch height from the built-in display + notchHeight = getRealNotchHeight() + case .matchMenuBar: + notchHeight = 43 + case .custom: + notchHeight = 38 + } + NotificationCenter.default.post( + name: Notification.Name.notchHeightChanged, object: nil) + } + if notchHeightMode == .custom { + Slider(value: $notchHeight, in: 15...45, step: 1) { + Text("Custom notch size - \(notchHeight, specifier: "%.0f")") + } + .onChange(of: notchHeight) { + NotificationCenter.default.post( + name: Notification.Name.notchHeightChanged, object: nil) + } + } + Picker("Notch height on non-notch displays", selection: $nonNotchHeightMode) { + Text("Match menubar height") + .tag(WindowHeightMode.matchMenuBar) + Text("Custom height") + .tag(WindowHeightMode.custom) + } + .onChange(of: nonNotchHeightMode) { + switch nonNotchHeightMode { + case .matchMenuBar: + nonNotchHeight = 23 + case .matchRealNotchSize, .custom: + nonNotchHeight = 23 + } + NotificationCenter.default.post( + name: Notification.Name.notchHeightChanged, object: nil) + } + if nonNotchHeightMode == .custom { + // Custom binding to skip values 1-14 (jump from 0 to 10) + let sliderValue = Binding( + get: { + nonNotchHeight == 0 ? 0 : nonNotchHeight - 14 + }, + set: { newValue in + let oldValue = nonNotchHeight + nonNotchHeight = newValue == 0 ? 0 : newValue + 14 + if oldValue != nonNotchHeight { + NotificationCenter.default.post( + name: Notification.Name.notchHeightChanged, object: nil) + } + } + ) + + Slider(value: sliderValue, in: 0...26, step: 1) { + Text("Custom notch size - \(nonNotchHeight, specifier: "%.0f")") + } + } + } header: { + Text("Notch sizing") + } + + NotchBehaviour() + + gestureControls() + } + .toolbar { + Button("Quit app") { + NSApp.terminate(self) + } + .controlSize(.extraLarge) + } + .accentColor(.effectiveAccent) + .navigationTitle("General") + .onChange(of: openNotchOnHover) { + if !openNotchOnHover { + enableGestures = true + } + } + } + + @ViewBuilder + func gestureControls() -> some View { + Section { + Defaults.Toggle(key: .enableGestures) { + Text("Enable gestures") + } + .disabled(!openNotchOnHover) + if enableGestures { + Toggle("Change media with horizontal gestures", isOn: .constant(false)) + .disabled(true) + Defaults.Toggle(key: .closeGestureEnabled) { + Text("Close gesture") + } + Slider(value: $gestureSensitivity, in: 100...300, step: 100) { + HStack { + Text("Gesture sensitivity") + Spacer() + Text( + Defaults[.gestureSensitivity] == 100 + ? "High" : Defaults[.gestureSensitivity] == 200 ? "Medium" : "Low" + ) + .foregroundStyle(.secondary) + } + } + } + } header: { + HStack { + Text("Gesture control") + customBadge(text: "Beta") + } + } footer: { + Text( + "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" + ) + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + } + + @ViewBuilder + func NotchBehaviour() -> some View { + Section { + Defaults.Toggle(key: .openNotchOnHover) { + Text("Open notch on hover") + } + Defaults.Toggle(key: .enableHaptics) { + Text("Enable haptic feedback") + } + Toggle("Remember last tab", isOn: $coordinator.openLastTabByDefault) + if openNotchOnHover { + Slider(value: $minimumHoverDuration, in: 0...1, step: 0.1) { + HStack { + Text("Hover delay") + Spacer() + Text("\(minimumHoverDuration, specifier: "%.1f")s") + .foregroundStyle(.secondary) + } + } + .onChange(of: minimumHoverDuration) { + NotificationCenter.default.post( + name: Notification.Name.notchHeightChanged, object: nil) + } + } + } header: { + Text("Notch behavior") + } + } +} diff --git a/boringNotch/components/Settings/Views/HUDSettingsView.swift b/boringNotch/components/Settings/Views/HUDSettingsView.swift new file mode 100644 index 00000000..944006af --- /dev/null +++ b/boringNotch/components/Settings/Views/HUDSettingsView.swift @@ -0,0 +1,138 @@ +// +// HUDSettingsView.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import Defaults +import SwiftUI + +struct HUD: View { + @EnvironmentObject var vm: BoringViewModel + @Default(.inlineHUD) var inlineHUD + @Default(.enableGradient) var enableGradient + @Default(.optionKeyAction) var optionKeyAction + @Default(.hudReplacement) var hudReplacement + @ObservedObject var coordinator = BoringViewCoordinator.shared + @State private var accessibilityAuthorized = false + + var body: some View { + Form { + Section { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Replace system HUD") + .font(.headline) + Text("Replaces the standard macOS volume, display brightness, and keyboard brightness HUDs with a custom design.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 40) + Defaults.Toggle("", key: .hudReplacement) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.large) + .disabled(!accessibilityAuthorized) + } + + if !accessibilityAuthorized { + VStack(alignment: .leading, spacing: 8) { + Text("Accessibility access is required to replace the system HUD.") + .font(.subheadline) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + Button("Request Accessibility") { + XPCHelperClient.shared.requestAccessibilityAuthorization() + } + .buttonStyle(.borderedProminent) + } + } + .padding(.top, 6) + } + } + + Section { + Picker("Option key behaviour", selection: $optionKeyAction) { + ForEach(OptionKeyAction.allCases) { opt in + Text(opt.rawValue).tag(opt) + } + } + + Picker("Progress bar style", selection: $enableGradient) { + Text("Hierarchical") + .tag(false) + Text("Gradient") + .tag(true) + } + Defaults.Toggle(key: .systemEventIndicatorShadow) { + Text("Enable glowing effect") + } + Defaults.Toggle(key: .systemEventIndicatorUseAccent) { + Text("Tint progress bar with accent color") + } + } header: { + Text("General") + } + .disabled(!hudReplacement) + + Section { + Defaults.Toggle(key: .showOpenNotchHUD) { + Text("Show HUD in open notch") + } + Defaults.Toggle(key: .showOpenNotchHUDPercentage) { + Text("Show percentage") + } + .disabled(!Defaults[.showOpenNotchHUD]) + } header: { + HStack { + Text("Open Notch") + customBadge(text: "Beta") + } + } + .disabled(!hudReplacement) + + Section { + Picker("HUD style", selection: $inlineHUD) { + Text("Default") + .tag(false) + Text("Inline") + .tag(true) + } + .onChange(of: Defaults[.inlineHUD]) { + if Defaults[.inlineHUD] { + withAnimation { + Defaults[.systemEventIndicatorShadow] = false + Defaults[.enableGradient] = false + } + } + } + + Defaults.Toggle(key: .showClosedNotchHUDPercentage) { + Text("Show percentage") + } + } header: { + Text("Closed Notch") + } + .disabled(!Defaults[.hudReplacement]) + } + .accentColor(.effectiveAccent) + .navigationTitle("HUDs") + .task { + accessibilityAuthorized = await XPCHelperClient.shared.isAccessibilityAuthorized() + } + .onAppear { + XPCHelperClient.shared.startMonitoringAccessibilityAuthorization() + } + .onDisappear { + XPCHelperClient.shared.stopMonitoringAccessibilityAuthorization() + } + .onReceive(NotificationCenter.default.publisher(for: .accessibilityAuthorizationChanged)) { notification in + if let granted = notification.userInfo?["granted"] as? Bool { + accessibilityAuthorized = granted + } + } + } +} diff --git a/boringNotch/components/Settings/Views/MediaSettingsView.swift b/boringNotch/components/Settings/Views/MediaSettingsView.swift new file mode 100644 index 00000000..06263f12 --- /dev/null +++ b/boringNotch/components/Settings/Views/MediaSettingsView.swift @@ -0,0 +1,125 @@ +// +// MediaSettingsView.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import Defaults +import SwiftUI + +struct Media: View { + @Default(.waitInterval) var waitInterval + @Default(.mediaController) var mediaController + @ObservedObject var coordinator = BoringViewCoordinator.shared + @Default(.hideNotchOption) var hideNotchOption + @Default(.enableSneakPeek) private var enableSneakPeek + @Default(.sneakPeekStyles) var sneakPeekStyles + + @Default(.enableLyrics) var enableLyrics + + var body: some View { + Form { + Section { + Picker("Music Source", selection: $mediaController) { + ForEach(availableMediaControllers) { controller in + Text(controller.rawValue).tag(controller) + } + } + .onChange(of: mediaController) { _, _ in + NotificationCenter.default.post( + name: Notification.Name.mediaControllerChanged, + object: nil + ) + } + } header: { + Text("Media Source") + } footer: { + if MusicManager.shared.isNowPlayingDeprecated { + HStack { + Text("YouTube Music requires this third-party app to be installed: ") + .foregroundStyle(.secondary) + .font(.caption) + Link( + "https://github.com/pear-devs/pear-desktop", + destination: URL(string: "https://github.com/pear-devs/pear-desktop")! + ) + .font(.caption) + .foregroundColor(.blue) // Ensures it's visibly a link + } + } else { + Text( + "'Now Playing' was the only option on previous versions and works with all media apps." + ) + .foregroundStyle(.secondary) + .font(.caption) + } + } + + Section { + Toggle( + "Show music live activity", + isOn: $coordinator.musicLiveActivityEnabled.animation() + ) + Toggle("Show sneak peek on playback changes", isOn: $enableSneakPeek) + Picker("Sneak Peek Style", selection: $sneakPeekStyles) { + ForEach(SneakPeekStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + HStack { + Stepper(value: $waitInterval, in: 0...10, step: 1) { + HStack { + Text("Media inactivity timeout") + Spacer() + Text("\(Defaults[.waitInterval], specifier: "%.0f") seconds") + .foregroundStyle(.secondary) + } + } + } + Picker( + selection: $hideNotchOption, + label: + HStack { + Text("Full screen behavior") + customBadge(text: "Beta") + } + ) { + Text("Hide for all apps").tag(HideNotchOption.always) + Text("Hide for media app only").tag( + HideNotchOption.nowPlayingOnly) + Text("Never hide").tag(HideNotchOption.never) + } + } header: { + Text("Media playback live activity") + } + + Section { + MusicSlotConfigurationView() + Defaults.Toggle(key: .enableLyrics) { + HStack { + Text("Show lyrics below artist name") + customBadge(text: "Beta") + } + } + } header: { + Text("Media controls") + } footer: { + Text("Customize which controls appear in the music player. Volume expands when active.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .accentColor(.effectiveAccent) + .navigationTitle("Media") + } + + // Only show controller options that are available on this macOS version + private var availableMediaControllers: [MediaControllerType] { + if MusicManager.shared.isNowPlayingDeprecated { + return MediaControllerType.allCases.filter { $0 != .nowPlaying } + } else { + return MediaControllerType.allCases + } + } +} diff --git a/boringNotch/components/Settings/Views/SettingsHelpers.swift b/boringNotch/components/Settings/Views/SettingsHelpers.swift new file mode 100644 index 00000000..37dadbae --- /dev/null +++ b/boringNotch/components/Settings/Views/SettingsHelpers.swift @@ -0,0 +1,56 @@ +// +// SettingsHelpers.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import SwiftUI + +func proFeatureBadge() -> some View { + Text("Upgrade to Pro") + .foregroundStyle(Color(red: 0.545, green: 0.196, blue: 0.98)) + .font(.footnote.bold()) + .padding(.vertical, 3) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 4).stroke( + Color(red: 0.545, green: 0.196, blue: 0.98), lineWidth: 1)) +} + +func comingSoonTag() -> some View { + Text("Coming soon") + .foregroundStyle(.secondary) + .font(.footnote.bold()) + .padding(.vertical, 3) + .padding(.horizontal, 6) + .background(Color(nsColor: .secondarySystemFill)) + .clipShape(.capsule) +} + +func customBadge(text: String) -> some View { + Text(text) + .foregroundStyle(.secondary) + .font(.footnote.bold()) + .padding(.vertical, 3) + .padding(.horizontal, 6) + .background(Color(nsColor: .secondarySystemFill)) + .clipShape(.capsule) +} + +func warningBadge(_ text: String, _ description: String) -> some View { + Section { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 22)) + .foregroundStyle(.yellow) + VStack(alignment: .leading) { + Text(text) + .font(.headline) + Text(description) + .foregroundStyle(.secondary) + } + Spacer() + } + } +} diff --git a/boringNotch/components/Settings/Views/ShelfSettingsView.swift b/boringNotch/components/Settings/Views/ShelfSettingsView.swift new file mode 100644 index 00000000..600b7fdb --- /dev/null +++ b/boringNotch/components/Settings/Views/ShelfSettingsView.swift @@ -0,0 +1,113 @@ +// +// ShelfSettingsView.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import Defaults +import SwiftUI + +struct Shelf: View { + + @Default(.shelfTapToOpen) var shelfTapToOpen: Bool + @Default(.quickShareProvider) var quickShareProvider + @Default(.expandedDragDetection) var expandedDragDetection: Bool + @StateObject private var quickShareService = QuickShareService.shared + + private var selectedProvider: QuickShareProvider? { + quickShareService.availableProviders.first(where: { $0.id == quickShareProvider }) + } + + var body: some View { + Form { + Section { + Defaults.Toggle(key: .boringShelf) { + Text("Enable shelf") + } + Defaults.Toggle(key: .openShelfByDefault) { + Text("Open shelf by default if items are present") + } + Defaults.Toggle(key: .expandedDragDetection) { + Text("Expanded drag detection area") + } + .onChange(of: expandedDragDetection) { + NotificationCenter.default.post( + name: Notification.Name.expandedDragDetectionChanged, + object: nil + ) + } + Defaults.Toggle(key: .copyOnDrag) { + Text("Copy items on drag") + } + Defaults.Toggle(key: .autoRemoveShelfItems) { + Text("Remove from shelf after dragging") + } + + } header: { + HStack { + Text("General") + } + } + + Section { + Picker("Quick Share Service", selection: $quickShareProvider) { + ForEach(quickShareService.availableProviders, id: \.id) { provider in + HStack { + Group { + if let icon = quickShareService.icon(for: provider.id, size: 16) { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "square.and.arrow.up") + } + } + .frame(width: 16, height: 16) + .foregroundColor(.accentColor) + Text(provider.id) + } + .tag(provider.id) + } + } + .pickerStyle(.menu) + + if let selectedProvider = selectedProvider { + HStack { + Group { + if let icon = quickShareService.icon(for: selectedProvider.id, size: 16) { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "square.and.arrow.up") + } + } + .frame(width: 16, height: 16) + .foregroundColor(.accentColor) + VStack(alignment: .leading, spacing: 2) { + Text("Currently selected: \(selectedProvider.id)") + .font(.caption) + .foregroundColor(.secondary) + Text("Files dropped on the shelf will be shared via this service") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 4) + } + + } header: { + HStack { + Text("Quick Share") + } + } footer: { + Text("Choose which service to use when sharing files from the shelf. Click the shelf button to select files, or drag files onto it to share immediately.") + .font(.caption) + .foregroundColor(.secondary) + } + } + .accentColor(.effectiveAccent) + .navigationTitle("Shelf") + } +} diff --git a/boringNotch/components/Settings/Views/ShortcutsSettingsView.swift b/boringNotch/components/Settings/Views/ShortcutsSettingsView.swift new file mode 100644 index 00000000..7d39732b --- /dev/null +++ b/boringNotch/components/Settings/Views/ShortcutsSettingsView.swift @@ -0,0 +1,33 @@ +// +// ShortcutsSettingsView.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import KeyboardShortcuts +import SwiftUI + +struct Shortcuts: View { + var body: some View { + Form { + Section { + KeyboardShortcuts.Recorder("Toggle Sneak Peek:", name: .toggleSneakPeek) + } header: { + Text("Media") + } footer: { + Text( + "Sneak Peek shows the media title and artist under the notch for a few seconds." + ) + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + Section { + KeyboardShortcuts.Recorder("Toggle Notch Open:", name: .toggleNotchOpen) + } + } + .accentColor(.effectiveAccent) + .navigationTitle("Shortcuts") + } +} diff --git a/boringNotch/components/Shelf/Models/Bookmark.swift b/boringNotch/components/Shelf/Models/Bookmark.swift index 3a0bff4f..48aad8b7 100644 --- a/boringNotch/components/Shelf/Models/Bookmark.swift +++ b/boringNotch/components/Shelf/Models/Bookmark.swift @@ -54,18 +54,9 @@ struct Bookmark: Sendable, Equatable, Codable { } } - func resolveURL() -> URL? { - return resolve().url - } - - var refreshedData: Data? { - return resolve().refreshedData - } - - static func update(in items: inout [ShelfItem], for item: ShelfItem, newBookmark: Data) { - guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return } - guard case .file = items[idx].kind else { return } - items[idx].kind = ShelfItemKind.file(bookmark: newBookmark) + /// Simple URL resolution without refresh tracking. Use for read-only access. + var resolvedURL: URL? { + resolve().url } func validate() async -> Bool { @@ -77,16 +68,14 @@ struct Bookmark: Sendable, Equatable, Codable { } func withAccess(_ block: @Sendable (URL) async throws -> T) async rethrows -> T? { - let url = resolveURL() - guard let url = url else { return nil } + guard let url = resolvedURL else { return nil } return try await url.accessSecurityScopedResource { url in try await block(url) } } func withAccess(_ block: (URL) throws -> T) rethrows -> T? { - let url = resolveURL() - guard let url = url else { return nil } + guard let url = resolvedURL else { return nil } return try url.accessSecurityScopedResource { url in try block(url) } diff --git a/boringNotch/components/Shelf/Models/ShelfItem.swift b/boringNotch/components/Shelf/Models/ShelfItem.swift index 0205e1c1..ebbc6090 100644 --- a/boringNotch/components/Shelf/Models/ShelfItem.swift +++ b/boringNotch/components/Shelf/Models/ShelfItem.swift @@ -51,19 +51,19 @@ enum ShelfItemKind: Codable, Equatable, Sendable { @MainActor struct ShelfItem: Identifiable, Codable, Equatable, Sendable { let id: UUID - var kind: ShelfItemKind - var isTemporary: Bool + let kind: ShelfItemKind + let isTemporary: Bool init(id: UUID = UUID(), kind: ShelfItemKind, isTemporary: Bool = false) { self.id = id self.kind = kind self.isTemporary = isTemporary } - + var displayName: String { switch kind { case .file(let bookmarkData): let bookmark = Bookmark(data: bookmarkData) - guard let resolvedURL = bookmark.resolveURL() else { return "" } + guard let resolvedURL = bookmark.resolvedURL else { return "" } // Check for stored data files (text blocks, weblocs, etc.) to provide friendly names if resolvedURL.pathExtension.lowercased() == "json" && resolvedURL.path.contains("TextBlocks") { @@ -119,21 +119,26 @@ struct ShelfItem: Identifiable, Codable, Equatable, Sendable { } var fileURL: URL? { - guard case .file = kind else { return nil } - return ShelfStateViewModel.shared.resolveFileURL(for: self) + guard case let .file(bookmarkData) = kind else { return nil } + return Bookmark(data: bookmarkData).resolvedURL } var URL: URL? { - if case let .file(bookmark) = kind { return resolvedContext(for: bookmark)?.url } - else if case let .link(url) = kind { return url } - else { return nil } + switch kind { + case .file(let bookmarkData): + return Bookmark(data: bookmarkData).resolvedURL + case .link(let url): + return url + case .text: + return nil + } } var icon: NSImage { guard case .file = kind else { return Self.thumbnailSymbolImage(systemName: kind.iconSymbolName) ?? NSImage() } - if let resolvedURL = ShelfStateViewModel.shared.resolveFileURL(for: self) { + if let resolvedURL = fileURL { return NSWorkspace.shared.icon(forFile: resolvedURL.path) } return NSImage() @@ -141,10 +146,8 @@ struct ShelfItem: Identifiable, Codable, Equatable, Sendable { func cleanupStoredData() { - guard case let .file(bookmark) = kind, - let context = resolvedContext(for: bookmark) else { return } - - let url = context.url + guard case let .file(bookmarkData) = kind, + let url = Bookmark(data: bookmarkData).resolvedURL else { return } // Handle temporary files if isTemporary { @@ -190,11 +193,11 @@ private extension ShelfItem { extension ShelfItem { var identityKey: String { switch kind { - case .file(let bookmark): - if let url = resolvedContext(for: bookmark)?.url { + case .file(let bookmarkData): + if let url = Bookmark(data: bookmarkData).resolvedURL { return "file://" + url.standardizedFileURL.path } - return "file://missing/" + bookmark.base64EncodedString() + return "file://missing/" + bookmarkData.base64EncodedString() case .link(let u): return "link://" + u.absoluteString case .text(let s): @@ -217,12 +220,4 @@ private extension ShelfItemKind { } } -private extension ShelfItem { - func resolvedContext(for bookmarkData: Data) -> (url: URL, bookmark: Data)? { - let bookmark = Bookmark(data: bookmarkData) - if let url = bookmark.resolveURL() { - return (url, bookmark.refreshedData ?? bookmarkData) - } - return nil - } -} + diff --git a/boringNotch/components/Shelf/Services/ImageProcessingService.swift b/boringNotch/components/Shelf/Services/ImageProcessingService.swift index 7ce30874..454e6d3e 100644 --- a/boringNotch/components/Shelf/Services/ImageProcessingService.swift +++ b/boringNotch/components/Shelf/Services/ImageProcessingService.swift @@ -198,7 +198,7 @@ final class ImageProcessingService { let options: [CIImageRepresentationOption: Any] = [ CIImageRepresentationOption(rawValue: kCGImageDestinationLossyCompressionQuality as String): quality ] - return try? context.heifRepresentation(of: ciImage, format: .RGBA8, colorSpace: colorSpace, options: options) + return context.heifRepresentation(of: ciImage, format: .RGBA8, colorSpace: colorSpace, options: options) } } diff --git a/boringNotch/components/Shelf/Services/QuickLookService.swift b/boringNotch/components/Shelf/Services/QuickLookService.swift index 172fe882..89384507 100644 --- a/boringNotch/components/Shelf/Services/QuickLookService.swift +++ b/boringNotch/components/Shelf/Services/QuickLookService.swift @@ -19,7 +19,6 @@ final class QuickLookService: ObservableObject { @Published var isQuickLookOpen: Bool = false private var previewPanel: QLPreviewPanel? - private var dataSource: QuickLookDataSource? private var accessingURLs: [URL] = [] private var previewPanelObserver: Any? @@ -34,9 +33,14 @@ final class QuickLookService: ObservableObject { } self.urls = accessingURLs self.isQuickLookOpen = true - if selectFirst { - self.selectedURL = accessingURLs.first + + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) + if selectFirst { + self.selectedURL = accessingURLs.first + } } + // Observe the shared Quick Look preview panel closing so we can relinquish security scope let panel = QLPreviewPanel.shared() // Remove any existing observer for previous panel @@ -74,13 +78,9 @@ final class QuickLookService: ObservableObject { } } - func showQuickLook(urls: [URL]) { - show(urls: urls, selectFirst: true, slideshow: false) - } - func updateSelection(urls: [URL]) { guard isQuickLookOpen else { return } - show(urls: urls, selectFirst: true) + show(urls: urls, selectFirst: true) } } @@ -115,20 +115,3 @@ extension View { } } - -final class QuickLookDataSource: NSObject, QLPreviewPanelDataSource, QLPreviewPanelDelegate { - private let urls: [URL] - - init(urls: [URL]) { - self.urls = urls - super.init() - } - - nonisolated func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { - return urls.count - } - nonisolated func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> QLPreviewItem! { - guard index >= 0 && index < urls.count else { return nil } - return urls[index] as QLPreviewItem - } -} diff --git a/boringNotch/components/Shelf/Services/QuickShareService.swift b/boringNotch/components/Shelf/Services/QuickShareService.swift index b7246920..f8add27c 100644 --- a/boringNotch/components/Shelf/Services/QuickShareService.swift +++ b/boringNotch/components/Shelf/Services/QuickShareService.swift @@ -12,7 +12,6 @@ import UniformTypeIdentifiers /// Dynamic representation of a sharing provider discovered at runtime struct QuickShareProvider: Identifiable, Hashable, Sendable { var id: String - var imageData: Data? var supportsRawText: Bool } @@ -22,6 +21,7 @@ class QuickShareService: ObservableObject { @Published var availableProviders: [QuickShareProvider] = [] @Published var isPickerOpen = false private var cachedServices: [String: NSSharingService] = [:] + private var cachedIcons: [String: NSImage] = [:] // Hold security-scoped URLs during sharing private var sharingAccessingURLs: [URL] = [] private var lifecycleDelegate: SharingLifecycleDelegate? @@ -32,17 +32,48 @@ class QuickShareService: ObservableObject { } } + // MARK: - Icon Retrieval + + @MainActor + func icon(for providerId: String, size: CGFloat) -> NSImage? { + // Return cached icon if available + if let cachedIcon = cachedIcons[providerId] { + return resizedIcon(cachedIcon, to: size) + } + + // Try to get icon from cached service + if let service = cachedServices[providerId] { + cachedIcons[providerId] = service.image + return resizedIcon(service.image, to: size) + } + + // For system share menu, return a generic share icon + if providerId == "System Share Menu" { + return NSImage(systemSymbolName: "square.and.arrow.up", accessibilityDescription: "Share") + } + + return nil + } + + private func resizedIcon(_ image: NSImage, to size: CGFloat) -> NSImage { + let targetSize = NSSize(width: size, height: size) + return NSImage(size: targetSize, flipped: false) { rect in + image.draw(in: rect, + from: NSRect(origin: .zero, size: image.size), + operation: .copy, + fraction: 1.0) + return true + } + } // MARK: - Provider Discovery @MainActor func discoverAvailableProviders() async { let finder = ShareServiceFinder() - // Use simple test items without creating actual temp files - // This avoids issues with the Share Sheet retaining references to deleted files let testItems: [Any] = [ - URL(string:"http://example.com") ?? URL(fileURLWithPath: "/"), - "Test Text" as NSString + URL(string:"http://example.com")!, + "Test" as NSString ] let services = await finder.findApplicableServices(for: testItems) @@ -51,12 +82,12 @@ class QuickShareService: ObservableObject { for svc in services { let title = svc.title - let imgData = svc.image.tiffRepresentation let supportsRawText = svc.canPerform(withItems: ["Test Text"]) - let provider = QuickShareProvider(id: title, imageData: imgData, supportsRawText: supportsRawText) + let provider = QuickShareProvider(id: title, supportsRawText: supportsRawText) if !providers.contains(provider) { providers.append(provider) cachedServices[title] = svc + cachedIcons[title] = svc.image } } @@ -66,7 +97,7 @@ class QuickShareService: ObservableObject { } if !providers.contains(where: { $0.id == "System Share Menu" }) { - providers.append(QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true)) + providers.append(QuickShareProvider(id: "System Share Menu", supportsRawText: true)) } self.availableProviders = providers @@ -206,6 +237,6 @@ extension QuickShareProvider { if let airdrop = svc.availableProviders.first(where: { $0.id == "AirDrop" }) { return airdrop } - return svc.availableProviders.first ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true) + return svc.availableProviders.first ?? QuickShareProvider(id: "System Share Menu", supportsRawText: true) } } diff --git a/boringNotch/components/Shelf/Services/ShareServiceFinder.swift b/boringNotch/components/Shelf/Services/ShareServiceFinder.swift index 3d796e50..745d7981 100644 --- a/boringNotch/components/Shelf/Services/ShareServiceFinder.swift +++ b/boringNotch/components/Shelf/Services/ShareServiceFinder.swift @@ -28,6 +28,7 @@ class ShareServiceFinder: NSObject, NSSharingServicePickerDelegate { self.onServicesCaptured = { services in guard !didResume else { return } didResume = true + picker.close() continuation.resume(returning: services) } } @@ -40,6 +41,7 @@ class ShareServiceFinder: NSObject, NSSharingServicePickerDelegate { try? await Task.sleep(for: .seconds(timeout)) guard !didResume else { return } didResume = true + picker.close() // Ensure picker is closed even on timeout print("Warning: timed out waiting for sharing services") continuation.resume(returning: []) } diff --git a/boringNotch/components/Shelf/Services/ShelfActionService.swift b/boringNotch/components/Shelf/Services/ShelfActionService.swift index 0bb773bf..3339be98 100644 --- a/boringNotch/components/Shelf/Services/ShelfActionService.swift +++ b/boringNotch/components/Shelf/Services/ShelfActionService.swift @@ -14,8 +14,8 @@ enum ShelfActionService { static func open(_ item: ShelfItem) { switch item.kind { - case .file(let bookmark): - handleBookmarkedFile(bookmark) { url in + case .file(let bookmarkData): + Bookmark(data: bookmarkData).withAccess { url in NSWorkspace.shared.open(url) } case .link(let url): @@ -27,15 +27,15 @@ enum ShelfActionService { } static func reveal(_ item: ShelfItem) { - guard case .file(let bookmark) = item.kind else { return } - handleBookmarkedFile(bookmark) { url in + guard case .file(let bookmarkData) = item.kind else { return } + Bookmark(data: bookmarkData).withAccess { url in NSWorkspace.shared.activateFileViewerSelecting([url]) } } static func copyPath(_ item: ShelfItem) { - guard case .file(let bookmark) = item.kind else { return } - handleBookmarkedFile(bookmark) { url in + guard case .file(let bookmarkData) = item.kind else { return } + Bookmark(data: bookmarkData).withAccess { url in NSPasteboard.general.clearContents() NSPasteboard.general.setString(url.path, forType: .string) } @@ -44,16 +44,5 @@ enum ShelfActionService { static func remove(_ item: ShelfItem) { ShelfStateViewModel.shared.remove(item) } - - private static func handleBookmarkedFile(_ bookmarkData: Data, action: @escaping @Sendable (URL) -> Void) { - Task { - let bookmark = Bookmark(data: bookmarkData) - if let url = bookmark.resolveURL() { - url.accessSecurityScopedResource { accessibleURL in - action(accessibleURL) - } - } - } - } } diff --git a/boringNotch/components/Shelf/Services/ShelfDropService.swift b/boringNotch/components/Shelf/Services/ShelfDropService.swift index d000665e..f3773595 100644 --- a/boringNotch/components/Shelf/Services/ShelfDropService.swift +++ b/boringNotch/components/Shelf/Services/ShelfDropService.swift @@ -11,15 +11,25 @@ import UniformTypeIdentifiers struct ShelfDropService { static func items(from providers: [NSItemProvider]) async -> [ShelfItem] { - var results: [ShelfItem] = [] - - for provider in providers { - if let item = await processProvider(provider) { - results.append(item) + // Process providers concurrently for better performance with large drops + await withTaskGroup(of: ShelfItem?.self) { group in + for provider in providers { + group.addTask { + await processProvider(provider) + } } + + var results: [ShelfItem] = [] + results.reserveCapacity(providers.count) + + for await item in group { + if let item = item { + results.append(item) + } + } + + return results } - - return results } private static func processProvider(_ provider: NSItemProvider) async -> ShelfItem? { diff --git a/boringNotch/components/Shelf/Services/ShelfPersistenceService.swift b/boringNotch/components/Shelf/Services/ShelfPersistenceService.swift index f3c81cc6..6478de4a 100644 --- a/boringNotch/components/Shelf/Services/ShelfPersistenceService.swift +++ b/boringNotch/components/Shelf/Services/ShelfPersistenceService.swift @@ -78,4 +78,15 @@ final class ShelfPersistenceService { print("Failed to save shelf items: \(error.localizedDescription)") } } + + func saveAsync(_ items: [ShelfItem]) async { + await Task.detached(priority: .utility) { [fileURL, encoder] in + do { + let data = try encoder.encode(items) + try data.write(to: fileURL, options: Data.WritingOptions.atomic) + } catch { + print("Failed to save shelf items: \(error.localizedDescription)") + } + }.value + } } diff --git a/boringNotch/components/Shelf/Services/ThumbnailService.swift b/boringNotch/components/Shelf/Services/ThumbnailService.swift index 3823872f..ad2b195b 100644 --- a/boringNotch/components/Shelf/Services/ThumbnailService.swift +++ b/boringNotch/components/Shelf/Services/ThumbnailService.swift @@ -13,51 +13,56 @@ import UniformTypeIdentifiers actor ThumbnailService { static let shared = ThumbnailService() - private var cache: [String: NSImage] = [:] - private var pendingRequests: [String: Task] = [:] + // Use NSCache for automatic memory management and thread safety + private let cache = NSCache() + private var pendingRequests: [String: Task] = [:] private let thumbnailGenerator = QLThumbnailGenerator.shared + private let maxCacheSize = 100 - private init() {} + private init() { + cache.countLimit = maxCacheSize + } - func thumbnail(for url: URL, size: CGSize) async -> NSImage? { - let cacheKey = "\(url.path)_\(size.width)x\(size.height)" + func thumbnail(for url: URL, size: CGSize) async -> CGImage? { + let cacheKey = url.path as NSString - if let cached = cache[cacheKey] { - return cached + if let cachedImage = cache.object(forKey: cacheKey) { + return cachedImage } - if let pending = pendingRequests[cacheKey] { + let stringKey = cacheKey as String + if let pending = pendingRequests[stringKey] { return await pending.value } - let task = Task { - let thumbnail = await generateQuickLookThumbnail(for: url, size: size) - if let thumbnail = thumbnail { - cache[cacheKey] = thumbnail + let task = Task { + // Generate image directly + let sendableImage = await generateQuickLookThumbnail(for: url, size: size) + + if let validImage = sendableImage { + cache.setObject(validImage, forKey: cacheKey) } - pendingRequests[cacheKey] = nil - return thumbnail + + pendingRequests[stringKey] = nil + return sendableImage } - pendingRequests[cacheKey] = task + pendingRequests[stringKey] = task return await task.value } func clearCache() { - cache.removeAll() + cache.removeAllObjects() } func clearCache(for url: URL) { - cache = cache.filter { !$0.key.starts(with: url.path) } + cache.removeObject(forKey: url.path as NSString) } - // MARK: - Private Methods - - private func generateQuickLookThumbnail(for url: URL, size: CGSize) async -> NSImage? { + private func generateQuickLookThumbnail(for url: URL, size: CGSize) async -> CGImage? { let scale = await MainActor.run { NSScreen.main?.backingScaleFactor ?? 2.0 } - return await url.accessSecurityScopedResource { scopedURL in - NSLog("🔐 ThumbnailService: obtaining security scope for \(scopedURL.path)") + return await url.accessSecurityScopedResource { scopedURL -> CGImage? in let request = QLThumbnailGenerator.Request( fileAt: scopedURL, size: size, @@ -66,33 +71,9 @@ actor ThumbnailService { ) request.iconMode = true - return await withCheckedContinuation { (continuation: CheckedContinuation) in - thumbnailGenerator.generateBestRepresentation(for: request) { representation, error in - if let rep = representation { - NSLog("🔍 ThumbnailService: generated thumbnail for \(scopedURL.path)") - continuation.resume(returning: rep.nsImage) - } else { - if let err = error { - NSLog("⚠️ ThumbnailService: thumbnail error for \(scopedURL.path): \(err.localizedDescription)") - } - continuation.resume(returning: nil) - } - } - } + let representation = try? await thumbnailGenerator.generateBestRepresentation(for: request) + guard let rep = representation else { return nil } + return rep.cgImage } } } - -// MARK: - Extensions - -extension QLThumbnailRepresentation { - var nsImage: NSImage { - return NSImage(cgImage: self.cgImage, size: self.cgImage.size) - } -} - -extension CGImage { - var size: NSSize { - return NSSize(width: self.width, height: self.height) - } -} diff --git a/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift b/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift index 0dbe2322..014246b5 100644 --- a/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift +++ b/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift @@ -37,7 +37,7 @@ final class ShelfItemViewModel: ObservableObject { func loadThumbnail() async { guard let url = item.fileURL else { return } if let image = await ThumbnailService.shared.thumbnail(for: url, size: CGSize(width: 56, height: 56)) { - self.thumbnail = image + self.thumbnail = NSImage(cgImage: image, size: CGSize(width: 56, height: 56)) } } @@ -401,7 +401,7 @@ final class ShelfItemViewModel: ObservableObject { private final class MenuActionTarget: NSObject { let item: ShelfItem weak var view: NSView? - unowned let viewModel: ShelfItemViewModel + weak var viewModel: ShelfItemViewModel? // Keep associated objects (like accessory view handlers) without magic keys private static var sliderHandlerAssoc = AssociatedObject() @@ -468,7 +468,7 @@ final class ShelfItemViewModel: ObservableObject { return nil } if !urls.isEmpty { - viewModel.onQuickLookRequest?(urls) + viewModel?.onQuickLookRequest?(urls) } case "Open": @@ -476,7 +476,7 @@ final class ShelfItemViewModel: ObservableObject { for it in selected { ShelfActionService.open(it) } case "Share…": - viewModel.shareItem(from: view) + viewModel?.shareItem(from: view) case "Rename": let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) @@ -488,7 +488,7 @@ final class ShelfItemViewModel: ObservableObject { let urls = await selected.asyncCompactMap { item -> URL? in if case .file = item.kind { // Use immediate update for user-initiated menu action - return await ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) + return ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) } return nil } @@ -559,21 +559,17 @@ final class ShelfItemViewModel: ObservableObject { guard !fileURLs.isEmpty else { break } Task { - do { - // Create ZIP in a temporary location while holding access to selected resources - if let zipTempURL = try await fileURLs.accessSecurityScopedResources(accessor: { urls in - await TemporaryFileStorageService.shared.createZip(from: urls) - }) { - if let bookmark = try? Bookmark(url: zipTempURL) { - let newItem = ShelfItem(kind: .file(bookmark: bookmark.data), isTemporary: true) - ShelfStateViewModel.shared.add([newItem]) - } else { - // Fallback: reveal the temporary file in Finder - NSWorkspace.shared.activateFileViewerSelecting([zipTempURL]) - } + // Create ZIP in a temporary location while holding access to selected resources + if let zipTempURL = await fileURLs.accessSecurityScopedResources(accessor: { urls in + await TemporaryFileStorageService.shared.createZip(from: urls) + }) { + if let bookmark = try? Bookmark(url: zipTempURL) { + let newItem = ShelfItem(kind: .file(bookmark: bookmark.data), isTemporary: true) + ShelfStateViewModel.shared.add([newItem]) + } else { + // Fallback: reveal the temporary file in Finder + NSWorkspace.shared.activateFileViewerSelecting([zipTempURL]) } - } catch { - print("❌ Compress failed: \(error)") } } @@ -699,7 +695,7 @@ final class ShelfItemViewModel: ObservableObject { self.chooserDelegate = chooserDelegate self.panel = panel } - @objc func changed(_ sender: Any?) { + @MainActor @objc func changed(_ sender: Any?) { if popup?.indexOfSelectedItem == 1 { chooserDelegate?.mode = .all } else { @@ -754,7 +750,7 @@ final class ShelfItemViewModel: ObservableObject { guard case let .file(bookmarkData) = item.kind else { return } Task { let bookmark = Bookmark(data: bookmarkData) - if let fileURL = bookmark.resolveURL() { + if let fileURL = bookmark.resolvedURL { // Start security-scoped access and keep it active until rename completes. let didStart = fileURL.startAccessingSecurityScopedResource() @@ -812,7 +808,7 @@ final class ShelfItemViewModel: ObservableObject { } } catch { print("❌ Failed to remove background: \(error.localizedDescription)") - await showErrorAlert(title: "Background Removal Failed", message: error.localizedDescription) + showErrorAlert(title: "Background Removal Failed", message: error.localizedDescription) } } } @@ -842,7 +838,7 @@ final class ShelfItemViewModel: ObservableObject { } } catch { print("❌ Failed to create PDF: \(error.localizedDescription)") - await showErrorAlert(title: "PDF Creation Failed", message: error.localizedDescription) + showErrorAlert(title: "PDF Creation Failed", message: error.localizedDescription) } } } diff --git a/boringNotch/components/Shelf/ViewModels/ShelfStateViewModel.swift b/boringNotch/components/Shelf/ViewModels/ShelfStateViewModel.swift index 5cb665dd..5f950b7f 100644 --- a/boringNotch/components/Shelf/ViewModels/ShelfStateViewModel.swift +++ b/boringNotch/components/Shelf/ViewModels/ShelfStateViewModel.swift @@ -12,20 +12,29 @@ final class ShelfStateViewModel: ObservableObject { static let shared = ShelfStateViewModel() @Published private(set) var items: [ShelfItem] = [] { - didSet { ShelfPersistenceService.shared.save(items) } + didSet { schedulePersistence() } } @Published var isLoading: Bool = false var isEmpty: Bool { items.isEmpty } - // Queue for deferred bookmark updates to avoid publishing during view updates - private var pendingBookmarkUpdates: [ShelfItem.ID: Data] = [:] - private var updateTask: Task? + // Debounced persistence + private var persistenceTask: Task? + private let persistenceDelay: Duration = .seconds(1) private init() { items = ShelfPersistenceService.shared.load() } + + private func schedulePersistence() { + persistenceTask?.cancel() + persistenceTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: self?.persistenceDelay ?? .seconds(1)) + guard let self = self, !Task.isCancelled else { return } + await ShelfPersistenceService.shared.saveAsync(self.items) + } + } func add(_ newItems: [ShelfItem]) { @@ -51,28 +60,7 @@ final class ShelfStateViewModel: ObservableObject { func updateBookmark(for item: ShelfItem, bookmark: Data) { guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return } if case .file = items[idx].kind { - items[idx].kind = .file(bookmark: bookmark) - } - } - - private func scheduleDeferredBookmarkUpdate(for item: ShelfItem, bookmark: Data) { - pendingBookmarkUpdates[item.id] = bookmark - - // Cancel existing task and schedule a new one - updateTask?.cancel() - updateTask = Task { @MainActor [weak self] in - await Task.yield() - - guard let self = self else { return } - - for (itemID, bookmarkData) in self.pendingBookmarkUpdates { - if let idx = self.items.firstIndex(where: { $0.id == itemID }), - case .file = self.items[idx].kind { - self.items[idx].kind = .file(bookmark: bookmarkData) - } - } - - self.pendingBookmarkUpdates.removeAll() + items[idx] = ShelfItem(kind: .file(bookmark: bookmark), isTemporary: items[idx].isTemporary) } } @@ -111,17 +99,8 @@ final class ShelfStateViewModel: ObservableObject { } - func resolveFileURL(for item: ShelfItem) -> URL? { - guard case .file(let bookmarkData) = item.kind else { return nil } - let bookmark = Bookmark(data: bookmarkData) - let result = bookmark.resolve() - if let refreshed = result.refreshedData, refreshed != bookmarkData { - NSLog("Bookmark for \(item) stale; refreshing") - scheduleDeferredBookmarkUpdate(for: item, bookmark: refreshed) - } - return result.url - } - + /// Resolves the file URL for an item and updates the bookmark if stale. + /// Use this for user-initiated actions where bookmark refresh is desired. func resolveAndUpdateBookmark(for item: ShelfItem) -> URL? { guard case .file(let bookmarkData) = item.kind else { return nil } let bookmark = Bookmark(data: bookmarkData) @@ -134,10 +113,16 @@ final class ShelfStateViewModel: ObservableObject { } func resolveFileURLs(for items: [ShelfItem]) -> [URL] { - var urls: [URL] = [] - for it in items { - if let u = resolveFileURL(for: it) { urls.append(u) } - } - return urls + items.compactMap { $0.fileURL } + } + + @MainActor + func flushSync() { + // Cancel any scheduled persistence task (we'll save synchronously now) + persistenceTask?.cancel() + persistenceTask = nil + + // Perform a synchronous, atomic save to disk + ShelfPersistenceService.shared.save(self.items) } } diff --git a/boringNotch/components/Shelf/Views/FileShareView.swift b/boringNotch/components/Shelf/Views/FileShareView.swift index f187d8ef..14aab8e0 100644 --- a/boringNotch/components/Shelf/Views/FileShareView.swift +++ b/boringNotch/components/Shelf/Views/FileShareView.swift @@ -20,7 +20,7 @@ struct FileShareView: View { @State private var isProcessing = false private var selectedProvider: QuickShareProvider { - quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true) + quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) ?? QuickShareProvider(id: "System Share Menu", supportsRawText: true) } var body: some View { @@ -64,10 +64,9 @@ struct FileShareView: View { vm.dropZoneTargeting ? 0.11 : 0.09 )) .frame(width: 55, height: 55) - Image(systemName: "square.and.arrow.up") Group { - if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) { - Image(nsImage: nsImg) + if let icon = quickShare.icon(for: selectedProvider.id, size: 34) { + Image(nsImage: icon) .resizable() .aspectRatio(contentMode: .fit) } else { diff --git a/boringNotch/components/Shelf/Views/ShelfItemView.swift b/boringNotch/components/Shelf/Views/ShelfItemView.swift index a7d3a72e..739ae992 100644 --- a/boringNotch/components/Shelf/Views/ShelfItemView.swift +++ b/boringNotch/components/Shelf/Views/ShelfItemView.swift @@ -18,7 +18,6 @@ struct ShelfItemView: View { @StateObject private var viewModel: ShelfItemViewModel @EnvironmentObject private var quickLookService: QuickLookService @State private var showStack = false - @State private var cachedPreviewImage: NSImage? @State private var debouncedDropTarget = false private var isSelected: Bool { viewModel.isSelected } @@ -47,7 +46,6 @@ struct ShelfItemView: View { DraggableClickHandler( item: item, viewModel: viewModel, - cachedPreviewImage: $cachedPreviewImage, dragPreviewContent: { DragPreviewView(thumbnail: viewModel.thumbnail ?? item.icon, displayName: item.displayName) }, @@ -74,22 +72,11 @@ struct ShelfItemView: View { .onAppear { Task { await viewModel.loadThumbnail() - // Pre-render drag preview once on appear - if cachedPreviewImage == nil { - cachedPreviewImage = await renderDragPreview() - } } viewModel.onQuickLookRequest = { urls in quickLookService.show(urls: urls, selectFirst: true) } } - .onChange(of: viewModel.thumbnail) { _, _ in - // Invalidate cached preview when thumbnail changes - Task { - cachedPreviewImage = await renderDragPreview() - } - } - .quickLookPresenter(using: quickLookService) } // MARK: - View Components @@ -154,25 +141,12 @@ struct ShelfItemView: View { return 1 } } - - // MARK: - Drag Preview Rendering - - @MainActor - private func renderDragPreview() async -> NSImage { - let content = DragPreviewView(thumbnail: viewModel.thumbnail ?? item.icon, displayName: item.displayName) - let renderer = ImageRenderer(content: content) - renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0 - return renderer.nsImage ?? (viewModel.thumbnail ?? item.icon) - } - - } // MARK: - Draggable Click Handler with NSDraggingSource private struct DraggableClickHandler: NSViewRepresentable { let item: ShelfItem let viewModel: ShelfItemViewModel - @Binding var cachedPreviewImage: NSImage? @ViewBuilder let dragPreviewContent: () -> Content let onRightClick: (NSEvent, NSView) -> Void let onClick: (NSEvent, NSView) -> Void @@ -181,7 +155,9 @@ private struct DraggableClickHandler: NSViewRepresentable { let view = DraggableClickView() view.item = item view.viewModel = viewModel - view.dragPreviewImage = cachedPreviewImage ?? renderDragPreview() + view.getDragPreview = { + self.renderDragPreview() + } view.onRightClick = onRightClick view.onClick = onClick return view @@ -190,9 +166,9 @@ private struct DraggableClickHandler: NSViewRepresentable { func updateNSView(_ nsView: DraggableClickView, context: Context) { nsView.item = item nsView.viewModel = viewModel - // Only update preview if cached version is available - if let cached = cachedPreviewImage { - nsView.dragPreviewImage = cached + // Update the closure to capture latest state if needed, though usually content closure is enough + nsView.getDragPreview = { + self.renderDragPreview() } nsView.onRightClick = onRightClick nsView.onClick = onClick @@ -214,7 +190,7 @@ private struct DraggableClickHandler: NSViewRepresentable { final class DraggableClickView: NSView, NSDraggingSource { var item: ShelfItem! weak var viewModel: ShelfItemViewModel? - var dragPreviewImage: NSImage? + var getDragPreview: (() -> NSImage)? var onRightClick: ((NSEvent, NSView) -> Void)? var onClick: ((NSEvent, NSView) -> Void)? @@ -272,8 +248,8 @@ private struct DraggableClickHandler: NSViewRepresentable { if let pasteboardItem = createPasteboardItem(for: dragItem) { let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem) - // Use the drag preview image - let image = dragPreviewImage ?? dragItem.icon + // Use the drag preview image - generated on demand + let image = getDragPreview?() ?? dragItem.icon let imageFrame = NSRect( x: 0, y: 0, diff --git a/boringNotch/components/Shelf/Views/ShelfView.swift b/boringNotch/components/Shelf/Views/ShelfView.swift index 63c4eca3..6af023f3 100644 --- a/boringNotch/components/Shelf/Views/ShelfView.swift +++ b/boringNotch/components/Shelf/Views/ShelfView.swift @@ -94,7 +94,7 @@ struct ShelfView: View { } } else { ScrollView(.horizontal) { - HStack(spacing: spacing) { + LazyHStack(spacing: spacing) { ForEach(tvm.items) { item in ShelfItemView(item: item) .environmentObject(quickLookService) diff --git a/boringNotch/components/Webcam/WebcamView.swift b/boringNotch/components/Webcam/WebcamView.swift index d8af6dc4..2fe50e93 100644 --- a/boringNotch/components/Webcam/WebcamView.swift +++ b/boringNotch/components/Webcam/WebcamView.swift @@ -22,14 +22,14 @@ struct CameraPreviewView: View { if let previewLayer = webcamManager.previewLayer { CameraPreviewLayerView(previewLayer: previewLayer) .scaleEffect(x: -1, y: 1) - .clipShape(RoundedRectangle(cornerRadius: Defaults[.mirrorShape] == .rectangle ? !Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.closed : MusicPlayerImageSizes.cornerRadiusInset.opened : 100)) + .clipShape(RoundedRectangle(cornerRadius: Defaults[.mirrorShape] == .rectangle ? MusicPlayerImageSizes.cornerRadiusInset.opened : 100)) .frame(width: geometry.size.width, height: geometry.size.width) .opacity(webcamManager.isSessionRunning ? 1 : 0) } if !webcamManager.isSessionRunning { ZStack { - RoundedRectangle(cornerRadius: Defaults[.mirrorShape] == .rectangle ? !Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.closed : 12 : 100) + RoundedRectangle(cornerRadius: Defaults[.mirrorShape] == .rectangle ? MusicPlayerImageSizes.cornerRadiusInset.opened : 100) .fill(Color(red: 20/255, green: 20/255, blue: 20/255)) .strokeBorder(.white.opacity(0.04), lineWidth: 1) .frame(width: geometry.size.width, height: geometry.size.width) diff --git a/boringNotch/extensions/Button+Bouncing.swift b/boringNotch/extensions/Button+Bouncing.swift index 6b4d918d..f73b3d3d 100644 --- a/boringNotch/extensions/Button+Bouncing.swift +++ b/boringNotch/extensions/Button+Bouncing.swift @@ -15,7 +15,7 @@ struct BouncingButtonStyle: ButtonStyle { configuration.label .padding(12) .background( - RoundedRectangle(cornerRadius: Defaults[.cornerRadiusScaling] ? 10 : MusicPlayerImageSizes.cornerRadiusInset.closed) + RoundedRectangle(cornerRadius: 10) .fill(Color(red: 20/255, green: 20/255, blue: 20/255)) .strokeBorder(.white.opacity(0.04), lineWidth: 1) ) diff --git a/boringNotch/extensions/NSItemProvider+LoadHelpers.swift b/boringNotch/extensions/NSItemProvider+LoadHelpers.swift index c5a0dc64..86b7cbe1 100644 --- a/boringNotch/extensions/NSItemProvider+LoadHelpers.swift +++ b/boringNotch/extensions/NSItemProvider+LoadHelpers.swift @@ -125,7 +125,7 @@ extension NSItemProvider { if resolvedURL == nil { // Fallback: try treating the data as a bookmark let bookmark = Bookmark(data: data) - resolvedURL = bookmark.resolveURL() + resolvedURL = bookmark.resolvedURL } } else if let string = item as? String { if let url = URL(string: string) { diff --git a/boringNotch/extensions/PanGesture.swift b/boringNotch/extensions/PanGesture.swift index 03a6e22d..5cd5a783 100644 --- a/boringNotch/extensions/PanGesture.swift +++ b/boringNotch/extensions/PanGesture.swift @@ -55,7 +55,8 @@ private struct ScrollMonitor: NSViewRepresentable { private let direction: PanDirection private let threshold: CGFloat private let action: (CGFloat, NSEvent.Phase) -> Void - private var monitor: Any? + private var localMonitor: Any? + private var globalMonitor: Any? private var accumulated: CGFloat = 0 private var active = false private var endTask: Task? @@ -86,17 +87,46 @@ private struct ScrollMonitor: NSViewRepresentable { func installMonitor(on view: NSView) { removeMonitor() - monitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self, weak view] event in + + // Local monitor for normal in-window scroll events. + localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self, weak view] event in guard let self = self, event.window === view?.window else { return event } self.handleScroll(event) return event } + + // Global monitor to catch edge cases + globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.scrollWheel]) { [weak self, weak view] event in + guard let self = self, let view = view, let viewWindow = view.window else { return } + + // Translate event to screen coordinates. + let eventScreenPoint: NSPoint + if let eventWindow = event.window { + eventScreenPoint = eventWindow.convertToScreen(NSRect(origin: event.locationInWindow, size: .zero)).origin + } else { + eventScreenPoint = NSEvent.mouseLocation + } + + // Compute this view's frame in screen coordinates and expand it slightly. + let viewRectInWindow = view.convert(view.bounds, to: nil) + let viewRectInScreen = viewWindow.convertToScreen(viewRectInWindow) + let expansion: CGFloat = 2 // small tolerance for very-edge events + let expandedRect = viewRectInScreen.insetBy(dx: -expansion, dy: -expansion) + + if expandedRect.contains(eventScreenPoint) { + self.handleScroll(event) + } + } } func removeMonitor() { - if let monitor = monitor { - NSEvent.removeMonitor(monitor) - self.monitor = nil + if let lm = localMonitor { + NSEvent.removeMonitor(lm) + self.localMonitor = nil + } + if let gm = globalMonitor { + NSEvent.removeMonitor(gm) + self.globalMonitor = nil } accumulated = 0 active = false diff --git a/boringNotch/managers/LyricsService.swift b/boringNotch/managers/LyricsService.swift new file mode 100644 index 00000000..283a81ef --- /dev/null +++ b/boringNotch/managers/LyricsService.swift @@ -0,0 +1,311 @@ +// +// LyricsService.swift +// boringNotch +// +// Extracted from MusicManager for better separation of concerns. +// + +import AppKit +import Foundation + +/// Service responsible for fetching and parsing lyrics for the currently playing track. +@MainActor +class LyricsService: ObservableObject { + static let shared = LyricsService() + + @Published var currentLyrics: String = "" + @Published var isFetchingLyrics: Bool = false + @Published var syncedLyrics: [(time: Double, text: String)] = [] + + // Cache to avoid redundant fetches + private var lyricsCache: [String: (plain: String, synced: [(time: Double, text: String)])] = [:] + private var currentFetchTask: Task? + + private init() {} + + // MARK: - Public API + + /// Fetches lyrics for the given track, preferring native Apple Music lyrics when available. + func fetchLyrics(bundleIdentifier: String?, title: String, artist: String) async { + // Cancel any pending fetch + currentFetchTask?.cancel() + + guard !title.isEmpty else { + clearLyrics() + return + } + + // Check cache first + let cacheKey = cacheKey(title: title, artist: artist) + if let cached = lyricsCache[cacheKey] { + currentLyrics = cached.plain + syncedLyrics = cached.synced + isFetchingLyrics = false + return + } + + isFetchingLyrics = true + currentLyrics = "" + syncedLyrics = [] + + let task = Task { [weak self] in + guard let self = self else { return } + + // Try Apple Music first if applicable + if let bundleIdentifier = bundleIdentifier, bundleIdentifier.contains("com.apple.Music") { + if let lyrics = await self.fetchAppleMusicLyrics() { + guard !Task.isCancelled else { return } + await MainActor.run { + self.currentLyrics = lyrics + self.syncedLyrics = [] + self.isFetchingLyrics = false + self.lyricsCache[cacheKey] = (plain: lyrics, synced: []) + } + return + } + } + + // Fallback to web + guard !Task.isCancelled else { return } + let webResult = await self.fetchLyricsFromWeb(title: title, artist: artist) + + guard !Task.isCancelled else { return } + await MainActor.run { + self.currentLyrics = webResult.plain + self.syncedLyrics = webResult.synced + self.isFetchingLyrics = false + if !webResult.plain.isEmpty { + self.lyricsCache[cacheKey] = webResult + } + } + } + + currentFetchTask = task + await task.value + } + + /// Clears all lyrics data. + func clearLyrics() { + currentFetchTask?.cancel() + currentFetchTask = nil + currentLyrics = "" + syncedLyrics = [] + isFetchingLyrics = false + } + + /// Returns the lyric line at the given elapsed time for synced lyrics. + func lyricLine(at elapsed: Double) -> String { + guard !syncedLyrics.isEmpty else { return currentLyrics } + + // Binary search for last line with time <= elapsed + var low = 0 + var high = syncedLyrics.count - 1 + var idx = 0 + while low <= high { + let mid = (low + high) / 2 + if syncedLyrics[mid].time <= elapsed { + idx = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + return syncedLyrics[idx].text + } + + // MARK: - Private Methods + + private func cacheKey(title: String, artist: String) -> String { + "\(normalizedQuery(title))|\(normalizedQuery(artist))" + } + + private func fetchAppleMusicLyrics() async -> String? { + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") + guard !runningApps.isEmpty else { return nil } + + let script = """ + tell application "Music" + if it is running then + if player state is playing or player state is paused then + try + set l to lyrics of current track + if l is missing value then + return "" + else + return l + end if + on error + return "" + end try + else + return "" + end if + else + return "" + end if + end tell + """ + + do { + if let result = try await AppleScriptHelper.execute(script), + let lyricsString = result.stringValue, + !lyricsString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return lyricsString.trimmingCharacters(in: .whitespacesAndNewlines) + } + } catch { + // Fall through to return nil + } + return nil + } + + private func fetchLyricsFromWeb(title: String, artist: String) async -> (plain: String, synced: [(time: Double, text: String)]) { + let cleanTitle = normalizedQuery(title) + let cleanArtist = normalizedQuery(artist) + + guard let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + return ("", []) + } + + // Try with artist first, then without if no results + let searchStrategies: [String] = { + var strategies: [String] = [] + + // Strategy 1: Search with artist (if provided) + if !cleanArtist.isEmpty, + let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { + strategies.append("https://lrclib.net/api/search?track_name=\(encodedTitle)&artist_name=\(encodedArtist)") + } + + // Strategy 2: Search with title only (always include as fallback) + strategies.append("https://lrclib.net/api/search?track_name=\(encodedTitle)") + + return strategies + }() + + for urlString in searchStrategies { + guard let url = URL(string: urlString) else { continue } + + do { + var request = URLRequest(url: url) + request.timeoutInterval = 10 + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + continue + } + + if let jsonArray = try JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let first = findBestMatch(in: jsonArray, title: cleanTitle, artist: cleanArtist) { + let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if !plain.isEmpty || !synced.isEmpty { + let resolvedPlain = plain.isEmpty ? synced : plain + let parsedSynced = synced.isEmpty ? [] : parseLRC(synced) + return (resolvedPlain, parsedSynced) + } + } + } catch { + continue + } + } + + return ("", []) + } + + /// Find the best matching result from the search results based on title similarity + private func findBestMatch(in results: [[String: Any]], title: String, artist: String) -> [String: Any]? { + guard !results.isEmpty else { return nil } + + // If only one result, use it + if results.count == 1 { return results.first } + + let normalizedTitle = title.lowercased() + let normalizedArtist = artist.lowercased() + + // Score each result and pick the best + var bestResult: [String: Any]? = nil + var bestScore = 0 + + for result in results { + var score = 0 + + // Check title match + if let resultTitle = result["trackName"] as? String { + if resultTitle.lowercased() == normalizedTitle { + score += 10 + } else if resultTitle.lowercased().contains(normalizedTitle) || normalizedTitle.contains(resultTitle.lowercased()) { + score += 5 + } + } + + // Check artist match (bonus if provided and matches) + if !normalizedArtist.isEmpty, let resultArtist = result["artistName"] as? String { + if resultArtist.lowercased() == normalizedArtist { + score += 8 + } else if resultArtist.lowercased().contains(normalizedArtist) || normalizedArtist.contains(resultArtist.lowercased()) { + score += 4 + } + } + + // Prefer results with lyrics + if let plain = result["plainLyrics"] as? String, !plain.isEmpty { + score += 2 + } + if let synced = result["syncedLyrics"] as? String, !synced.isEmpty { + score += 3 + } + + if score > bestScore { + bestScore = score + bestResult = result + } + } + + return bestResult ?? results.first + } + + // MARK: - Synced lyrics helpers + + private func parseLRC(_ lrc: String) -> [(time: Double, text: String)] { + var result: [(Double, String)] = [] + let pattern = #"\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + + for lineSub in lrc.split(separator: "\n") { + let line = String(lineSub) + let nsLine = line as NSString + + guard let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: nsLine.length)) else { + continue + } + + let minStr = nsLine.substring(with: match.range(at: 1)) + let secStr = nsLine.substring(with: match.range(at: 2)) + let msRange = match.range(at: 3) + let msStr = msRange.location != NSNotFound ? nsLine.substring(with: msRange) : "0" + + let minutes = Double(minStr) ?? 0 + let seconds = Double(secStr) ?? 0 + // Handle both centiseconds (2 digits) and milliseconds (3 digits) + let msValue = Double(msStr) ?? 0 + let msDivisor = msStr.count == 3 ? 1000.0 : 100.0 + let time = minutes * 60 + seconds + msValue / msDivisor + + let textStart = match.range.location + match.range.length + let text = nsLine.substring(from: textStart).trimmingCharacters(in: .whitespaces) + if !text.isEmpty { + result.append((time, text)) + } + } + + return result.sorted { $0.0 < $1.0 } + } + + private func normalizedQuery(_ string: String) -> String { + string + .folding(options: .diacriticInsensitive, locale: .current) + .replacingOccurrences(of: "\u{FFFD}", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/boringNotch/managers/MusicManager.swift b/boringNotch/managers/MusicManager.swift index cf8730c2..93ec9075 100644 --- a/boringNotch/managers/MusicManager.swift +++ b/boringNotch/managers/MusicManager.swift @@ -48,10 +48,13 @@ class MusicManager: ObservableObject { @Published var volumeControlSupported: Bool = true @ObservedObject var coordinator = BoringViewCoordinator.shared @Published var usingAppIconForArtwork: Bool = false - @Published var currentLyrics: String = "" - @Published var isFetchingLyrics: Bool = false - @Published var syncedLyrics: [(time: Double, text: String)] = [] @Published var canFavoriteTrack: Bool = false + + // Lyrics are now managed by LyricsService + var lyricsService: LyricsService { LyricsService.shared } + var currentLyrics: String { lyricsService.currentLyrics } + var isFetchingLyrics: Bool { lyricsService.isFetchingLyrics } + var syncedLyrics: [(time: Double, text: String)] { lyricsService.syncedLyrics } @Published var isFavoriteTrack: Bool = false private var artworkData: Data? = nil @@ -343,167 +346,15 @@ class MusicManager: ObservableObject { // MARK: - Lyrics private func fetchLyricsIfAvailable(bundleIdentifier: String?, title: String, artist: String) { guard Defaults[.enableLyrics], !title.isEmpty else { - DispatchQueue.main.async { - self.isFetchingLyrics = false - self.currentLyrics = "" - } - return - } - - // Prefer native Apple Music lyrics when available - if let bundleIdentifier = bundleIdentifier, bundleIdentifier.contains("com.apple.Music") { - Task { @MainActor in - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") - guard !runningApps.isEmpty else { - await self.fetchLyricsFromWeb(title: title, artist: artist) - return - } - - self.isFetchingLyrics = true - self.currentLyrics = "" - do { - let script = """ - tell application \"Music\" - if it is running then - if player state is playing or player state is paused then - try - set l to lyrics of current track - if l is missing value then - return \"\" - else - return l - end if - on error - return \"\" - end try - else - return \"\" - end if - else - return \"\" - end if - end tell - """ - if let result = try await AppleScriptHelper.execute(script), let lyricsString = result.stringValue, !lyricsString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - self.currentLyrics = lyricsString.trimmingCharacters(in: .whitespacesAndNewlines) - self.isFetchingLyrics = false - self.syncedLyrics = [] - return - } - } catch { - // fall through to web lookup - } - await self.fetchLyricsFromWeb(title: title, artist: artist) - } - } else { Task { @MainActor in - self.isFetchingLyrics = true - self.currentLyrics = "" - await self.fetchLyricsFromWeb(title: title, artist: artist) + lyricsService.clearLyrics() } - } - } - - private func normalizedQuery(_ string: String) -> String { - string - .folding(options: .diacriticInsensitive, locale: .current) - .replacingOccurrences(of: "\u{FFFD}", with: "") - } - - @MainActor - private func fetchLyricsFromWeb(title: String, artist: String) async { - let cleanTitle = normalizedQuery(title) - let cleanArtist = normalizedQuery(artist) - guard let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), - let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { - self.currentLyrics = "" - self.isFetchingLyrics = false return } - - // LRCLIB simple search (no auth): https://lrclib.net/api/search?track_name=...&artist_name=... - let urlString = "https://lrclib.net/api/search?track_name=\(encodedTitle)&artist_name=\(encodedArtist)" - guard let url = URL(string: urlString) else { - self.currentLyrics = "" - self.isFetchingLyrics = false - return - } - do { - let (data, response) = try await URLSession.shared.data(from: url) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { - self.currentLyrics = "" - self.isFetchingLyrics = false - return - } - if let jsonArray = try JSONSerialization.jsonObject(with: data) as? [[String: Any]], - let first = jsonArray.first { - // Prefer plain lyrics (syncedLyrics may also be present) - let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let resolved = plain.isEmpty ? synced : plain - self.currentLyrics = resolved - self.isFetchingLyrics = false - if !synced.isEmpty { - self.syncedLyrics = self.parseLRC(synced) - } else { - self.syncedLyrics = [] - } - } else { - self.currentLyrics = "" - self.isFetchingLyrics = false - self.syncedLyrics = [] - } - } catch { - self.currentLyrics = "" - self.isFetchingLyrics = false - self.syncedLyrics = [] - } - } - - // MARK: - Synced lyrics helpers - private func parseLRC(_ lrc: String) -> [(time: Double, text: String)] { - var result: [(Double, String)] = [] - lrc.split(separator: "\n").forEach { lineSub in - let line = String(lineSub) - // Match [mm:ss.xx] or [m:ss] - let pattern = #"\[(\d{1,2}):(\d{2})(?:\.(\d{1,2}))?\]"# - guard let regex = try? NSRegularExpression(pattern: pattern) else { return } - let nsLine = line as NSString - if let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: nsLine.length)) { - let minStr = nsLine.substring(with: match.range(at: 1)) - let secStr = nsLine.substring(with: match.range(at: 2)) - let csRange = match.range(at: 3) - let centiStr = csRange.location != NSNotFound ? nsLine.substring(with: csRange) : "0" - let minutes = Double(minStr) ?? 0 - let seconds = Double(secStr) ?? 0 - let centis = Double(centiStr) ?? 0 - let time = minutes * 60 + seconds + centis / 100.0 - let textStart = match.range.location + match.range.length - let text = nsLine.substring(from: textStart).trimmingCharacters(in: .whitespaces) - if !text.isEmpty { - result.append((time, text)) - } - } - } - return result.sorted { $0.0 < $1.0 } - } - - func lyricLine(at elapsed: Double) -> String { - guard !syncedLyrics.isEmpty else { return currentLyrics } - // Binary search for last line with time <= elapsed - var low = 0 - var high = syncedLyrics.count - 1 - var idx = 0 - while low <= high { - let mid = (low + high) / 2 - if syncedLyrics[mid].time <= elapsed { - idx = mid - low = mid + 1 - } else { - high = mid - 1 - } + + Task { @MainActor in + await lyricsService.fetchLyrics(bundleIdentifier: bundleIdentifier, title: title, artist: artist) } - return syncedLyrics[idx].text } private func triggerFlipAnimation() { diff --git a/boringNotch/metal/visualizer.metal b/boringNotch/metal/visualizer.metal deleted file mode 100644 index 9bd88b03..00000000 --- a/boringNotch/metal/visualizer.metal +++ /dev/null @@ -1,18 +0,0 @@ -// -// visualizer.metal -// boringNotch -// -// Created by Harsh Vardhan Goswami on 28/08/24. -// - -#include -using namespace metal; - -vertex float4 vertexShader(uint vertexID [[vertex_id]], - constant float2 *vertices [[buffer(0)]]) { - return float4(vertices[vertexID], 0, 1); -} - -fragment float4 fragmentShader(constant float4 &color [[buffer(0)]]) { - return color; -} diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 8477434c..b3cf0d44 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -8,6 +8,7 @@ import SwiftUI import Defaults +// MARK: - File System Paths private let availableDirectories = FileManager .default .urls(for: .documentDirectory, in: .userDomainMask) @@ -18,13 +19,6 @@ let appVersion = "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as let temporaryDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! let spacing: CGFloat = 16 -struct CustomVisualizer: Codable, Hashable, Equatable, Defaults.Serializable { - let UUID: UUID - var name: String - var url: URL - var speed: CGFloat = 1.0 -} - enum CalendarSelectionState: Codable, Defaults.Serializable { case all case selected(Set) @@ -38,7 +32,26 @@ enum HideNotchOption: String, Defaults.Serializable { // Define notification names at file scope extension Notification.Name { + // MARK: - Media static let mediaControllerChanged = Notification.Name("mediaControllerChanged") + + // MARK: - Display + static let selectedScreenChanged = Notification.Name("SelectedScreenChanged") + static let notchHeightChanged = Notification.Name("NotchHeightChanged") + static let showOnAllDisplaysChanged = Notification.Name("showOnAllDisplaysChanged") + static let automaticallySwitchDisplayChanged = Notification.Name("automaticallySwitchDisplayChanged") + + // MARK: - Shelf + static let expandedDragDetectionChanged = Notification.Name("expandedDragDetectionChanged") + + // MARK: - System + static let accessibilityAuthorizationChanged = Notification.Name("accessibilityAuthorizationChanged") + + // MARK: - Sharing + static let sharingDidFinish = Notification.Name("com.boringNotch.sharingDidFinish") + + // MARK: - UI + static let accentColorChanged = Notification.Name("AccentColorChanged") } // Media controller types for selection in settings @@ -95,7 +108,6 @@ extension Defaults.Keys { static let hideFromScreenRecording = Key("hideFromScreenRecording", default: false) // MARK: Appearance - static let showEmojis = Key("showEmojis", default: false) //static let alwaysShowTabs = Key("alwaysShowTabs", default: true) static let showMirror = Key("showMirror", default: false) static let mirrorShape = Key("mirrorShape", default: MirrorShapeEnum.rectangle) @@ -113,9 +125,6 @@ extension Defaults.Keys { default: SliderColorEnum.white ) static let playerColorTinting = Key("playerColorTinting", default: true) - static let useMusicVisualizer = Key("useMusicVisualizer", default: true) - static let customVisualizers = Key<[CustomVisualizer]>("customVisualizers", default: []) - static let selectedVisualizer = Key("selectedVisualizer", default: nil) // MARK: Gestures static let enableGestures = Key("enableGestures", default: true) @@ -188,6 +197,7 @@ extension Defaults.Keys { static let customAccentColorData = Key("customAccentColorData", default: nil) // Show or hide the title bar static let hideTitleBar = Key("hideTitleBar", default: true) + static let hideNonNotchedFromMissionControl = Key("hideNonNotchedFromMissionControl", default: true) // Helper to determine the default media controller based on NowPlaying deprecation status static var defaultMediaController: MediaControllerType { diff --git a/boringNotch/models/SharingStateManager.swift b/boringNotch/models/SharingStateManager.swift index a4249959..5b1b3606 100644 --- a/boringNotch/models/SharingStateManager.swift +++ b/boringNotch/models/SharingStateManager.swift @@ -9,9 +9,6 @@ import AppKit import Combine import Foundation -extension Notification.Name { - static let sharingDidFinish = Notification.Name("com.boringNotch.sharingDidFinish") -} @MainActor final class SharingStateManager: ObservableObject { diff --git a/boringNotch/observers/DragDetector.swift b/boringNotch/observers/DragDetector.swift index 0b96a4d2..9eab0777 100644 --- a/boringNotch/observers/DragDetector.swift +++ b/boringNotch/observers/DragDetector.swift @@ -45,7 +45,10 @@ final class DragDetector { NSPasteboard.PasteboardType(UTType.url.identifier), .string ] - return dragPasteboard.types?.contains(where: validTypes.contains) ?? false + let isValid = dragPasteboard.pasteboardItems?.allSatisfy { item in + item.types.allSatisfy { validTypes.contains($0) } + } + return isValid ?? false } func startMonitoring() { diff --git a/boringNotch/observers/MediaKeyInterceptor.swift b/boringNotch/observers/MediaKeyInterceptor.swift index 9efbe700..e0219d9b 100644 --- a/boringNotch/observers/MediaKeyInterceptor.swift +++ b/boringNotch/observers/MediaKeyInterceptor.swift @@ -14,7 +14,7 @@ private let kSystemDefinedEventType = CGEventType(rawValue: 14)! final class MediaKeyInterceptor { static let shared = MediaKeyInterceptor() - + private enum NXKeyType: Int { case soundUp = 0 case soundDown = 1 @@ -24,35 +24,35 @@ final class MediaKeyInterceptor { case keyboardBrightnessUp = 21 case keyboardBrightnessDown = 22 } - + private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? private let step: Float = 1.0 / 16.0 private var audioPlayer: AVAudioPlayer? private init() {} - + // MARK: - Accessibility (via XPC) - + func requestAccessibilityAuthorization() { XPCHelperClient.shared.requestAccessibilityAuthorization() } - + func ensureAccessibilityAuthorization(promptIfNeeded: Bool = false) async -> Bool { await XPCHelperClient.shared.ensureAccessibilityAuthorization(promptIfNeeded: promptIfNeeded) } - + // MARK: - Event Tap func start(promptIfNeeded: Bool = false) async { guard eventTap == nil else { return } - + // Ensure HUD replacement is enabled guard Defaults[.hudReplacement] else { stop() return } - + // Check accessibility authorization let authorized = await XPCHelperClient.shared.isAccessibilityAuthorized() if !authorized { @@ -63,7 +63,7 @@ final class MediaKeyInterceptor { return } } - + let mask = CGEventMask(1 << kSystemDefinedEventType.rawValue) eventTap = CGEvent.tapCreate( tap: .cghidEventTap, @@ -86,61 +86,64 @@ final class MediaKeyInterceptor { CGEvent.tapEnable(tap: eventTap, enable: true) } } - + func stop() { if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: false) + CFMachPortInvalidate(eventTap) } if let runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) } + runLoopSource = nil eventTap = nil } - + // MARK: - Event Handling - + private func handleEvent(_ cgEvent: CGEvent) -> Unmanaged? { // Ensure the CGEvent has a valid type before converting to NSEvent guard cgEvent.type != .null else { - return Unmanaged.passRetained(cgEvent) + return Unmanaged.passUnretained(cgEvent) } + guard let nsEvent = NSEvent(cgEvent: cgEvent), nsEvent.type == .systemDefined, nsEvent.subtype.rawValue == 8 else { - return Unmanaged.passRetained(cgEvent) + return Unmanaged.passUnretained(cgEvent) } - + let data1 = nsEvent.data1 let keyCode = (data1 & 0xFFFF_0000) >> 16 let stateByte = ((data1 & 0xFF00) >> 8) - + // 0xA = key down, 0xB = key up. Only handle key down. guard stateByte == 0xA, let keyType = NXKeyType(rawValue: keyCode) else { - return Unmanaged.passRetained(cgEvent) + return Unmanaged.passUnretained(cgEvent) } - + let flags = nsEvent.modifierFlags let option = flags.contains(.option) let shift = flags.contains(.shift) let command = flags.contains(.command) - + // Handle option key action (without shift) if option && !shift { if handleOptionAction(for: keyType, command: command) { return nil } } - + // Handle normal key press handleKeyPress(keyType: keyType, option: option, shift: shift, command: command) return nil } - + private func handleOptionAction(for keyType: NXKeyType, command: Bool) -> Bool { let action = Defaults[.optionKeyAction] - + switch action { case .openSettings: openSystemSettings(for: keyType, command: command) @@ -152,7 +155,7 @@ final class MediaKeyInterceptor { return true } } - + private func prepareAudioPlayerIfNeeded() { guard audioPlayer == nil else { return } @@ -198,7 +201,7 @@ final class MediaKeyInterceptor { private func handleKeyPress(keyType: NXKeyType, option: Bool, shift: Bool, command: Bool) { let stepDivisor: Float = (option && shift) ? 4.0 : 1.0 - + switch keyType { case .soundUp: Task { @MainActor in @@ -222,7 +225,7 @@ final class MediaKeyInterceptor { adjustBrightness(delta: delta, keyboard: keyType == .keyboardBrightnessDown || command) } } - + private func adjustBrightness(delta: Float, keyboard: Bool) { Task { @MainActor in if keyboard { @@ -232,7 +235,7 @@ final class MediaKeyInterceptor { } } } - + private func showHUD(for keyType: NXKeyType, command: Bool) { Task { @MainActor in switch keyType { @@ -253,10 +256,10 @@ final class MediaKeyInterceptor { } } } - + private func openSystemSettings(for keyType: NXKeyType, command: Bool) { let urlString: String - + switch keyType { case .soundUp, .soundDown, .mute: urlString = "x-apple.systempreferences:com.apple.preference.sound" @@ -269,8 +272,8 @@ final class MediaKeyInterceptor { case .keyboardBrightnessUp, .keyboardBrightnessDown: urlString = "x-apple.systempreferences:com.apple.preference.keyboard" } - + guard let url = URL(string: urlString) else { return } NSWorkspace.shared.open(url) } -} +} \ No newline at end of file diff --git a/boringNotch/sizing/matters.swift b/boringNotch/sizing/matters.swift index c79695d5..f5a792d5 100644 --- a/boringNotch/sizing/matters.swift +++ b/boringNotch/sizing/matters.swift @@ -36,6 +36,48 @@ enum MusicPlayerImageSizes { return nil } +@MainActor func getRealNotchHeight() -> CGFloat { + for screen in NSScreen.screens { + let safeAreaTop = screen.safeAreaInsets.top + if safeAreaTop > 0 { + return safeAreaTop + } + } + + return 38 +} + +@MainActor func getMenuBarHeight() -> CGFloat { + for screen in NSScreen.screens { + if screen.safeAreaInsets.top > 0 { + return screen.frame.maxY - screen.visibleFrame.maxY - 1 + } + } + + return 43 +} + +@MainActor func syncNotchHeightIfNeeded() { + switch Defaults[.notchHeightMode] { + case .matchRealNotchSize: + let realHeight = getRealNotchHeight() + if Defaults[.notchHeight] != realHeight { + Defaults[.notchHeight] = realHeight + NotificationCenter.default.post(name: .notchHeightChanged, object: nil) + } + + case .matchMenuBar: + let menuHeight = getMenuBarHeight() + if Defaults[.notchHeight] != menuHeight { + Defaults[.notchHeight] = menuHeight + NotificationCenter.default.post(name: .notchHeightChanged, object: nil) + } + + case .custom: + break + } +} + @MainActor func getClosedNotchSize(screenUUID: String? = nil) -> CGSize { // Default notch size, to avoid using optionals var notchHeight: CGFloat = Defaults[.nonNotchHeight] @@ -55,23 +97,7 @@ enum MusicPlayerImageSizes { { notchWidth = screen.frame.width - topLeftNotchpadding - topRightNotchpadding + 4 } - - // Check if the Mac has a notch - if screen.safeAreaInsets.top > 0 { - // This is a display WITH a notch - use notch height settings - notchHeight = Defaults[.notchHeight] - if Defaults[.notchHeightMode] == .matchRealNotchSize { - notchHeight = screen.safeAreaInsets.top - } else if Defaults[.notchHeightMode] == .matchMenuBar { - notchHeight = screen.frame.maxY - screen.visibleFrame.maxY - } - } else { - // This is a display WITHOUT a notch - use non-notch height settings - notchHeight = Defaults[.nonNotchHeight] - if Defaults[.nonNotchHeightMode] == .matchMenuBar { - notchHeight = screen.frame.maxY - screen.visibleFrame.maxY - } - } + notchHeight = screen.safeAreaInsets.top > 0 ? Defaults[.notchHeight] : Defaults[.nonNotchHeight] } return .init(width: notchWidth, height: notchHeight)