diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..1925113 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64e1baa --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# Xcode +# +build/ +DerivedData/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ +*.xcscmblueprint + +# SwiftPM +# +.build/ +Package.resolved + +# CocoaPods +# +Pods/ +Podfile.lock + +# Carthage +# +Carthage/Build/ + +# Fastlane +# +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output + +# Archives +# +*.xcarchive + +# App data +# +*.ipa +*.dSYM.zip +*.dSYM + +# Playgrounds +# +timeline.xctimeline +playground.xcworkspace + +# Xcode Server +# +integration.json + +# Other +# +*.moved-aside +*.xcuserstate + +# GitHub instructions and agent files +# +.github/ +rough_note.md \ No newline at end of file diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Contents.json b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db4..519bc19 100644 --- a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,51 +1,61 @@ { "images" : [ { + "filename" : "notch-stage-icon2 2.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { + "filename" : "notch-stage-icon2 5.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { + "filename" : "notch-stage-icon2 6.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { + "filename" : "notch-stage-icon2 11.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { + "filename" : "notch-stage-icon2 12.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { + "filename" : "notch-stage-icon2 13.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { + "filename" : "notch-stage-icon2 7.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { + "filename" : "notch-stage-icon2 8.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { + "filename" : "notch-stage-icon2 9.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { + "filename" : "notch-stage-icon2 10.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 10.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 10.png new file mode 100644 index 0000000..f114f25 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 10.png differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 11.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 11.png new file mode 100644 index 0000000..166997a Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 11.png differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 12.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 12.png new file mode 100644 index 0000000..e0ab74d Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 12.png differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 13.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 13.png new file mode 100644 index 0000000..c2d2b43 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 13.png differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 2.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 2.png new file mode 100644 index 0000000..2b897b3 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 2.png differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 5.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 5.png new file mode 100644 index 0000000..394b128 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 5.png differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 6.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 6.png new file mode 100644 index 0000000..394b128 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 6.png differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 7.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 7.png new file mode 100644 index 0000000..c2d2b43 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 7.png differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 8.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 8.png new file mode 100644 index 0000000..770e71c Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 8.png differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 9.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 9.png new file mode 100644 index 0000000..770e71c Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 9.png differ diff --git a/DynamicIsland/Assets.xcassets/Github.imageset/Contents.json b/DynamicIsland/Assets.xcassets/Github.imageset/Contents.json new file mode 100644 index 0000000..d750f10 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/Github.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "GitHub Mark White.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "GitHub Mark White 1.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "GitHub Mark White 2.svg", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/Github.imageset/GitHub Mark White 1.svg b/DynamicIsland/Assets.xcassets/Github.imageset/GitHub Mark White 1.svg new file mode 100644 index 0000000..d5e6491 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/Github.imageset/GitHub Mark White 1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/DynamicIsland/Assets.xcassets/Github.imageset/GitHub Mark White 2.svg b/DynamicIsland/Assets.xcassets/Github.imageset/GitHub Mark White 2.svg new file mode 100644 index 0000000..d5e6491 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/Github.imageset/GitHub Mark White 2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/DynamicIsland/Assets.xcassets/Github.imageset/GitHub Mark White.svg b/DynamicIsland/Assets.xcassets/Github.imageset/GitHub Mark White.svg new file mode 100644 index 0000000..d5e6491 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/Github.imageset/GitHub Mark White.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/DynamicIsland/Assets.xcassets/bolt.imageset/Contents.json b/DynamicIsland/Assets.xcassets/bolt.imageset/Contents.json new file mode 100644 index 0000000..6909fcc --- /dev/null +++ b/DynamicIsland/Assets.xcassets/bolt.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bolt.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/bolt.imageset/bolt.png b/DynamicIsland/Assets.xcassets/bolt.imageset/bolt.png new file mode 100644 index 0000000..9544fe6 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/bolt.imageset/bolt.png differ diff --git a/DynamicIsland/Assets.xcassets/chrome.imageset/Contents.json b/DynamicIsland/Assets.xcassets/chrome.imageset/Contents.json new file mode 100644 index 0000000..f0f188f --- /dev/null +++ b/DynamicIsland/Assets.xcassets/chrome.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "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/DynamicIsland/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 1.png b/DynamicIsland/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 1.png new file mode 100644 index 0000000..1cce1cd Binary files /dev/null and b/DynamicIsland/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 1.png differ diff --git a/DynamicIsland/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 2.png b/DynamicIsland/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 2.png new file mode 100644 index 0000000..1cce1cd Binary files /dev/null and b/DynamicIsland/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 2.png differ diff --git a/DynamicIsland/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon.png b/DynamicIsland/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon.png new file mode 100644 index 0000000..1cce1cd Binary files /dev/null and b/DynamicIsland/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon.png differ diff --git a/DynamicIsland/Assets.xcassets/defaultmusic.imageset/Contents.json b/DynamicIsland/Assets.xcassets/defaultmusic.imageset/Contents.json new file mode 100644 index 0000000..a19a549 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/defaultmusic.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/dynamicisland.imageset/Contents.json b/DynamicIsland/Assets.xcassets/dynamicisland.imageset/Contents.json new file mode 100644 index 0000000..d6b705b --- /dev/null +++ b/DynamicIsland/Assets.xcassets/dynamicisland.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "TheBoringTeam.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/dynamicisland.imageset/TheBoringTeam.svg b/DynamicIsland/Assets.xcassets/dynamicisland.imageset/TheBoringTeam.svg new file mode 100644 index 0000000..032a961 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/dynamicisland.imageset/TheBoringTeam.svg @@ -0,0 +1,3 @@ + + + diff --git a/DynamicIsland/Assets.xcassets/logo.imageset/256-mac 1.png b/DynamicIsland/Assets.xcassets/logo.imageset/256-mac 1.png new file mode 100644 index 0000000..3bc82b0 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/logo.imageset/256-mac 1.png differ diff --git a/DynamicIsland/Assets.xcassets/logo.imageset/Contents.json b/DynamicIsland/Assets.xcassets/logo.imageset/Contents.json new file mode 100644 index 0000000..e05b766 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "256-mac 1.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/logo2.imageset/BoringNotch icon.png b/DynamicIsland/Assets.xcassets/logo2.imageset/BoringNotch icon.png new file mode 100644 index 0000000..d233e5f Binary files /dev/null and b/DynamicIsland/Assets.xcassets/logo2.imageset/BoringNotch icon.png differ diff --git a/DynamicIsland/Assets.xcassets/logo2.imageset/Contents.json b/DynamicIsland/Assets.xcassets/logo2.imageset/Contents.json new file mode 100644 index 0000000..37d84e3 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/logo2.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "BoringNotch icon.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/plug.imageset/Contents.json b/DynamicIsland/Assets.xcassets/plug.imageset/Contents.json new file mode 100644 index 0000000..7356dce --- /dev/null +++ b/DynamicIsland/Assets.xcassets/plug.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "plug.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/plug.imageset/plug.png b/DynamicIsland/Assets.xcassets/plug.imageset/plug.png new file mode 100644 index 0000000..c4ddd2a Binary files /dev/null and b/DynamicIsland/Assets.xcassets/plug.imageset/plug.png differ diff --git a/DynamicIsland/Assets.xcassets/sparkle.imageset/Contents.json b/DynamicIsland/Assets.xcassets/sparkle.imageset/Contents.json new file mode 100644 index 0000000..138a4dc --- /dev/null +++ b/DynamicIsland/Assets.xcassets/sparkle.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "sparkle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/sparkle.imageset/sparkle.svg b/DynamicIsland/Assets.xcassets/sparkle.imageset/sparkle.svg new file mode 100644 index 0000000..5a4a86e --- /dev/null +++ b/DynamicIsland/Assets.xcassets/sparkle.imageset/sparkle.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/DynamicIsland/Assets.xcassets/spotlight.imageset/Contents.json b/DynamicIsland/Assets.xcassets/spotlight.imageset/Contents.json new file mode 100644 index 0000000..896a107 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/spotlight.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "spotlight.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/spotlight.imageset/spotlight.svg b/DynamicIsland/Assets.xcassets/spotlight.imageset/spotlight.svg new file mode 100644 index 0000000..272ceec --- /dev/null +++ b/DynamicIsland/Assets.xcassets/spotlight.imageset/spotlight.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 3810b35..db43dfe 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -1,24 +1,521 @@ // // ContentView.swift -// DynamicIsland +// boringNotchApp // -// Created by admin63 on 01/11/24. +// Created by Harsh Vardhan Goswami on 02/08/24 +// Modified by Richard Kunkli on 24/08/2024. // +import AVFoundation +import Combine +import Defaults +import KeyboardShortcuts import SwiftUI +import SwiftUIIntrospect struct ContentView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @StateObject var webcamManager: WebcamManager = .init() + + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @ObservedObject var musicManager = MusicManager.shared + @ObservedObject var batteryModel = BatteryStatusViewModel.shared + + @State private var isHovering: Bool = false + @State private var hoverWorkItem: DispatchWorkItem? + @State private var debounceWorkItem: DispatchWorkItem? + + @State private var isHoverStateChanging: Bool = false + + @State private var gestureProgress: CGFloat = .zero + + @State private var haptics: Bool = false + + @Namespace var albumArtNamespace + + @Default(.useMusicVisualizer) var useMusicVisualizer + + @Default(.showNotHumanFace) var showNotHumanFace + @Default(.useModernCloseAnimation) var useModernCloseAnimation + + private let extendedHoverPadding: CGFloat = 30 + private let zeroHeightHoverPadding: CGFloat = 10 + var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") + ZStack(alignment: .top) { + let mainLayout = NotchLayout() + .frame(alignment: .top) + .padding(.horizontal, vm.notchState == .open + ? Defaults[.cornerRadiusScaling] ? (cornerRadiusInsets.opened.top - 5) : (cornerRadiusInsets.opened.bottom - 5) + : cornerRadiusInsets.closed.bottom + ) + .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) + .background(.black) + .mask { + ((vm.notchState == .open) && Defaults[.cornerRadiusScaling]) + ? NotchShape(topCornerRadius: cornerRadiusInsets.opened.top, bottomCornerRadius: cornerRadiusInsets.opened.bottom) + .drawingGroup() + : NotchShape(topCornerRadius: cornerRadiusInsets.closed.top, bottomCornerRadius: cornerRadiusInsets.closed.bottom) + .drawingGroup() + } + .padding(.bottom, vm.notchState == .open && Defaults[.extendHoverArea] ? 0 : (vm.effectiveClosedNotchHeight == 0) + ? zeroHeightHoverPadding + : 0 + ) + + mainLayout + .conditionalModifier(!useModernCloseAnimation) { view in + let hoverAnimationAnimation = Animation.bouncy.speed(1.2) + let notchStateAnimation = Animation.spring.speed(1.2) + return view + .animation(hoverAnimationAnimation, value: isHovering) + .animation(notchStateAnimation, value: vm.notchState) + .animation(.smooth, value: gestureProgress) + .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) + } + .conditionalModifier(useModernCloseAnimation) { view in + let hoverAnimationAnimation = Animation.bouncy.speed(1.2) + let notchStateAnimation = Animation.spring.speed(1.2) + return view + .animation(hoverAnimationAnimation, value: isHovering) + .animation(notchStateAnimation, value: vm.notchState) + } + .conditionalModifier(Defaults[.openNotchOnHover]) { view in + view.onHover { hovering in + handleHover(hovering) + } + } + .conditionalModifier(!Defaults[.openNotchOnHover]) { view in + view + .onHover { hovering in + withAnimation(vm.animation) { + isHovering = hovering + } + + // Only close if mouse leaves and the notch is open + if !hovering && vm.notchState == .open { + vm.close() + } + } + .onTapGesture { + if (vm.notchState == .closed) && Defaults[.enableHaptics] { + haptics.toggle() + } + doOpen() + } + .conditionalModifier(Defaults[.enableGestures]) { view in + view + .panGesture(direction: .down) { translation, phase in + handleDownGesture(translation: translation, phase: phase) + } + } + } + .conditionalModifier(Defaults[.closeGestureEnabled] && Defaults[.enableGestures]) { view in + view + .panGesture(direction: .up) { translation, phase in + handleUpGesture(translation: translation, phase: phase) + } + } + .onAppear(perform: { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + withAnimation(vm.animation) { + if coordinator.firstLaunch { + doOpen() + } + } + } + }) + .onChange(of: vm.notchState) { _, newState in + // Reset hover state when notch state changes + if newState == .closed && isHovering { + // Only reset visually, without triggering the hover logic again + isHoverStateChanging = true + withAnimation { + isHovering = false + } + // Reset the flag after the animation completes + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isHoverStateChanging = false + } + } + } + .onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if !newPopoverState && !isHovering && vm.notchState == .open { + vm.close() + } + } + } + .sensoryFeedback(.alignment, trigger: haptics) + .contextMenu { + SettingsLink(label: { + Text("Settings") + }) + .keyboardShortcut(KeyEquivalent(","), modifiers: .command) +// Button("Edit") { // Doesnt work.... +// let dn = DynamicNotch(content: EditPanelView()) +// dn.toggle() +// } +// #if DEBUG +// .disabled(false) +// #else +// .disabled(true) +// #endif +// .keyboardShortcut("E", modifiers: .command) + } + } + .frame(maxWidth: openNotchSize.width, maxHeight: openNotchSize.height, alignment: .top) + .shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5) + .background(dragDetector) + .environmentObject(vm) + .environmentObject(webcamManager) + } + + @ViewBuilder + func NotchLayout() -> some View { + VStack(alignment: .leading) { + VStack(alignment: .leading) { + if coordinator.firstLaunch { + Spacer() + HelloAnimation().frame(width: 200, height: 80).onAppear(perform: { + vm.closeHello() + }) + .padding(.top, 40) + Spacer() + } else { + if coordinator.expandingView.type == .battery && coordinator.expandingView.show && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] { + HStack(spacing: 0) { + HStack { + Text(batteryModel.statusText) + .font(.subheadline) + } + + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width + 10) + + HStack { + DynamicIslandBatteryView( + batteryWidth: 30, + isCharging: batteryModel.isCharging, + isInLowPowerMode: batteryModel.isInLowPowerMode, + isPluggedIn: batteryModel.isPluggedIn, + levelBattery: batteryModel.levelBattery, + isForNotification: true + ) + } + .frame(width: 76, alignment: .trailing) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) + } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) { + InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) + .transition(.opacity) + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { + MusicLiveActivity() + } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { + DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) + } else if vm.notchState == .open { + DynamicIslandHeader() + .frame(height: max(24, vm.effectiveClosedNotchHeight)) + .blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0) + .animation(.spring(response: 1, dampingFraction: 1, blendDuration: 0.8), value: vm.notchState) + } else { + Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: vm.effectiveClosedNotchHeight) + } + + if coordinator.sneakPeek.show { + if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && !Defaults[.inlineHUD] { + SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in + // + }) + .padding(.bottom, 10) + .padding(.leading, 4) + .padding(.trailing, 8) + } + // Old sneak peek music + else if coordinator.sneakPeek.type == .music { + if vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { + HStack(alignment: .center) { + Image(systemName: "music.note") + GeometryReader { geo in + MarqueeText(.constant(musicManager.songTitle + " - " + musicManager.artistName), textColor: .gray, minDuration: 1, frameWidth: geo.size.width) + } + } + .foregroundStyle(.gray) + .padding(.bottom, 10) + } + } + } + } + } + .conditionalModifier((coordinator.sneakPeek.show && (coordinator.sneakPeek.type == .music) && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.sneakPeek.show && (coordinator.sneakPeek.type != .music) && (vm.notchState == .closed))) { view in + view + .fixedSize() + } + .zIndex(2) + + ZStack { + if vm.notchState == .open { + switch coordinator.currentView { + case .home: + NotchHomeView(albumArtNamespace: albumArtNamespace) + case .shelf: + NotchShelfView() + } + } + } + .zIndex(1) + .allowsHitTesting(vm.notchState == .open) + .blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0) + .opacity(abs(gestureProgress) > 0.3 ? min(abs(gestureProgress * 2), 0.8) : 1) + } + } + + @ViewBuilder + func DynamicIslandFaceAnimation() -> some View { + HStack { + HStack { + Rectangle() + .fill(.clear) + .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width - 20) + MinimalFaceFeatures() + } + }.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) + } + + @ViewBuilder + func MusicLiveActivity() -> some View { + HStack { + HStack { + Color.clear + .aspectRatio(1, contentMode: .fit) + .background( + Image(nsImage: musicManager.albumArt) + .resizable() + .aspectRatio(contentMode: .fill) + ) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed)) + .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) + .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) + } + .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))) + + Rectangle() + .fill(.black) + .overlay( + HStack(alignment: .top){ + if(coordinator.expandingView.show && coordinator.expandingView.type == .music) { + MarqueeText( + .constant(musicManager.songTitle), + textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, + minDuration: 0.4, + frameWidth: 100 + ) + .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) + Spacer(minLength: vm.closedNotchSize.width) + // Song Artist + Text(musicManager.artistName) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray) + .opacity((coordinator.expandingView.show && coordinator.expandingView.type == .music && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) + } + } + ) + .frame(width: (coordinator.expandingView.show && coordinator.expandingView.type == .music && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : vm.closedNotchSize.width + (isHovering ? 8 : 0)) + + + 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) + } + .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), + height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) + } else { + LottieAnimationView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), + height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) + } + + @ViewBuilder + var dragDetector: some View { + if Defaults[.dynamicShelf] { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .onDrop(of: [.data], isTargeted: $vm.dragDetectorTargeting) { _ in true } + .onChange(of: vm.anyDropZoneTargeting) { _, isTargeted in + if isTargeted, vm.notchState == .closed { + coordinator.currentView = .shelf + doOpen() + } else if !isTargeted { + print("DROP EVENT", vm.dropEvent) + if vm.dropEvent { + vm.dropEvent = false + return + } + + vm.dropEvent = false + vm.close() + } + } + } else { + EmptyView() + } + } + + private func doOpen() { + withAnimation(.bouncy.speed(1.2)) { + vm.open() + } + } + + // MARK: - Hover Management + + /// Handle hover state changes with debouncing + private func handleHover(_ hovering: Bool) { + // Don't process events if we're already transitioning + if isHoverStateChanging { return } + + // Cancel any pending tasks + hoverWorkItem?.cancel() + hoverWorkItem = nil + debounceWorkItem?.cancel() + debounceWorkItem = nil + + if hovering { + // Handle mouse enter + withAnimation(.bouncy.speed(1.2)) { + isHovering = true + } + + // Only provide haptic feedback if notch is closed + if vm.notchState == .closed && Defaults[.enableHaptics] { + haptics.toggle() + } + + // Don't open notch if there's a sneak peek showing + if coordinator.sneakPeek.show { + return + } + + // Delay opening the notch + let task = DispatchWorkItem { + // ContentView is a struct, so we don't use weak self here + guard vm.notchState == .closed, isHovering else { return } + doOpen() + } + + hoverWorkItem = task + DispatchQueue.main.asyncAfter( + deadline: .now() + Defaults[.minimumHoverDuration], + execute: task + ) + } else { + // Handle mouse exit with debounce to prevent flickering + let debounce = DispatchWorkItem { + // ContentView is a struct, so we don't use weak self here + + // Update visual state + withAnimation(.bouncy.speed(1.2)) { + isHovering = false + } + + // Close the notch if it's open and battery popover is not active + if vm.notchState == .open && !vm.isBatteryPopoverActive { + vm.close() + } + } + + debounceWorkItem = debounce + // Add a small delay to debounce rapid mouse movements + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: debounce) + } + } + + // MARK: - Gesture Handling + + private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) { + guard vm.notchState == .closed else { return } + + withAnimation(.smooth) { + gestureProgress = (translation / Defaults[.gestureSensitivity]) * 20 + } + + if phase == .ended { + withAnimation(.smooth) { + gestureProgress = .zero + } + } + + if translation > Defaults[.gestureSensitivity] { + if Defaults[.enableHaptics] { + haptics.toggle() + } + withAnimation(.smooth) { + gestureProgress = .zero + } + doOpen() + } + } + + private func handleUpGesture(translation: CGFloat, phase: NSEvent.Phase) { + if vm.notchState == .open && !vm.isHoveringCalendar { + withAnimation(.smooth) { + gestureProgress = (translation / Defaults[.gestureSensitivity]) * -20 + } + + if phase == .ended { + withAnimation(.smooth) { + gestureProgress = .zero + } + } + + if translation > Defaults[.gestureSensitivity] { + withAnimation(.smooth) { + gestureProgress = .zero + isHovering = false + } + vm.close() + + if Defaults[.enableHaptics] { + haptics.toggle() + } + } } - .padding() } } -#Preview { - ContentView() +struct FullScreenDropDelegate: DropDelegate { + @Binding var isTargeted: Bool + let onDrop: () -> Void + + func dropEntered(info _: DropInfo) { + isTargeted = true + } + + func dropExited(info _: DropInfo) { + isTargeted = false + } + + func performDrop(info _: DropInfo) -> Bool { + isTargeted = false + onDrop() + return true + } } diff --git a/DynamicIsland/DynamicIsland.entitlements b/DynamicIsland/DynamicIsland.entitlements index 8d636b5..6d10c93 100644 --- a/DynamicIsland/DynamicIsland.entitlements +++ b/DynamicIsland/DynamicIsland.entitlements @@ -2,24 +2,29 @@ - com.apple.security.app-sandbox - - com.apple.security.automation.apple-events - - com.apple.security.device.camera - - com.apple.security.files.user-selected.read-write - - com.apple.security.network.client - - com.apple.security.network.server - - com.apple.security.personal-information.calendars - - com.apple.security.temporary-exception.mach-lookup.global-name - - $(PRODUCT_BUNDLE_IDENTIFIER)-spks - $(PRODUCT_BUNDLE_IDENTIFIER)-spki - + com.apple.security.temporary-exception.apple-events + + com.spotify.client + com.apple.Music + + com.apple.security.app-sandbox + + com.apple.security.automation.apple-events + + com.apple.security.device.camera + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.personal-information.calendars + + com.apple.security.temporary-exception.mach-lookup.global-name + + $(PRODUCT_BUNDLE_IDENTIFIER)-spks + $(PRODUCT_BUNDLE_IDENTIFIER)-spki + diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 9a8db69..cc1052c 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -2,16 +2,398 @@ // DynamicIslandApp.swift // DynamicIsland // -// Created by admin63 on 01/11/24. +// Created by Harsh Vardhan Goswami on 02/08/24. // +import AVFoundation +import Combine +import KeyboardShortcuts +import Sparkle import SwiftUI +import Defaults @main -struct DynamicIslandApp: App { +struct DynamicNotchApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @Default(.menubarIcon) var showMenuBarIcon + @Environment(\.openWindow) var openWindow + let updaterController: SPUStandardUpdaterController + + init() { + updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + } + var body: some Scene { - WindowGroup { - ContentView() + MenuBarExtra("dynamic.island", systemImage: "sparkle", isInserted: $showMenuBarIcon) { + SettingsLink(label: { + Text("Settings") + }) + .keyboardShortcut(KeyEquivalent(","), modifiers: .command) + if false { + Button("Activate License") { + openWindow(id: "activation") + } + } + CheckForUpdatesView(updater: updaterController.updater) + Divider() + Button("Restart Dynamic Island") { + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return } + + let workspace = NSWorkspace.shared + + if let appURL = workspace.urlForApplication(withBundleIdentifier: bundleIdentifier) { + + let configuration = NSWorkspace.OpenConfiguration() + configuration.createsNewApplicationInstance = true + + workspace.openApplication(at: appURL, configuration: configuration) + } + + NSApplication.shared.terminate(nil) + } + Button("Quit", role: .destructive) { + NSApp.terminate(nil) + } + .keyboardShortcut(KeyEquivalent("Q"), modifiers: .command) } + + Settings { + SettingsView(updaterController: updaterController) + } + .defaultSize(CGSize(width: 750, height: 700)) + + Window("Onboarding", id: "onboarding") { + ProOnboard() + } + .windowStyle(.hiddenTitleBar) + .windowResizability(.contentSize) + + Window("Activation", id: "activation") { + ActivationWindow() + } + .windowStyle(.hiddenTitleBar) + .windowResizability(.contentSize) + } +} + +class AppDelegate: NSObject, NSApplicationDelegate { + var statusItem: NSStatusItem? + var windows: [NSScreen: NSWindow] = [:] + var viewModels: [NSScreen: DynamicIslandViewModel] = [:] + var window: NSWindow? + let vm: DynamicIslandViewModel = .init() + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + var whatsNewWindow: NSWindow? + var timer: Timer? + let calenderManager = CalendarManager() + var closeNotchWorkItem: DispatchWorkItem? + private var previousScreens: [NSScreen]? + @Environment(\.openWindow) var openWindow + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return false + } + + func applicationWillTerminate(_ notification: Notification) { + NotificationCenter.default.removeObserver(self) + } + + @objc func onScreenLocked(_: Notification) { + print("Screen locked") + cleanupWindows() + } + + @objc func onScreenUnlocked(_: Notification) { + print("Screen unlocked") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.cleanupWindows() + self?.adjustWindowPosition(changeAlpha: true) + } + } + + private func cleanupWindows(shouldInvert: Bool = false) { + if shouldInvert ? !Defaults[.showOnAllDisplays] : Defaults[.showOnAllDisplays] { + for window in windows.values { + window.close() + NotchSpaceManager.shared.notchSpace.windows.remove(window) + } + windows.removeAll() + viewModels.removeAll() + } else if let window = window { + window.close() + NotchSpaceManager.shared.notchSpace.windows.remove(window) + self.window = nil + } + } + + private func createDynamicIslandWindow(for screen: NSScreen, with viewModel: DynamicIslandViewModel) -> NSWindow { + let window = DynamicIslandWindow( + contentRect: NSRect(x: 0, y: 0, width: openNotchSize.width, height: openNotchSize.height), + styleMask: [.borderless, .nonactivatingPanel, .utilityWindow, .hudWindow], + backing: .buffered, + defer: false + ) + + window.contentView = NSHostingView( + rootView: ContentView() + .environmentObject(viewModel) + ) + + window.orderFrontRegardless() + NotchSpaceManager.shared.notchSpace.windows.insert(window) + return window + } + + private func positionWindow(_ window: NSWindow, on screen: NSScreen, changeAlpha: Bool = false) { + if changeAlpha { + window.alphaValue = 0 + } + + DispatchQueue.main.async { [weak window] in + guard let window = window else { return } + let screenFrame = screen.frame + window.setFrameOrigin(NSPoint( + x: screenFrame.origin.x + (screenFrame.width / 2) - window.frame.width / 2, + y: screenFrame.origin.y + screenFrame.height - window.frame.height + )) + window.alphaValue = 1 + } + } + + func applicationDidFinishLaunching(_ notification: Notification) { + + coordinator.setupWorkersNotificationObservers(); + + NotificationCenter.default.addObserver( + self, + selector: #selector(screenConfigurationDidChange), + name: NSApplication.didChangeScreenParametersNotification, + object: nil + ) + + NotificationCenter.default.addObserver(forName: Notification.Name.selectedScreenChanged, object: nil, queue: nil) { [weak self] _ in + self?.adjustWindowPosition(changeAlpha: true) + } + + NotificationCenter.default.addObserver(forName: Notification.Name.notchHeightChanged, object: nil, queue: nil) { [weak self] _ in + self?.adjustWindowPosition() + } + + NotificationCenter.default.addObserver(forName: Notification.Name.automaticallySwitchDisplayChanged, object: nil, queue: nil) { [weak self] _ in + guard let self = self, let window = self.window else { return } + window.alphaValue = self.coordinator.selectedScreen == self.coordinator.preferredScreen ? 1 : 0 + } + + NotificationCenter.default.addObserver(forName: Notification.Name.showOnAllDisplaysChanged, object: nil, queue: nil) { [weak self] _ in + guard let self = self else { return } + self.cleanupWindows(shouldInvert: true) + + if(!Defaults[.showOnAllDisplays]) { + let viewModel = self.vm + let window = self.createDynamicIslandWindow(for: NSScreen.main ?? NSScreen.screens.first!, with: viewModel) + self.window = window + self.adjustWindowPosition(changeAlpha: true) + } else { + self.adjustWindowPosition() + } + } + + DistributedNotificationCenter.default().addObserver(self, selector: #selector(onScreenLocked(_:)), name: NSNotification.Name(rawValue: "com.apple.screenIsLocked"), object: nil) + DistributedNotificationCenter.default().addObserver(self, selector: #selector(onScreenUnlocked(_:)), name: NSNotification.Name(rawValue: "com.apple.screenIsUnlocked"), object: nil) + + KeyboardShortcuts.onKeyDown(for: .toggleSneakPeek) { [weak self] in + guard let self = self else { return } + self.coordinator.toggleSneakPeek( + status: !self.coordinator.sneakPeek.show, + type: .music, + duration: 3.0 + ) + } + + KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in + guard let self = self else { return } + + let mouseLocation = NSEvent.mouseLocation + + var viewModel = self.vm + + if(Defaults[.showOnAllDisplays]) { + for screen in NSScreen.screens { + if screen.frame.contains(mouseLocation) { + if let screenViewModel = self.viewModels[screen] { + viewModel = screenViewModel + break + } + } + } + } + + self.closeNotchWorkItem?.cancel() + self.closeNotchWorkItem = nil + + switch viewModel.notchState { + case .closed: + viewModel.open() + + let workItem = DispatchWorkItem { [weak viewModel] in + viewModel?.close() + } + self.closeNotchWorkItem = workItem + + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem) + case .open: + viewModel.close() + } + } + + if !Defaults[.showOnAllDisplays] { + let viewModel = self.vm + let window = createDynamicIslandWindow(for: NSScreen.main ?? NSScreen.screens.first!, with: viewModel) + self.window = window + adjustWindowPosition(changeAlpha: true) + } else { + adjustWindowPosition(changeAlpha: true) + } + + if coordinator.firstLaunch { + DispatchQueue.main.async { + self.openWindow(id: "onboarding") + } + playWelcomeSound() + } + + previousScreens = NSScreen.screens + } + + func playWelcomeSound() { + let audioPlayer = AudioPlayer() + audioPlayer.play(fileName: "dynamic", fileExtension: "m4a") + } + + func deviceHasNotch() -> Bool { + if #available(macOS 12.0, *) { + for screen in NSScreen.screens { + if screen.safeAreaInsets.top > 0 { + return true + } + } + } + return false + } + + @objc func screenConfigurationDidChange() { + let currentScreens = NSScreen.screens + + let screensChanged = currentScreens.count != previousScreens?.count || + Set(currentScreens.map { $0.localizedName }) != Set(previousScreens?.map { $0.localizedName } ?? []) || + Set(currentScreens.map { $0.frame }) != Set(previousScreens?.map { $0.frame } ?? []) + + previousScreens = currentScreens + + if screensChanged { + DispatchQueue.main.async { [weak self] in + self?.cleanupWindows() + self?.adjustWindowPosition() + } + } + } + + @objc func adjustWindowPosition(changeAlpha: Bool = false) { + if Defaults[.showOnAllDisplays] { + let currentScreens = Set(NSScreen.screens) + + for screen in windows.keys where !currentScreens.contains(screen) { + if let window = windows[screen] { + window.close() + NotchSpaceManager.shared.notchSpace.windows.remove(window) + windows.removeValue(forKey: screen) + viewModels.removeValue(forKey: screen) + } + } + + for screen in currentScreens { + if windows[screen] == nil { + let viewModel = DynamicIslandViewModel(screen: screen.localizedName) + let window = createDynamicIslandWindow(for: screen, with: viewModel) + + windows[screen] = window + viewModels[screen] = viewModel + } + + if let window = windows[screen], let viewModel = viewModels[screen] { + positionWindow(window, on: screen, changeAlpha: changeAlpha) + + if viewModel.notchState == .closed { + viewModel.close() + } + } + } + } else { + let selectedScreen: NSScreen + + if let preferredScreen = NSScreen.screens.first(where: { $0.localizedName == coordinator.preferredScreen }) { + coordinator.selectedScreen = coordinator.preferredScreen + selectedScreen = preferredScreen + } else if Defaults[.automaticallySwitchDisplay], let mainScreen = NSScreen.main { + coordinator.selectedScreen = mainScreen.localizedName + selectedScreen = mainScreen + } else { + if let window = window { + window.alphaValue = 0 + } + return + } + + vm.screen = selectedScreen.localizedName + vm.notchSize = getClosedNotchSize(screen: selectedScreen.localizedName) + + if window == nil { + window = createDynamicIslandWindow(for: selectedScreen, with: vm) + } + + if let window = window { + positionWindow(window, on: selectedScreen, changeAlpha: changeAlpha) + + if vm.notchState == .closed { + vm.close() + } + } + } + } + + @objc func togglePopover(_ sender: Any?) { + if window?.isVisible == true { + window?.orderOut(nil) + } else { + window?.orderFrontRegardless() + } + } + + @objc func showMenu() { + statusItem?.menu?.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil) + } + + @objc func quitAction() { + NSApplication.shared.terminate(nil) + } +} + +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") +} + +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/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift new file mode 100644 index 0000000..8e5ace5 --- /dev/null +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -0,0 +1,214 @@ +// +// DynamicIslandViewCoordinator.swift +// DynamicIsland +// +// Created by Alexander on 2024-11-20. +// + +import Combine +import Defaults +import SwiftUI +import TheBoringWorkerNotifier + +enum SneakContentType { + case brightness + case volume + case backlight + case music + case mic + case battery + case download +} + +struct sneakPeek { + var show: Bool = false + var type: SneakContentType = .music + var value: CGFloat = 0 + var icon: String = "" +} + +struct SharedSneakPeek: Codable { + var show: Bool + var type: String + var value: String + var icon: String +} + +enum BrowserType { + case chromium + case safari +} + +struct ExpandedItem { + var show: Bool = false + var type: SneakContentType = .battery + var value: CGFloat = 0 + var browser: BrowserType = .chromium +} + +class DynamicIslandViewCoordinator: ObservableObject { + static let shared = DynamicIslandViewCoordinator() + var notifier: TheBoringWorkerNotifier = .init() + + @Published var currentView: NotchViews = .home + private var sneakPeekDispatch: DispatchWorkItem? + private var expandingViewDispatch: DispatchWorkItem? + + + @AppStorage("firstLaunch") var firstLaunch: Bool = true + @AppStorage("showWhatsNew") var showWhatsNew: Bool = true + @AppStorage("musicLiveActivityEnabled") var musicLiveActivityEnabled: Bool = true + @AppStorage("currentMicStatus") var currentMicStatus: Bool = true + + @AppStorage("alwaysShowTabs") var alwaysShowTabs: Bool = true { + didSet { + if !alwaysShowTabs { + openLastTabByDefault = false + if TrayDrop.shared.isEmpty || !Defaults[.openShelfByDefault] { + currentView = .home + } + } + } + } + + @AppStorage("openLastTabByDefault") var openLastTabByDefault: Bool = false { + didSet { + if openLastTabByDefault { + alwaysShowTabs = true + } + } + } + + @AppStorage("hudReplacement") var hudReplacement: Bool = true { + didSet { + notifier.postNotification(name: notifier.toggleHudReplacementNotification.name, userInfo: nil) + } + } + + @AppStorage("preferred_screen_name") var preferredScreen = NSScreen.main?.localizedName ?? "Unknown" { + didSet { + selectedScreen = preferredScreen + NotificationCenter.default.post(name: Notification.Name.selectedScreenChanged, object: nil) + } + } + + @Published var selectedScreen: String = NSScreen.main?.localizedName ?? "Unknown" + + @Published var optionKeyPressed: Bool = true + + private init() { + selectedScreen = preferredScreen + notifier = TheBoringWorkerNotifier() + } + + func setupWorkersNotificationObservers() { + notifier.setupObserver(notification: notifier.micStatusNotification, handler: initialMicStatus) + notifier.setupObserver(notification: notifier.sneakPeakNotification, handler: sneakPeekEvent) + } + + @objc func sneakPeekEvent(_ notification: Notification) { + let decoder = JSONDecoder() + if let decodedData = try? decoder.decode(SharedSneakPeek.self, from: notification.userInfo?.first?.value as! Data) { + let contentType = decodedData.type == "brightness" ? SneakContentType.brightness : decodedData.type == "volume" ? SneakContentType.volume : decodedData.type == "backlight" ? SneakContentType.backlight : decodedData.type == "mic" ? SneakContentType.mic : SneakContentType.brightness + + let value = CGFloat((NumberFormatter().number(from: decodedData.value) ?? 0.0).floatValue) + let icon = decodedData.icon + + print(decodedData) + + toggleSneakPeek(status: decodedData.show, type: contentType, value: value, icon: icon) + + } else { + print("Failed to decode JSON data") + } + } + + func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") { + sneakPeekDuration = duration + if type != .music { + // close() + if !hudReplacement { + return + } + } + DispatchQueue.main.async { + withAnimation(.smooth) { + self.sneakPeek.show = status + self.sneakPeek.type = type + self.sneakPeek.value = value + self.sneakPeek.icon = icon + } + } + + if type == .mic { + currentMicStatus = value == 1 + } + } + + private var sneakPeekDuration: TimeInterval = 1.5 + + @Published var sneakPeek: sneakPeek = .init() { + didSet { + if sneakPeek.show { + sneakPeekDispatch?.cancel() + + sneakPeekDispatch = DispatchWorkItem { [weak self] in + guard let self = self else { return } + withAnimation { + self.toggleSneakPeek(status: false, type: SneakContentType.music) + self.sneakPeekDuration = 1.5 + } + } + DispatchQueue.main + .asyncAfter( + deadline: .now() + self.sneakPeekDuration, + execute: sneakPeekDispatch! + ) + } + } + } + + func toggleExpandingView(status: Bool, type: SneakContentType, value: CGFloat = 0, browser: BrowserType = .chromium) { + if expandingView.show { + withAnimation(.smooth) { + self.expandingView.show = false + } + } + DispatchQueue.main.async { + withAnimation(.smooth) { + self.expandingView.show = status + self.expandingView.type = type + self.expandingView.value = value + self.expandingView.browser = browser + } + } + } + + @Published var expandingView: ExpandedItem = .init() { + didSet { + if expandingView.show { + expandingViewDispatch?.cancel() + + expandingViewDispatch = DispatchWorkItem { [weak self] in + guard let self = self else { return } + self.toggleExpandingView(status: false, type: SneakContentType.battery) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + (expandingView.type == .download ? 2 : 3), execute: expandingViewDispatch!) + } + } + } + + + @objc func initialMicStatus(_ notification: Notification) { + currentMicStatus = notification.userInfo?.first?.value as! Bool + } + + func toggleMic() { + notifier.postNotification(name: notifier.toggleMicNotification.name, userInfo: nil) + } + + func showEmpty() { + currentView = .home + } +} diff --git a/DynamicIsland/Info.plist b/DynamicIsland/Info.plist index d1565e5..d07ae61 100644 --- a/DynamicIsland/Info.plist +++ b/DynamicIsland/Info.plist @@ -7,6 +7,8 @@ NSAllowsArbitraryLoads + NSAppleEventsUsageDescription + Dynamic Island uses AppleScripts to control Spotify and Apple Music NSCalendarsFullAccessUsageDescription Show calender events inside the notch SUEnableDownloaderService @@ -16,7 +18,7 @@ SUFeedURL https://TheBoredTeam.github.io/boring.notch/appcast.xml SUPublicEDKey - LlPWpDWaN5DLDCuB45z9xwxVXkdIqJhOT0eP5CiMsto= + B1Y47t8C/v8ImurYA+9arEsuCrpxwJSviekiflMElbI= UTImportedTypeDeclarations diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 382dd8c..94c1c62 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -3,6 +3,9 @@ "strings" : { " – %lld" : { + }, + "'Now Playing' was the only option on previous versions and works with all media apps." : { + }, "(%@)" : { "localizations" : { @@ -40,9 +43,6 @@ }, "β€’ New feature 1" : { - }, - "⚠️ Important" : { - }, "About" : { "localizations" : { @@ -63,6 +63,9 @@ } } } + }, + "Access Denied" : { + }, "Activate" : { @@ -72,9 +75,15 @@ }, "Activation" : { + }, + "Add" : { + }, "Add manually" : { + }, + "Add new visualizer" : { + }, "Additional features" : { @@ -87,6 +96,9 @@ }, "All-day" : { + }, + "Always hide in fullscreen" : { + }, "Always show tabs" : { @@ -119,6 +131,15 @@ } } } + }, + "Battery Information" : { + + }, + "Battery Settings" : { + + }, + "Battery Status" : { + }, "Boost your productivity with Clipboard Manager" : { @@ -164,6 +185,15 @@ } } } + }, + "Buy us a coffee" : { + + }, + "Calendar" : { + + }, + "Calendar access is denied. Please enable it in System Settings." : { + }, "Cancel" : { @@ -177,6 +207,9 @@ } } } + }, + "Charging on Hold: Desktop Mode" : { + }, "Check for updates" : { "extractionState" : "stale", @@ -207,9 +240,6 @@ } } } - }, - "CPU" : { - }, "Custom color" : { "localizations" : { @@ -223,9 +253,15 @@ }, "Custom height" : { + }, + "Custom music live activity animation" : { + }, "Custom notch size - %.0f" : { + }, + "Custom vizualizers (Lottie)" : { + }, "Default" : { @@ -280,9 +316,6 @@ }, "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" : { - }, - "Edit" : { - }, "Edit layout" : { @@ -317,6 +350,9 @@ }, "Enable music live activity" : { + }, + "Enable sneak peek" : { + }, "Enhance your experience with HUDs" : { @@ -326,9 +362,6 @@ }, "Error" : { - }, - "ERROR: VIEW NOT DEFINED" : { - }, "Exclude apps" : { "localizations" : { @@ -345,9 +378,6 @@ }, "Extensions" : { - }, - "Fullscreen media playback detection" : { - }, "General" : { "localizations" : { @@ -383,6 +413,12 @@ }, "Gradient" : { + }, + "Hide BoringNotch Options" : { + + }, + "Hide only when NowPlaying app is in fullscreen" : { + }, "Hierarchical" : { @@ -390,10 +426,7 @@ "High" : { }, - "Hover over the notch after changing the height to see the effect." : { - - }, - "Hover over the notch after changing the screen to adapt the new size" : { + "https://github.com/th-ch/youtube-music" : { }, "HUD style" : { @@ -427,9 +460,15 @@ }, "License key" : { + }, + "Lottie JSON URL" : { + }, "Low" : { + }, + "Low Power Mode" : { + }, "Made with 🫢🏻 by not so boring not.people" : { "localizations" : { @@ -446,6 +485,9 @@ }, "Match real notch size" : { + }, + "Max Capacity: %lld%%" : { + }, "Media" : { "localizations" : { @@ -480,10 +522,10 @@ } } }, - "Medium" : { + "Media Source" : { }, - "Memory" : { + "Medium" : { }, "Menubar icon" : { @@ -511,14 +553,26 @@ }, "More" : { + }, + "Music Source" : { + }, "muted" : { + }, + "Name" : { + }, "Need Restart" : { }, - "Network" : { + "Never hide" : { + + }, + "No custom animation available" : { + + }, + "No custom visualizer" : { }, "No events today" : { @@ -541,14 +595,17 @@ "No extension installed" : { }, - "Non-notch displays" : { - - }, - "Non-notch screen height" : { + "Non-notch display height" : { }, "Notch behavior" : { + }, + "Notch display height" : { + + }, + "Notch Height" : { + }, "OK" : { @@ -578,6 +635,9 @@ } } } + }, + "Open System Settings" : { + }, "Percentage" : { "localizations" : { @@ -588,6 +648,9 @@ } } } + }, + "Plugged In" : { + }, "Progress bar" : { "localizations" : { @@ -658,6 +721,15 @@ } } } + }, + "Select Calendars" : { + + }, + "selected" : { + + }, + "Selected animation" : { + }, "Settings" : { "localizations" : { @@ -685,6 +757,9 @@ } } } + }, + "Show calendar" : { + }, "Show charging indicator" : { "extractionState" : "stale", @@ -721,12 +796,18 @@ }, "Show on a specific display" : { + }, + "Show on all displays" : { + }, "Slider color" : { }, "Sneak Peek shows the media title and artist under the notch for a few seconds." : { + }, + "Sneak Peek Style" : { + }, "Software updates" : { "localizations" : { @@ -737,6 +818,9 @@ } } } + }, + "Speed" : { + }, "Square" : { @@ -758,14 +842,13 @@ }, "TheBoringNotch" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "TheDynamicIsland" - } - } - } + + }, + "Time to Full Charge: %lld min" : { + + }, + "Toggle Notch Open:" : { + }, "Toggle Sneak Peek:" : { @@ -787,6 +870,9 @@ }, "Upgrade to Pro" : { + }, + "Use music visualizer spectrogram" : { + }, "Version" : { "localizations" : { @@ -816,6 +902,9 @@ }, "What's New" : { + }, + "YouTube Music requires this third-party app to be installed: " : { + } }, "version" : "1.0" diff --git a/DynamicIsland/MediaControllers/AppleMusicController.swift b/DynamicIsland/MediaControllers/AppleMusicController.swift new file mode 100644 index 0000000..2c34757 --- /dev/null +++ b/DynamicIsland/MediaControllers/AppleMusicController.swift @@ -0,0 +1,154 @@ +// +// AppleMusicController.swift +// boringNotch +// +// Created by Alexander Greco on 2025-03-29. +// + +import Foundation +import Combine +import SwiftUI + +class AppleMusicController: MediaControllerProtocol { + // MARK: - Properties + @Published private var playbackState: PlaybackState = PlaybackState( + bundleIdentifier: "com.apple.Music" + ) + + var playbackStatePublisher: Published.Publisher { $playbackState } + + // MARK: - Initialization + init() { + setupPlaybackStateChangeObserver() + DispatchQueue.main.async { [weak self] in + if self?.isActive() == true { + self?.updatePlaybackInfo() + } + } + } + + private func setupPlaybackStateChangeObserver() { + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(updatePlaybackInfo), + name: NSNotification.Name("com.apple.Music.playerInfo"), + object: nil + ) + } + + deinit { + DistributedNotificationCenter.default().removeObserver( + self, + name: NSNotification.Name("com.apple.Music.playerInfo"), + object: nil + ) + + } + + // MARK: - Protocol Implementation + func play() { + executeCommand("play") + } + + func pause() { + executeCommand("pause") + } + + func togglePlay() { + executeCommand("playpause") + } + + func nextTrack() { + executeCommand("next track") + } + + func previousTrack() { + executeCommand("previous track") + } + + func seek(to time: Double) { + executeCommand("set player position to \(time)") + updatePlaybackInfo() + } + + func isActive() -> Bool { + let runningApps = NSWorkspace.shared.runningApplications + return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } + } + + // MARK: - Private Methods + @objc func updatePlaybackInfo() { + guard let descriptor = fetchPlaybackInfo() else { return } + guard descriptor.numberOfItems >= 8 else { return } + + let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false + let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown" + let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown" + let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown" + let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0 + let duration = descriptor.atIndex(6)?.doubleValue ?? 0 + let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false + let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false + let artworkData = descriptor.atIndex(9)?.data as Data? + + let updatedState = PlaybackState( + bundleIdentifier: "com.apple.Music", + isPlaying: isPlaying, + title: currentTrack, + artist: currentTrackArtist, + album: currentTrackAlbum, + currentTime: currentTime, + duration: duration, + playbackRate: 1, + isShuffled: isShuffled, + isRepeating: isRepeating, + lastUpdated: Date(), + artwork: (artworkData?.isEmpty ?? true) ? nil : artworkData + ) + + DispatchQueue.main.async { [weak self] in + self?.playbackState = updatedState + } + } + + private func executeCommand(_ command: String) { + let script = "tell application \"Music\" to \(command)" + Task { + try? await AppleScriptHelper.executeVoid(script) + } + } + + private func fetchPlaybackInfo() -> NSAppleEventDescriptor? { + let script = """ + tell application "Music" + set isRunning to true + try + set playerState to player state is playing + set currentTrackName to name of current track + set currentTrackArtist to artist of current track + set currentTrackAlbum to album of current track + set trackPosition to player position + set trackDuration to duration of current track + set shuffleState to false + set repeatState to false + try + set artData to data of artwork 1 of current track + on error + set artData to "" + end try + return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artData} + on error + return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, false, ""} + end try + end tell + """ + var descriptor: NSAppleEventDescriptor? = nil + let semaphore = DispatchSemaphore(value: 0) + Task { + descriptor = try? await AppleScriptHelper.execute(script) + semaphore.signal() + } + semaphore.wait() + return descriptor + } +} diff --git a/DynamicIsland/MediaControllers/MediaControllerProtocol.swift b/DynamicIsland/MediaControllers/MediaControllerProtocol.swift new file mode 100644 index 0000000..825042e --- /dev/null +++ b/DynamicIsland/MediaControllers/MediaControllerProtocol.swift @@ -0,0 +1,21 @@ +// +// MediaControllerProtocol.swift +// boringNotch +// +// Created by Alexander Greco on 2025-03-29. +// + +import Foundation +import AppKit + +protocol MediaControllerProtocol: ObservableObject { + var playbackStatePublisher: Published.Publisher { get } + func play() + func pause() + func seek(to time: Double) + func nextTrack() + func previousTrack() + func togglePlay() + func isActive() -> Bool + func updatePlaybackInfo() +} diff --git a/DynamicIsland/MediaControllers/NowPlayingController.swift b/DynamicIsland/MediaControllers/NowPlayingController.swift new file mode 100644 index 0000000..df3749c --- /dev/null +++ b/DynamicIsland/MediaControllers/NowPlayingController.swift @@ -0,0 +1,197 @@ +// +// NowPlayingController.swift +// boringNotch +// +// Created by Alexander on 2025-03-29. +// + +import Combine +import AppKit +import Foundation + +class NowPlayingController: ObservableObject, MediaControllerProtocol { + // MARK: - Properties + @Published var playbackState: PlaybackState = .init( + bundleIdentifier: "com.apple.Music" + ) + + var playbackStatePublisher: Published.Publisher { $playbackState } + private var cancellables = Set() + private var lastMusicItem: (title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)? + + // MARK: - Media Remote Functions + private let mediaRemoteBundle: CFBundle + private let MRMediaRemoteGetNowPlayingInfo: @convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void + private let MRMediaRemoteRegisterForNowPlayingNotifications: @convention(c) (DispatchQueue) -> Void + private let MRMediaRemoteGetNowPlayingApplicationIsPlaying: @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void + private let MRMediaRemoteGetNowPlayingClient: @convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void + private let MRNowPlayingClientGetBundleIdentifier: @convention(c) (AnyObject?) -> String? + private let MRNowPlayingClientGetParentAppBundleIdentifier: @convention(c) (AnyObject?) -> String? + private let MRMediaRemoteSendCommandFunction:@convention(c) (Int, AnyObject?) -> Void + private let MRMediaRemoteSetElapsedTimeFunction: @convention(c) (Double) -> Void + + // MARK: - Initialization + init?() { + guard let bundle = CFBundleCreate(kCFAllocatorDefault, NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")), + let MRMediaRemoteGetNowPlayingInfoPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingInfo" as CFString), + let MRMediaRemoteRegisterForNowPlayingNotificationsPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteRegisterForNowPlayingNotifications" as CFString), + let MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying" as CFString), + let MRMediaRemoteGetNowPlayingClientPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingClient" as CFString), + let MRNowPlayingClientGetBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetBundleIdentifier" as CFString), + let MRNowPlayingClientGetParentAppBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetParentAppBundleIdentifier" as CFString), + let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSendCommand" as CFString), + let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSetElapsedTime" as CFString) + else { return nil } + + mediaRemoteBundle = bundle + MRMediaRemoteGetNowPlayingInfo = unsafeBitCast(MRMediaRemoteGetNowPlayingInfoPointer, to: (@convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void).self) + MRMediaRemoteRegisterForNowPlayingNotifications = unsafeBitCast(MRMediaRemoteRegisterForNowPlayingNotificationsPointer, to: (@convention(c) (DispatchQueue) -> Void).self) + MRMediaRemoteGetNowPlayingApplicationIsPlaying = unsafeBitCast(MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer, to: (@convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void).self) + MRMediaRemoteGetNowPlayingClient = unsafeBitCast(MRMediaRemoteGetNowPlayingClientPointer, to: (@convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void).self) + MRNowPlayingClientGetBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self) + MRNowPlayingClientGetParentAppBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetParentAppBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self) + MRMediaRemoteSendCommandFunction = unsafeBitCast(MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self) + MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast(MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self) + + setupNowPlayingObserver() + updatePlaybackInfo() + } + + deinit { + // Clean up all Combine subscribers + cancellables.removeAll() + + // Remove distributed notification observers + DistributedNotificationCenter.default().removeObserver( + self, + name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), + object: nil + ) + + DistributedNotificationCenter.default().removeObserver( + self, + name: NSNotification.Name("com.apple.Music.playerInfo"), + object: nil + ) + } + + // MARK: - Protocol Implementation + func play() { + MRMediaRemoteSendCommandFunction(0, nil) + } + + func pause() { + MRMediaRemoteSendCommandFunction(1, nil) + } + + func togglePlay() { + MRMediaRemoteSendCommandFunction(2, nil) + } + + func nextTrack() { + MRMediaRemoteSendCommandFunction(4, nil) + } + + func previousTrack() { + MRMediaRemoteSendCommandFunction(5, nil) + } + + func seek(to time: Double) { + MRMediaRemoteSetElapsedTimeFunction(time) + } + + func isActive() -> Bool { + return true + } + + // MARK: - Setup Methods + private func setupNowPlayingObserver() { + MRMediaRemoteRegisterForNowPlayingNotifications(DispatchQueue.main) + + NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingInfoDidChangeNotification")) + .sink { [weak self] _ in self?.updatePlaybackInfo() } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingApplicationDidChangeNotification")) + .sink { [weak self] _ in self?.updateApp() } + .store(in: &cancellables) + + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(updatePlaybackInfo), + name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), + object: nil + ) + + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(updatePlaybackInfo), + name: NSNotification.Name("com.apple.Music.playerInfo"), + object: nil + ) + } + + // MARK: - Update Methods + @objc func updateApp() { + // Get the now playing client + MRMediaRemoteGetNowPlayingClient(DispatchQueue.main) { [weak self] clientObj in + guard let clientObj = clientObj else { + DispatchQueue.main.async { + self?.playbackState.bundleIdentifier = "com.apple.Music" // Default fallback + } + return + } + + // Try to get parent bundle ID first, then fall back to direct bundle ID + var appBundleID = self?.MRNowPlayingClientGetParentAppBundleIdentifier(clientObj) + if appBundleID == nil { + appBundleID = self?.MRNowPlayingClientGetBundleIdentifier(clientObj) + } + + // Special case for WebKit.GPU which is often Safari + if appBundleID == "com.apple.WebKit.GPU" { + appBundleID = "com.apple.Safari" + } + + DispatchQueue.main.async { + self?.playbackState.bundleIdentifier = appBundleID ?? "com.apple.Music" + } + } + } + + @objc func updatePlaybackInfo() { + updateApp() + + MRMediaRemoteGetNowPlayingInfo(DispatchQueue.main) { [weak self] information in + guard let self = self else { return } + + let title = information["kMRMediaRemoteNowPlayingInfoTitle"] as? String ?? "" + let artist = information["kMRMediaRemoteNowPlayingInfoArtist"] as? String ?? "" + let album = information["kMRMediaRemoteNowPlayingInfoAlbum"] as? String ?? "" + let duration = information["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval ?? 0 + let currentTime = information["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? TimeInterval ?? 0 + let artworkData = information["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data + let timestamp = information["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date ?? Date() + let playbackRate = information["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 1 + + // Update playback state + DispatchQueue.main.async { + self.playbackState.title = title + self.playbackState.artist = artist + self.playbackState.album = album + self.playbackState.duration = duration + self.playbackState.currentTime = currentTime + self.playbackState.artwork = artworkData + self.playbackState.lastUpdated = timestamp + self.playbackState.playbackRate = playbackRate + + // Check playback state + self.MRMediaRemoteGetNowPlayingApplicationIsPlaying(DispatchQueue.main) { [weak self] isPlaying in + DispatchQueue.main.async { + self?.playbackState.isPlaying = isPlaying + } + } + } + } + } +} diff --git a/DynamicIsland/MediaControllers/SpotifyController.swift b/DynamicIsland/MediaControllers/SpotifyController.swift new file mode 100644 index 0000000..20a31c3 --- /dev/null +++ b/DynamicIsland/MediaControllers/SpotifyController.swift @@ -0,0 +1,168 @@ +// +// SpotifyController.swift +// boringNotch +// +// Created by Alexander Greco on 2025-03-29. +// + +import Foundation +import Combine +import SwiftUI + +class SpotifyController: MediaControllerProtocol { + // MARK: - Properties + @Published private var playbackState: PlaybackState = PlaybackState( + bundleIdentifier: "com.spotify.client" + ) + + var playbackStatePublisher: Published.Publisher { $playbackState } + + init() { + setupPlaybackStateChangeObserver() + DispatchQueue.main.async { [weak self] in + if self?.isActive() == true { + self?.updatePlaybackInfo() + } + } + } + + private func setupPlaybackStateChangeObserver() { + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(updatePlaybackInfo), + name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), + object: nil + ) + } + + deinit { + // Remove notification observer when controller is deallocated + DistributedNotificationCenter.default().removeObserver( + self, + name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), + object: nil + ) + } + + // MARK: - Protocol Implementation + func play() { + executeCommand("play") + } + + func pause() { + executeCommand("pause") + } + + func togglePlay() { + executeCommand("playpause") + } + + func nextTrack() { + executeCommand("next track") + } + + func previousTrack() { + executeCommand("previous track") + } + + func seek(to time: Double) { + executeCommand("set player position to \(time)") + updatePlaybackInfo() + } + + func isActive() -> Bool { + let runningApps = NSWorkspace.shared.runningApplications + return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } + } + + // MARK: - Private Methods + @objc func updatePlaybackInfo() { + guard let descriptor = fetchPlaybackInfo() else { return } + guard descriptor.numberOfItems >= 9 else { return } + + let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false + let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown" + let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown" + let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown" + let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0 + let duration = (descriptor.atIndex(6)?.doubleValue ?? 0)/1000 + let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false + let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false + let artworkURL = descriptor.atIndex(9)?.stringValue ?? "" + + let state = PlaybackState( + bundleIdentifier: "com.spotify.client", + isPlaying: isPlaying, + title: currentTrack, + artist: currentTrackArtist, + album: currentTrackAlbum, + currentTime: currentTime, + duration: duration, + playbackRate: 1, + isShuffled: isShuffled, + isRepeating: isRepeating, + lastUpdated: Date() + ) + + DispatchQueue.main.async { [weak self] in + self?.playbackState = state + } + + // Load artwork asynchronously and update the state when complete + if !artworkURL.isEmpty, let url = URL(string: artworkURL) { + DispatchQueue.global(qos: .background).async { [weak self] in + do { + let artworkData = try Data(contentsOf: url) + DispatchQueue.main.async { + // Create a new state with the artwork data and update + var updatedState = state + updatedState.artwork = artworkData + self?.playbackState = updatedState + } + } catch { + print("Failed to load artwork: \(error)") + } + } + } else { + self.playbackState = state + } + } + + private func executeCommand(_ command: String) { + let script = "tell application \"Spotify\" to \(command)" + Task { + try? await AppleScriptHelper.executeVoid(script) + } + } + + private func fetchPlaybackInfo() -> NSAppleEventDescriptor? { + let script = """ + tell application "Spotify" + set isRunning to true + try + set playerState to player state is playing + set currentTrackName to name of current track + set currentTrackArtist to artist of current track + set currentTrackAlbum to album of current track + set trackPosition to player position + set trackDuration to duration of current track + set shuffleState to shuffling + set repeatState to repeating + set artworkURL to artwork url of current track + return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL} + on error + return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, ""} + end try + end tell + """ + + var descriptor: NSAppleEventDescriptor? = nil + let semaphore = DispatchSemaphore(value: 0) + Task { + descriptor = try? await AppleScriptHelper.execute(script) + semaphore.signal() + } + semaphore.wait() + return descriptor + } +} diff --git a/DynamicIsland/MediaControllers/YouTubeMusicController.swift b/DynamicIsland/MediaControllers/YouTubeMusicController.swift new file mode 100644 index 0000000..fc40adb --- /dev/null +++ b/DynamicIsland/MediaControllers/YouTubeMusicController.swift @@ -0,0 +1,386 @@ +// +// YouTubeMusicController.swift +// boringNotch +// +// Created By Alexander Greco on 2025-03-30. +// + +import Foundation +import Combine +import SwiftUI + +class YouTubeMusicController: MediaControllerProtocol { + // MARK: - Properties + @Published var playbackState: PlaybackState = .init( + bundleIdentifier: "com.github.th-ch.youtube-music", + isPlaying: true + ) + + var playbackStatePublisher: Published.Publisher { $playbackState } + private let baseURL = "http://localhost:26538" + private var accessToken: String? + private var refreshTimer: Timer? + private var cancellables = Set() + private var playbackInfoCancellable: AnyCancellable? + private var isAuthenticating = false + private let authQueue = DispatchQueue(label: "com.boringnotch.youtubemusicauth", qos: .background) + + init() { + setupAppStateObservers() + authQueue.async { [weak self] in + self?.authenticateAndSetup() + } + } + + deinit { + stopPeriodicUpdates() + cancellables.forEach { $0.cancel() } + NSWorkspace.shared.notificationCenter.removeObserver(self) + } + + // MARK: - Authentication + + private func authenticateAndSetup() { + // Prevent multiple concurrent authentication attempts + guard !isAuthenticating else { return } + + isAuthenticating = true + getAccessToken { [weak self] success in + guard let self = self else { return } + + DispatchQueue.main.async { + self.isAuthenticating = false + if success { + self.startPeriodicUpdates() + self.updatePlaybackInfo() + } else { + // Retry authentication after a delay if it fails + DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 5) { [weak self] in + self?.authenticateAndSetup() + } + } + } + } + } + + private func getAccessToken(completion: @escaping (Bool) -> Void) { + guard let url = URL(string: "\(baseURL)/auth/boringNotch") else { + completion(false) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + + URLSession.shared.dataTaskPublisher(for: request) + .map { data, response -> Data in + return data + } + .decode(type: AuthResponse.self, decoder: JSONDecoder()) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { completionStatus in + switch completionStatus { + case .failure(let error): + completion(false) + case .finished: + break + } + }, receiveValue: { [weak self] response in + self?.accessToken = response.accessToken + completion(true) + }) + .store(in: &cancellables) + } + + // MARK: - Protocol Implementation + + func play() { + sendCommand(endpoint: "/play", method: "POST") + updatePlaybackInfo() + } + + func pause() { + sendCommand(endpoint: "/pause", method: "POST") + updatePlaybackInfo() + } + + func togglePlay() { + if !isActive() { + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: playbackState.bundleIdentifier) { + NSWorkspace.shared.open(url) + } + } + sendCommand(endpoint: "/toggle-play", method: "POST") + updatePlaybackInfo() + } + + func nextTrack() { + sendCommand(endpoint: "/next", method: "POST") + updatePlaybackInfo() + } + + func previousTrack() { + sendCommand(endpoint: "/previous", method: "POST") + updatePlaybackInfo() + } + + func seek(to time: Double) { + guard let url = URL(string: "\(baseURL)/seek-to") else { return } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let token = accessToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } else { + authenticateAndSetup() + return + } + + // Create the request body with the seconds value + let seekData = ["seconds": time] + + do { + request.httpBody = try JSONEncoder().encode(seekData) + } catch { + return + } + + URLSession.shared.dataTaskPublisher(for: request) + .tryMap { data, response -> Data in + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw URLError(.userAuthenticationRequired) + } else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 { + throw URLError(.badServerResponse) + } + + return data + } + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] completion in + if case .failure(let error) = completion { + if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired { + self?.accessToken = nil + self?.authenticateAndSetup() + } + } + }, receiveValue: { [weak self] _ in + self?.updatePlaybackInfo() + }) + .store(in: &cancellables) + } + + func isActive() -> Bool { + let runningApps = NSWorkspace.shared.runningApplications + return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } + } + + // MARK: - Private Methods + + private func setupAppStateObservers() { + // Register for app launch notifications + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(handleAppStateChange), + name: NSWorkspace.didLaunchApplicationNotification, + object: nil + ) + + // Register for app termination notifications + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(handleAppStateChange), + name: NSWorkspace.didTerminateApplicationNotification, + object: nil + ) + } + + @objc private func handleAppStateChange(notification: Notification) { + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == playbackState.bundleIdentifier else { + return + } + + if notification.name == NSWorkspace.didLaunchApplicationNotification { + startPeriodicUpdates() + } else if notification.name == NSWorkspace.didTerminateApplicationNotification { + // App has terminated - update state and stop timer + stopPeriodicUpdates() + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.playbackState = PlaybackState( + bundleIdentifier: self.playbackState.bundleIdentifier, + isPlaying: false + ) + } + } + } + + private func startPeriodicUpdates() { + stopPeriodicUpdates() // Ensure we don't create multiple timers + + // Only start the timer if the app is actually running + guard isActive() else { return } + + refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + self?.updatePlaybackInfo() + } + refreshTimer?.tolerance = 0.5 + + // Initial update when timer starts + updatePlaybackInfo() + } + + private func stopPeriodicUpdates() { + refreshTimer?.invalidate() + refreshTimer = nil + } + + func updatePlaybackInfo() { + if !isActive() { + playbackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier, isPlaying: false) + stopPeriodicUpdates() + return + } + guard let request = createAuthenticatedRequest(for: "/api/v1/song") else { return } + + let backgroundQueue = DispatchQueue.global(qos: .utility) + + backgroundQueue.async { [weak self] in + guard let self = self else { return } + + playbackInfoCancellable?.cancel() + + playbackInfoCancellable = URLSession.shared.dataTaskPublisher(for: request) + .tryMap { data, response -> Data in + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + // Check for authentication errors + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + // Re-authenticate and retry later + DispatchQueue.main.async { [weak self] in + self?.accessToken = nil + self?.authenticateAndSetup() + } + throw URLError(.userAuthenticationRequired) + } else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 { + throw URLError(.badServerResponse) + } + + return data + } + .decode(type: PlaybackResponse.self, decoder: JSONDecoder()) + .subscribe(on: backgroundQueue) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }, receiveValue: { [weak self] response in + self?.updatePlaybackState(with: response) + }) + } + } + + private func updatePlaybackState(with response: PlaybackResponse) { + playbackState.isPlaying = !response.isPaused + playbackState.title = response.title + playbackState.artist = response.artist + playbackState.album = response.album ?? "" + playbackState.currentTime = response.elapsedSeconds + playbackState.duration = response.songDuration + playbackState.lastUpdated = Date() + + // Load artwork if available + if let artworkURL = response.imageSrc, let url = URL(string: artworkURL) { + DispatchQueue.global(qos: .background).async { [weak self] in + do { + let artworkData = try Data(contentsOf: url) + DispatchQueue.main.async { + self?.playbackState.artwork = artworkData + } + } catch { return } + } + } + } + + private func sendCommand(endpoint: String, method: String = "GET") { + guard let url = URL(string: "\(baseURL)/api/v1\(endpoint)") else { return } + + var request = URLRequest(url: url) + request.httpMethod = method + + if let token = accessToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } else { + authenticateAndSetup() + return + } + + URLSession.shared.dataTaskPublisher(for: request) + .tryMap { data, response -> Data in + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw URLError(.userAuthenticationRequired) + } else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 { + throw URLError(.badServerResponse) + } + + return data + } + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] completion in + if case .failure(let error) = completion { + if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired { + // Authentication error - token might be expired + self?.accessToken = nil + self?.authenticateAndSetup() + + // Try the command again after authentication + DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 2) { [weak self] in + self?.sendCommand(endpoint: endpoint, method: method) + } + } + } + }, receiveValue: { [weak self] _ in + self?.updatePlaybackInfo() + }) + .store(in: &cancellables) + } + + private func createAuthenticatedRequest(for endpoint: String) -> URLRequest? { + guard let token = accessToken, let url = URL(string: "\(baseURL)\(endpoint)") else { + if accessToken == nil { + // Token is missing, try to authenticate again + authenticateAndSetup() + } + return nil + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } +} + +// MARK: - API Response Models + +struct AuthResponse: Codable { + let accessToken: String +} + +struct PlaybackResponse: Codable { + let isPaused: Bool + let title: String + let artist: String + let album: String? + let elapsedSeconds: Double + let songDuration: Double + let imageSrc: String? +} diff --git a/DynamicIsland/Shortcuts/ShortcutConstants.swift b/DynamicIsland/Shortcuts/ShortcutConstants.swift new file mode 100644 index 0000000..4e476a2 --- /dev/null +++ b/DynamicIsland/Shortcuts/ShortcutConstants.swift @@ -0,0 +1,18 @@ +// +// Constants.swift +// boringNotch +// +// Created by Richard Kunkli on 16/08/2024. +// + +import KeyboardShortcuts +import SwiftUI + +extension KeyboardShortcuts.Name { + static let clipboardHistoryPanel = Self("clipboardHistoryPanel", default: .init(.c, modifiers: [.shift, .command])) + static let toggleMicrophone = Self("toggleMicrophone", default: .init(.f5, modifiers: [.function])) + static let decreaseBacklight = Self("decreaseBacklight", default: .init(.f1, modifiers: [.command])) + static let increaseBacklight = Self("increaseBacklight", default: .init(.f2, modifiers: [.command])) + static let toggleSneakPeek = Self("toggleSneakPeek", default: .init(.h, modifiers: [.command, .shift])) + static let toggleNotchOpen = Self("toggleNotchOpen", default: .init(.i, modifiers: [.command, .shift])) +} diff --git a/DynamicIsland/animations/HelloAnimation.swift b/DynamicIsland/animations/HelloAnimation.swift new file mode 100644 index 0000000..6fc5966 --- /dev/null +++ b/DynamicIsland/animations/HelloAnimation.swift @@ -0,0 +1,152 @@ +// +// HelloAnimation.swift +// DynamicIsland +// +// Created by Harsh Vardhan Goswami on 04/08/24. +// HelloAnimation.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 08/08/24. +// + +import SwiftUI + +struct HelloShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.size.width + let height = rect.size.height + path.move(to: CGPoint(x: 0.00095*width, y: 0.88718*height)) + path.addCurve(to: CGPoint(x: 0.19536*width, y: 0.31015*height), control1: CGPoint(x: 0.00993*width, y: 0.87738*height), control2: CGPoint(x: 0.16556*width, y: 0.56785*height)) + path.addCurve(to: CGPoint(x: 0.15043*width, y: 0.04964*height), control1: CGPoint(x: 0.22517*width, y: 0.05245*height), control2: CGPoint(x: 0.1859*width, y: -0.068*height)) + path.addCurve(to: CGPoint(x: 0.10028*width, y: 0.932*height), control1: CGPoint(x: 0.11495*width, y: 0.16729*height), control2: CGPoint(x: 0.09792*width, y: 1.02023*height)) + path.addCurve(to: CGPoint(x: 0.18354*width, y: 0.47822*height), control1: CGPoint(x: 0.10265*width, y: 0.84376*height), control2: CGPoint(x: 0.12157*width, y: 0.47822*height)) + path.addCurve(to: CGPoint(x: 0.22327*width, y: 0.88718*height), control1: CGPoint(x: 0.25733*width, y: 0.51463*height), control2: CGPoint(x: 0.19915*width, y: 0.81575*height)) + path.addCurve(to: CGPoint(x: 0.38553*width, y: 0.71351*height), control1: CGPoint(x: 0.2474*width, y: 0.95861*height), control2: CGPoint(x: 0.33586*width, y: 0.89978*height)) + path.addCurve(to: CGPoint(x: 0.35998*width, y: 0.45441*height), control1: CGPoint(x: 0.43519*width, y: 0.52724*height), control2: CGPoint(x: 0.38978*width, y: 0.4306*height)) + path.addCurve(to: CGPoint(x: 0.35478*width, y: 0.87317*height), control1: CGPoint(x: 0.33018*width, y: 0.47822*height), control2: CGPoint(x: 0.27956*width, y: 0.71631*height)) + path.addCurve(to: CGPoint(x: 0.53453*width, y: 0.62808*height), control1: CGPoint(x: 0.42999*width, y: 1.03004*height), control2: CGPoint(x: 0.51892*width, y: 0.6939*height)) + path.addCurve(to: CGPoint(x: 0.57332*width, y: 0.00623*height), control1: CGPoint(x: 0.55014*width, y: 0.56225*height), control2: CGPoint(x: 0.63955*width, y: 0.05805*height)) + path.addCurve(to: CGPoint(x: 0.48723*width, y: 0.60146*height), control1: CGPoint(x: 0.5071*width, y: -0.04559*height), control2: CGPoint(x: 0.48486*width, y: 0.50623*height)) + path.addCurve(to: CGPoint(x: 0.54588*width, y: 0.91239*height), control1: CGPoint(x: 0.48959*width, y: 0.6967*height), control2: CGPoint(x: 0.50378*width, y: 0.87597*height)) + path.addCurve(to: CGPoint(x: 0.70719*width, y: 0.51043*height), control1: CGPoint(x: 0.58798*width, y: 0.9488*height), control2: CGPoint(x: 0.6807*width, y: 0.64768*height)) + path.addCurve(to: CGPoint(x: 0.73273*width, y: 0.01323*height), control1: CGPoint(x: 0.73368*width, y: 0.37317*height), control2: CGPoint(x: 0.76679*width, y: 0.03984*height)) + path.addCurve(to: CGPoint(x: 0.67171*width, y: 0.15048*height), control1: CGPoint(x: 0.69868*width, y: -0.01338*height), control2: CGPoint(x: 0.68259*width, y: 0.07205*height)) + path.addCurve(to: CGPoint(x: 0.69678*width, y: 0.92639*height), control1: CGPoint(x: 0.66083*width, y: 0.22892*height), control2: CGPoint(x: 0.62204*width, y: 0.86057*height)) + path.addCurve(to: CGPoint(x: 0.87275*width, y: 0.47822*height), control1: CGPoint(x: 0.77152*width, y: 0.99222*height), control2: CGPoint(x: 0.78855*width, y: 0.42997*height)) + path.addCurve(to: CGPoint(x: 0.91438*width, y: 0.89776*height), control1: CGPoint(x: 0.9734*width, y: 0.51043*height), control2: CGPoint(x: 0.92329*width, y: 0.85998*height)) + path.addCurve(to: CGPoint(x: 0.79943*width, y: 0.69608*height), control1: CGPoint(x: 0.87047*width, y: 1.08403*height), control2: CGPoint(x: 0.77956*width, y: height)) + path.addCurve(to: CGPoint(x: 0.92006*width, y: 0.53081*height), control1: CGPoint(x: 0.81523*width, y: 0.45436*height), control2: CGPoint(x: 0.86282*width, y: 0.43277*height)) + path.addCurve(to: CGPoint(x: 0.99905*width, y: 0.432*height), control1: CGPoint(x: 0.95979*width, y: 0.57703*height), control2: CGPoint(x: 0.98959*width, y: 0.4944*height)) + return path + } +} + +extension ShapeStyle where Self == AngularGradient { + static var hello: some ShapeStyle { + LinearGradient( + stops: [ + .init(color: .blue, location: 0.0), + .init(color: .purple, location: 0.2), + .init(color: .red, location: 0.4), + .init(color: .mint, location: 0.5), + .init(color: .indigo, location: 0.7), + .init(color: .pink, location: 0.9), + .init(color: .blue, location: 1.0) + ], + startPoint: .leading, + endPoint: .trailing + ) + } +} + +struct GlowingSnake< + Content: Shape, + Fill: ShapeStyle +>: View, Animatable { + + var progress: Double + var delay: Double = 1.0 + var fill: Fill + var lineWidth = 4.0 + var blurRadius = 8.0 + + @ViewBuilder var shape: Content + + var animatableData: Double { + get { progress } + set { progress = newValue } + } + + var body: some View { + shape + .trim( + from: { + if progress > 1 - delay { + 2 * progress - 1.0 + } else if progress > delay { + progress - delay + } else { + .zero + } + }(), + to: progress + ) + .glow( + fill: fill, + lineWidth: lineWidth, + blurRadius: blurRadius + ) + } +} + +struct HelloAnimation: View { + @State private var progress: Double = 0.0 + + var body: some View { + GlowingSnake( + progress: progress, + fill: .hello, + lineWidth: 8, + blurRadius: 8.0, + shape: { HelloShape() } + ) + .onAppear { + withAnimation( + .easeInOut(duration: 4.0) + ) { + progress = 1.0 + } + } + } +} + +extension View where Self: Shape { + func glow( + fill: some ShapeStyle, + lineWidth: Double, + blurRadius: Double = 8.0, + lineCap: CGLineCap = .round + ) -> some View { + self + .stroke(style: StrokeStyle(lineWidth: lineWidth / 2, lineCap: lineCap)) + .fill(fill) + .overlay { + self + .stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: lineCap)) + .fill(fill) + .blur(radius: blurRadius) + } + .overlay { + self + .stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: lineCap)) + .fill(fill) + .blur(radius: blurRadius / 2) + } + } +} + +#Preview { + HelloAnimation() + .frame(width: 300, height: 100) +} diff --git a/DynamicIsland/animations/drop.swift b/DynamicIsland/animations/drop.swift new file mode 100644 index 0000000..eafce26 --- /dev/null +++ b/DynamicIsland/animations/drop.swift @@ -0,0 +1,29 @@ +// +// drop.swift +// DynamicIsland +// +// Created by Harsh Vardhan Goswami on 04/08/24. +// + +import Foundation +import SwiftUI + + +public class DynamicIslandAnimations { + @Published var notchStyle: Style = .notch + + init() { + self.notchStyle = .notch + } + + var animation: Animation { + if #available(macOS 14.0, *), notchStyle == .notch { + Animation.spring(.bouncy(duration: 0.4)) + } else { + Animation.timingCurve(0.16, 1, 0.3, 1, duration: 0.7) + } + } + + // TODO: Move all animations to this file + +} diff --git a/DynamicIsland/components/AnimatedFace.swift b/DynamicIsland/components/AnimatedFace.swift new file mode 100644 index 0000000..51913b7 --- /dev/null +++ b/DynamicIsland/components/AnimatedFace.swift @@ -0,0 +1,82 @@ +// +// AnimatedFace.swift +// +// Created by Harsh Vardhan Goswami on 04/08/24. +// + +import SwiftUI + +struct MinimalFaceFeatures: View { + @State private var isBlinking = false + @State var height:CGFloat = 20; + @State var width:CGFloat = 30; + + var body: some View { + VStack(spacing: 4) { // Adjusted spacing to fit within 30x30 + // Eyes + HStack(spacing: 4) { // Adjusted spacing to fit within 30x30 + Eye(isBlinking: $isBlinking) + Eye(isBlinking: $isBlinking) + } + + // Nose and mouth combined + VStack(spacing: 2) { // Adjusted spacing to fit within 30x30 + // Nose + RoundedRectangle(cornerRadius: 2) + .fill(Color.white) + .frame(width: 3, height: 4) + + // Mouth (happy) + GeometryReader { geometry in + Path { path in + let width = geometry.size.width + let height = geometry.size.height + path.move(to: CGPoint(x: 0, y: height / 2)) + path.addQuadCurve(to: CGPoint(x: width, y: height / 2), control: CGPoint(x: width / 2, y: height)) + } + .stroke(Color.white, lineWidth: 2) + } + .frame(width: 14, height: 10) + } + } + .frame(width: self.width, height: self.height) // Maximum size of face + .onAppear { + startBlinking() + } + } + + func startBlinking() { + Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in + withAnimation(.spring(duration: 0.2)) { + isBlinking = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.spring(duration: 0.2)) { + isBlinking = false + } + } + } + } +} + +struct Eye: View { + @Binding var isBlinking: Bool + + var body: some View { + RoundedRectangle(cornerRadius: 10) + .fill(Color.white) + .frame(width: 4, height: isBlinking ? 1 : 4) + .frame(maxWidth: 15, maxHeight: 15) // Adjusted max size + .animation(.easeInOut(duration: 0.1), value: isBlinking) + } +} + +struct MinimalFaceFeatures_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.black + MinimalFaceFeatures() + } + .previewLayout(.fixed(width: 60, height: 60)) // Adjusted preview size for better visibility + } +} diff --git a/DynamicIsland/components/BottomRoundedRectangle.swift b/DynamicIsland/components/BottomRoundedRectangle.swift new file mode 100644 index 0000000..fa951e7 --- /dev/null +++ b/DynamicIsland/components/BottomRoundedRectangle.swift @@ -0,0 +1,39 @@ + + +import SwiftUI + + +struct BottomRoundedRectangle: Shape { + var radius: CGFloat + + func path(in rect: CGRect) -> Path { + var path = Path() + + // Top left corner + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + + // Top right corner + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + + // Bottom right corner (rounded) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - radius)) + path.addArc(center: CGPoint(x: rect.maxX - radius, y: rect.maxY - radius), + radius: radius, + startAngle: Angle(degrees: 0), + endAngle: Angle(degrees: 90), + clockwise: false) + + // Bottom left corner (rounded) + path.addLine(to: CGPoint(x: rect.minX + radius, y: rect.maxY)) + path.addArc(center: CGPoint(x: rect.minX + radius, y: rect.maxY - radius), + radius: radius, + startAngle: Angle(degrees: 90), + endAngle: Angle(degrees: 180), + clockwise: false) + + // Back to top left to close the path + path.closeSubpath() + + return path + } +} diff --git a/DynamicIsland/components/Calender/BoringCalendar.swift b/DynamicIsland/components/Calender/BoringCalendar.swift new file mode 100644 index 0000000..1a68776 --- /dev/null +++ b/DynamicIsland/components/Calender/BoringCalendar.swift @@ -0,0 +1,315 @@ +// +// BoringCalender.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 08/09/24. +// + +import EventKit +import SwiftUI +import Defaults + +struct Config: Equatable { +// var count: Int = 10 // 3 days past + today + 7 days future + var past: Int = 3 + var future: Int = 7 + var steps: Int = 1 // Each step is one day + var spacing: CGFloat = 1 + var showsText: Bool = true + var offset: Int = 2 // Number of dates to the left of the selected date +} + +struct WheelPicker: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @Binding var selectedDate: Date + @State private var scrollPosition: Int? + @State private var haptics: Bool = false + @State private var byClick: Bool = false + let config: Config + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: config.spacing) { + let totalSteps = config.steps * (config.past + config.future) + let spacerNum = config.offset + ForEach(0.. totalSteps + spacerNum - 1){ + Spacer().frame(width: 24, height: 24).id(index) + } else { + let offset = -config.offset - config.past + let date = dateForIndex(index, offset: offset) + let isSelected = isDateSelected(index, offset: offset) + dateButton(date: date, isSelected: isSelected, offset: offset){ + selectedDate = date + byClick = true + withAnimation{ + scrollPosition = indexForDate(date, offset: offset) - config.offset + } + //haptics.toggle() // Causes double haptic when click + } + } + } + } + .frame(height: 50) + .scrollTargetLayout() + } + .scrollIndicators(.never) + .scrollPosition(id: $scrollPosition, anchor: .leading) + .scrollTargetBehavior(.viewAligned) // Ensures scroll view snaps to button center + .safeAreaPadding(.horizontal) + .sensoryFeedback(.alignment, trigger: haptics) + .onChange(of: scrollPosition) { oldValue, newValue in + if(!byClick){ + handleScrollChange(newValue: newValue, config: config) + }else{ + byClick = false + } + } + .onAppear { + scrollToToday(config: config) + } + } + + private func dateButton(date: Date, isSelected: Bool, offset: Int, onClick:@escaping()->Void) -> some View { + Button(action: onClick) { + VStack(spacing: 2) { + dayText(date: dateToString(for: date), isSelected: isSelected) + dateCircle(date: date, isSelected: isSelected) + } + } + .buttonStyle(PlainButtonStyle()) + .id(indexForDate(date, offset: offset)) + } + + private func dayText(date: String, isSelected: Bool) -> some View { + Text(date) + .font(.caption2) + .foregroundStyle( + isSelected ? Defaults[.accentColor] : .gray + ) + } + + private func dateCircle(date: Date, isSelected: Bool) -> some View { + ZStack { + Circle() + .fill(isSelected ? Defaults[.accentColor] : .clear) + .frame(width: 24, height: 24) + Text("\(date.date)") + .font(.title3) + .foregroundStyle(isSelected ? .white : .gray) + } + } + + func handleScrollChange(newValue: Int?, config: Config) { + let offset = -config.offset - config.past + let todayIndex = indexForDate(Date(), offset: offset) + guard let newIndex = newValue else { return } + let targetDateIndex = newIndex + config.offset + switch targetDateIndex{ + case todayIndex-config.past.. Int { + let calendar = Calendar.current + let startDate = calendar.startOfDay(for: calendar.date(byAdding: .day, value: offset, to: Date()) ?? Date()) + let targetDate = calendar.startOfDay(for: date) + let daysDifference = calendar.dateComponents([.day], from: startDate, to: targetDate).day ?? 0 + return daysDifference + } + + private func dateForIndex(_ index: Int, offset: Int) -> Date { + let startDate = Calendar.current.date(byAdding: .day, value: offset, to: Date()) ?? Date() + return Calendar.current.date(byAdding: .day, value: index, to: startDate) ?? Date() + } + + private func dayForIndex(_ index: Int, offset: Int) -> String { + let date = dateForIndex(index, offset: offset) + return dateToString(for: date) + } + + private func dateToString(for date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "E" + return formatter.string(from: date) + } + + private func isDateSelected(_ index: Int, offset: Int) -> Bool { + Calendar.current.isDate(dateForIndex(index, offset: offset), inSameDayAs: selectedDate) + } +} + +struct CalendarView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @StateObject private var calendarManager = CalendarManager() + @State private var selectedDate = Date() + + var body: some View { + VStack(spacing: 8) { + HStack { + Text("\(selectedDate, format: .dateTime.month())") + .font(.system(size: 18)) + .fontWeight(.semibold) + ZStack { + WheelPicker(selectedDate: $selectedDate, config: Config()) + HStack { + LinearGradient( + colors: [.black, .clear], startPoint: .leading, endPoint: .trailing + ) + .frame(width: 20) + Spacer() + LinearGradient( + colors: [.clear, .black], startPoint: .leading, endPoint: .trailing + ) + .frame(width: 20) + } + } + } + if calendarManager.events.isEmpty { + EmptyEventsView() + } else { + EventListView(events: calendarManager.events) + } + } + .listRowBackground(Color.clear) + .onChange(of: selectedDate) { _, newDate in + calendarManager.updateCurrentDate(newDate) + } + .onChange(of: vm.notchState) { _, _ in + calendarManager.updateCurrentDate(Date.now) + } + .onAppear { + calendarManager.updateCurrentDate(Date.now) + } + } +} + +struct EmptyEventsView: View { + var body: some View { + ScrollView { + Text("No events today") + .font(.headline) + .foregroundStyle(.white) + Text("Enjoy your free time!") + .font(.subheadline) + .foregroundColor(.gray) + } + } +} + +struct EventListView: View { + @Environment(\.openURL) private var openURL + let events: [EKEvent] + + var body: some View { + ScrollView(showsIndicators: false) { + HStack(alignment: .top) { + VStack(alignment: .trailing, spacing: 5) { + ForEach(events.indices, id: \.self) { index in + VStack(alignment: .trailing) { + if isAllDayEvent( + start: events[index].startDate, end: events[index].endDate) + { + Text("All-day") + } else { + Text("\(events[index].startDate, style: .time)") + Text("\(events[index].endDate, style: .time)") + } + } + .multilineTextAlignment(.trailing) + .padding(.bottom, 8) + .font(.caption2) + } + } + + VStack(alignment: .leading, spacing: 5) { + ForEach(events.indices, id: \.self) { index in + HStack(alignment: .top) { + VStack(spacing: 5) { + Image( + systemName: isEventEnded(events[index].endDate) + ? "checkmark.circle" : "circle" + ) + .foregroundColor(isEventEnded(events[index].endDate) ? .green : .gray) + .font(.footnote) + Rectangle() + .frame(width: 1) + .foregroundStyle(.gray.opacity(0.5)) + .opacity(index == events.count - 1 ? 0 : 1) + } + .padding(.top, 1) + + Button { + if let url = generateEventURL(for: events[index]) { + openURL(url) + } + } label: { + Text(events[index].title) + .font(.footnote) + .foregroundStyle(.gray) + } + .buttonStyle(.plain) + + Spacer(minLength: 0) + } + .opacity(isEventEnded(events[index].endDate) ? 0.6 : 1) + } + } + } + } + .scrollIndicators(.never) + .scrollTargetBehavior(.viewAligned) + } + + private func isAllDayEvent(start: Date, end: Date) -> Bool { + let calendar = Calendar.current + + let startComponents = calendar.dateComponents([.hour, .minute], from: start) + let endComponents = calendar.dateComponents([.hour, .minute], from: end) + + return startComponents.hour == 0 && startComponents.minute == 0 && endComponents.hour == 23 + && endComponents.minute == 59 + } + + private func isEventEnded(_ end: Date) -> Bool { + return Date.now > end + } + + private func generateEventURL(for event: EKEvent) -> URL? { + var dateComponent = "" + if event.hasRecurrenceRules { + if let startDate = event.startDate { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + formatter.timeZone = TimeZone.current + if !event.isAllDay { + formatter.timeZone = TimeZone(secondsFromGMT: 0) + } + dateComponent = "/\(formatter.string(from: startDate))" + } + } + return URL(string: "ical://ekevent\(dateComponent)/\(event.calendarItemIdentifier)?method=show&options=more") + } +} + +// Keep the CalendarManager, EmptyEventsView, and EventListView as they were in the previous implementation + +#Preview { + CalendarView() + .frame(width: 250) + .padding(.horizontal) + .background(.black) + .environmentObject(DynamicIslandViewModel()) +} diff --git a/DynamicIsland/components/DynamicIslandSystemTiles.swift b/DynamicIsland/components/DynamicIslandSystemTiles.swift new file mode 100644 index 0000000..367adb8 --- /dev/null +++ b/DynamicIsland/components/DynamicIslandSystemTiles.swift @@ -0,0 +1,109 @@ +// +// DynamicIslandSystemTiles.swift +// DynamicIsland +// +// Created by Harsh Vardhan Goswami on 04/08/24. +// BoringSystemTiles.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 16/08/24. +// + +import Foundation +import SwiftUI +import Defaults + +struct SystemItemButton: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @State var icon: String = "gear" + var onTap: () -> Void + @State var label: String? + @State var showEmojis: Bool = true + @State var emoji: String = "πŸ”§" + + var body: some View { + Button(action: onTap) { + if Defaults[.tileShowLabels] { + HStack { + if !showEmojis { + Image(systemName: icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 10) + .foregroundStyle(.gray) + } + + Text((showEmojis ? "\(emoji) " : "") + label!) + .font(.caption2) + .fontWeight(.regular) + .foregroundStyle(.gray) + .frame(maxWidth: .infinity, alignment: .leading) + .allowsTightening(true) + .minimumScaleFactor(0.7) + .lineLimit(1) + } + } else { + Color.clear + .overlay { + Image(systemName: icon) + .foregroundStyle(.gray) + } + .aspectRatio(1, contentMode: .fit) + } + } + .buttonStyle(BouncingButtonStyle(vm: vm)) + } +} + +func logout() { + DispatchQueue.global(qos: .background).async { + let appleScript = """ + tell application "System Events" to log out + """ + + var error: NSDictionary? + if let scriptObject = NSAppleScript(source: appleScript) { + scriptObject.executeAndReturnError(&error) + if let error = error { + print("Error: \(error)") + } + } + } +} + +struct DynamicIslandSystemTiles: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + + struct ItemButton { + var icon: String + var onTap: () -> Void + } + + var body: some View { + Grid { + GridRow { +// SystemItemButton(icon: "clipboard", onTap: { +// vm.openClipboard() +// }, label: "Clipboard History", showEmojis: Defaults[.showEmojis], emoji: "✨") + // SystemItemButton(icon: "keyboard", onTap: { + // vm?.close() + // vm?.togglesneakPeek(status: true, type: .backlight, value: 1) + // }, label: "πŸ’‘ Keyboard Backlight") + } + GridRow { + SystemItemButton(icon: coordinator.currentMicStatus ? "mic" : "mic.slash", onTap: { + coordinator.toggleMic() + vm.close() + }, label: "Toggle Microphone", showEmojis: Defaults[.showEmojis], emoji: coordinator.currentMicStatus ? "πŸ˜€" : "🀫") + // SystemItemButton(icon: "lock", onTap: { + // logout() + // }, label: "πŸ”’ Lock My Device") + } + } + } +} + +#Preview { + DynamicIslandSystemTiles().padding() +} diff --git a/DynamicIsland/components/EmptyState.swift b/DynamicIsland/components/EmptyState.swift new file mode 100644 index 0000000..9ca8292 --- /dev/null +++ b/DynamicIsland/components/EmptyState.swift @@ -0,0 +1,26 @@ +// +// EmptyState.swift +// +// Created by Harsh Vardhan Goswami on 04/08/24. +// + +import SwiftUI + +struct EmptyStateView: View { + var message: String + @State private var isVisible = true + + var body: some View { + HStack { + MinimalFaceFeatures( + height: 70, width: 80) + Text(message) + .font(.system(size:14)) + .foregroundColor(.gray) + }.transition(.blurReplace.animation(.spring(.bouncy(duration: 0.3)))) // Smooth animation + } +} + +#Preview { + EmptyStateView(message: "Play some music babies") +} diff --git a/DynamicIsland/components/HoverButton.swift b/DynamicIsland/components/HoverButton.swift new file mode 100644 index 0000000..7b68448 --- /dev/null +++ b/DynamicIsland/components/HoverButton.swift @@ -0,0 +1,46 @@ +// +// HoverButton.swift +// boringNotch +// +// Created by Kraigo on 04.09.2024. +// + +import SwiftUI + +struct HoverButton: View { + var icon: String + var iconColor: Color = .white; + var scale: Image.Scale = .medium + var action: () -> Void + var contentTransition: ContentTransition = .symbolEffect; + + @State private var isHovering = false + + var body: some View { + let size = CGFloat(scale == .large ? 40 : 30) + + Button(action: action) { + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + .frame(width: size, height: size) + .overlay { + Capsule() + .fill(isHovering ? Color.gray.opacity(0.2) : .clear) + .frame(width: size, height: size) + .overlay { + Image(systemName: icon) + .foregroundColor(iconColor) + .contentTransition(contentTransition) + .font(scale == .large ? .largeTitle : .body) + } + } + } + .buttonStyle(PlainButtonStyle()) + .onHover { hovering in + withAnimation(.smooth(duration: 0.3)) { + isHovering = hovering + } + } + } +} diff --git a/DynamicIsland/components/Live activities/DownloadView.swift b/DynamicIsland/components/Live activities/DownloadView.swift new file mode 100644 index 0000000..2e6104b --- /dev/null +++ b/DynamicIsland/components/Live activities/DownloadView.swift @@ -0,0 +1,52 @@ +// +// DownloadView.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 17/08/24. +// + +import Foundation +import SwiftUI + +enum Browser { + case safari + case chrome +} + +struct DownloadFile { + var name: String + var size: Int + var formattedSize: String + var browser: Browser +} + +class DownloadWatcher: ObservableObject { + @Published var downloadFiles: [DownloadFile] = [] +} + +struct DownloadArea: View { + @EnvironmentObject var watcher: DownloadWatcher + + var body: some View { + HStack(alignment: .center) { + HStack { + if watcher.downloadFiles.first!.browser == .safari { + AppIcon(for: "com.apple.safari") + } else { + Image(.chrome).resizable().scaledToFit().frame(width: 30, height: 30) + } + VStack(alignment: .leading) { + Text("Download") + Text("In progress").font(.system(.footnote)).foregroundStyle(.gray) + } + } + Spacer() + HStack(spacing: 12) { + VStack(alignment: .trailing) { + Text(watcher.downloadFiles.first!.formattedSize) + Text(watcher.downloadFiles.first!.name).font(.caption2).foregroundStyle(.gray) + } + } + } + } +} diff --git a/DynamicIsland/components/Live activities/DynamicIslandBattery.swift b/DynamicIsland/components/Live activities/DynamicIslandBattery.swift new file mode 100644 index 0000000..997b89a --- /dev/null +++ b/DynamicIsland/components/Live activities/DynamicIslandBattery.swift @@ -0,0 +1,256 @@ +import SwiftUI +import Defaults + +/// A view that displays the battery status with an icon and charging indicator. +struct BatteryView: View { + + var levelBattery: Float + var isPluggedIn: Bool + var isCharging: Bool + var isInLowPowerMode: Bool + var batteryWidth: CGFloat = 26 + var isForNotification: Bool + + var animationStyle: BoringAnimations = BoringAnimations() + + var icon: String = "battery.0" + + /// Determines the icon to display when charging. + var iconStatus: String { + if isCharging { + return "bolt" + } + else if isPluggedIn { + return "plug" + } + else { + return "" + } + } + + /// Determines the color of the battery based on its status. + var batteryColor: Color { + if isInLowPowerMode { + return .yellow + } else if levelBattery <= 20 && !isCharging && !isPluggedIn { + return .red + } else if isCharging || isPluggedIn || levelBattery == 100 { + return .green + } else { + return .white + } + } + + var body: some View { + ZStack(alignment: .leading) { + + Image(systemName: icon) + .resizable() + .fontWeight(.thin) + .aspectRatio(contentMode: .fit) + .foregroundColor(.white.opacity(0.5)) + .frame( + width: batteryWidth + 1 + ) + + RoundedRectangle(cornerRadius: 2.5) + .fill(batteryColor) + .frame( + width: CGFloat(((CGFloat(CFloat(levelBattery)) / 100) * (batteryWidth - 6))), + height: (batteryWidth - 2.75) - 18 + ) + .padding(.leading, 2) + + if iconStatus != "" && (isForNotification || Defaults[.showPowerStatusIcons]) { + ZStack { + Image(iconStatus) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.white) + .frame( + width: 17, + height: 17 + ) + } + .frame(width: batteryWidth, height: batteryWidth) + } + } + } +} + +/// A view that displays detailed battery information and settings. +struct BatteryMenuView: View { + + var isPluggedIn: Bool + var isCharging: Bool + var levelBattery: Float + var maxCapacity: Float + var timeToFullCharge: Int + var isInLowPowerMode: Bool + var onDismiss: () -> Void + + @Environment(\.openURL) private var openURL + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + + HStack { + Text("Battery Status") + .font(.headline) + .fontWeight(.semibold) + Spacer() + Text("\(Int(levelBattery))%") + .font(.headline) + .fontWeight(.semibold) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Max Capacity: \(Int(maxCapacity))%") + .font(.subheadline) + .fontWeight(.regular) + if isInLowPowerMode { + Label("Low Power Mode", systemImage: "bolt.circle") + .font(.subheadline) + .fontWeight(.regular) + } + if isCharging { + Label("Charging", systemImage: "bolt.fill") + .font(.subheadline) + .fontWeight(.regular) + } + if isPluggedIn { + Label("Plugged In", systemImage: "powerplug.fill") + .font(.subheadline) + .fontWeight(.regular) + } + if timeToFullCharge > 0 { + Label("Time to Full Charge: \(timeToFullCharge) min", systemImage: "clock") + .font(.subheadline) + .fontWeight(.regular) + } + if !isCharging && isPluggedIn && levelBattery >= 80 { + Label("Charging on Hold: Desktop Mode", systemImage: "desktopcomputer") + .font(.subheadline) + .fontWeight(.regular) + } + + } + .padding(.vertical, 8) + + Divider().background(Color.white) + + Button(action: openBatteryPreferences) { + Label("Battery Settings", systemImage: "gearshape") + .fontWeight(.regular) + } + .frame(maxWidth: .infinity) + .buttonStyle(.plain) + .padding(.vertical, 8) + } + .padding() + .frame(width: 280) + .foregroundColor(.white) + } + + private func openBatteryPreferences() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.battery") { + openURL(url) + onDismiss() + } + } +} + + +/// A view that displays the battery status and allows interaction to show detailed information. +struct DynamicIslandBatteryView: View { + + @State var batteryWidth: CGFloat = 26 + var isCharging: Bool = false + var isInLowPowerMode: Bool = false + var isPluggedIn: Bool = false + var levelBattery: Float = 0 + var maxCapacity: Float = 0 + var timeToFullCharge: Int = 0 + @State var isForNotification: Bool = false + + @State private var showPopupMenu: Bool = false + @State private var isPressed: Bool = false + @State private var isHoveringPopover: Bool = false + + @EnvironmentObject var vm: DynamicIslandViewModel + + var body: some View { + HStack { + if Defaults[.showBatteryPercentage] { + Text("\(Int32(levelBattery))%") + .font(.callout) + .foregroundStyle(.white) + } + BatteryView( + levelBattery: levelBattery, + isPluggedIn: isPluggedIn, + isCharging: isCharging, + isInLowPowerMode: isInLowPowerMode, + batteryWidth: batteryWidth, + isForNotification: isForNotification + ) + } + .scaleEffect(isPressed ? 0.95 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isPressed) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + withAnimation { + isPressed = true + } + } + .onEnded { _ in + withAnimation { + isPressed = false + showPopupMenu.toggle() + } + } + ) + .popover( + isPresented: $showPopupMenu, + arrowEdge: .bottom) { + BatteryMenuView( + isPluggedIn: isPluggedIn, + isCharging: isCharging, + levelBattery: levelBattery, + maxCapacity: maxCapacity, + timeToFullCharge: timeToFullCharge, + isInLowPowerMode: isInLowPowerMode, + onDismiss: { + showPopupMenu = false + } + ) + .onHover { hovering in + isHoveringPopover = hovering + } + } + .onChange(of: showPopupMenu) { _, _ in + updateBatteryPopoverActiveState() + } + .onChange(of: isHoveringPopover) { _, _ in + updateBatteryPopoverActiveState() + } + } + + private func updateBatteryPopoverActiveState() { + vm.isBatteryPopoverActive = showPopupMenu && isHoveringPopover + } +} + +#Preview { + DynamicIslandBatteryView( + batteryWidth: 30, + isCharging: false, + isInLowPowerMode: false, + isPluggedIn: true, + levelBattery: 80, + maxCapacity: 100, + timeToFullCharge: 10, + isForNotification: false + ).frame(width: 200, height: 200) +} diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift new file mode 100644 index 0000000..5fbedc7 --- /dev/null +++ b/DynamicIsland/components/Live activities/InlineHUD.swift @@ -0,0 +1,147 @@ +// +// InlineHUDs.swift +// boringNotch +// +// Created by Richard Kunkli on 14/09/2024. +// + +import SwiftUI + +struct InlineHUD: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @Binding var type: SneakContentType + @Binding var value: CGFloat + @Binding var icon: String + @Binding var hoverAnimation: Bool + @Binding var gestureProgress: CGFloat + var body: some View { + HStack { + HStack(spacing: 5) { + Group { + switch (type) { + case .volume: + if icon.isEmpty { + Image(systemName: SpeakerSymbol(value)) + .contentTransition(.interpolate) + .symbolVariant(value > 0 ? .none : .slash) + .frame(width: 20, height: 15, alignment: .leading) + } else { + Image(systemName: icon) + .contentTransition(.interpolate) + .opacity(value.isZero ? 0.6 : 1) + .scaleEffect(value.isZero ? 0.85 : 1) + .frame(width: 20, height: 15, alignment: .leading) + } + case .brightness: + Image(systemName: BrightnessSymbol(value)) + .contentTransition(.interpolate) + .frame(width: 20, height: 15, alignment: .center) + case .backlight: + Image(systemName: "keyboard") + .contentTransition(.interpolate) + .frame(width: 20, height: 15, alignment: .center) + case .mic: + Image(systemName: "mic") + .symbolRenderingMode(.hierarchical) + .symbolVariant(value > 0 ? .none : .slash) + .contentTransition(.interpolate) + .frame(width: 20, height: 15, alignment: .center) + default: + EmptyView() + } + } + .foregroundStyle(.white) + .symbolVariant(.fill) + + Text(Type2Name(type)) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + .allowsTightening(true) + .contentTransition(.numericText()) + } + .frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading) + + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width - 20) + + HStack { + if (type == .mic) { + Text(value.isZero ? "muted" : "unmuted") + .foregroundStyle(.gray) + .lineLimit(1) + .allowsTightening(true) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity, alignment: .trailing) + .contentTransition(.interpolate) + } else { + HStack { + DraggableProgressBar(value: $value) + if (type == .volume && value.isZero) { + Text("muted") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.gray) + .lineLimit(1) + .allowsTightening(true) + .multilineTextAlignment(.trailing) + } + } + } + } + .padding(.trailing, 4) + .frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center) + } + .frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center) + } + + func SpeakerSymbol(_ value: CGFloat) -> String { + switch(value) { + case 0: + return "speaker" + case 0...0.3: + return "speaker.wave.1" + case 0.3...0.8: + return "speaker.wave.2" + case 0.8...1: + return "speaker.wave.3" + default: + return "speaker.wave.2" + } + } + + func BrightnessSymbol(_ value: CGFloat) -> String { + switch(value) { + case 0...0.6: + return "sun.min" + case 0.6...1: + return "sun.max" + default: + return "sun.min" + } + } + + func Type2Name(_ type: SneakContentType) -> String { + switch(type) { + case .volume: + return "Volume" + case .brightness: + return "Brightness" + case .backlight: + return "Backlight" + case .mic: + return "Mic" + default: + return "" + } + } +} + +#Preview { + InlineHUD(type: .constant(.brightness), value: .constant(0.4), icon: .constant(""), hoverAnimation: .constant(false), gestureProgress: .constant(0)) + .padding(.horizontal, 8) + .background(Color.black) + .padding() + .environmentObject(DynamicIslandViewModel()) +} diff --git a/DynamicIsland/components/Live activities/LiveActivityModifier.swift b/DynamicIsland/components/Live activities/LiveActivityModifier.swift new file mode 100644 index 0000000..e10fc0c --- /dev/null +++ b/DynamicIsland/components/Live activities/LiveActivityModifier.swift @@ -0,0 +1,43 @@ +// +// LiveActivityModifier.swift +// boringNotch +// +// Created by Richard Kunkli on 12/08/2024. +// + +import SwiftUI + +enum ActivityType { + case mediaPlayback + case charging + case download +} + +struct LiveActivityModifier: ViewModifier { + let `for`: ActivityType + let leftContent: () -> Left + let rightContent: () -> Right + + func body(content: Content) -> some View { + content + .overlay( + HStack { + leftContent() + Spacer() + //.frame(minWidth: vm.closedNotchSize.width) + rightContent() + } + .padding() + ) + } +} + +extension View { + func liveActivity( + for activityId: ActivityType, + @ViewBuilder left: @escaping () -> Left, + @ViewBuilder right: @escaping () -> Right + ) -> some View { + self.modifier(LiveActivityModifier(for: activityId, leftContent: left, rightContent: right)) + } +} diff --git a/DynamicIsland/components/Live activities/MarqueeTextView.swift b/DynamicIsland/components/Live activities/MarqueeTextView.swift new file mode 100644 index 0000000..4bb7bf3 --- /dev/null +++ b/DynamicIsland/components/Live activities/MarqueeTextView.swift @@ -0,0 +1,93 @@ +// +// MarqueeTextView.swift +// boringNotch +// +// Created by Richard Kunkli on 08/08/2024. +// + +import SwiftUI + +struct SizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } +} + +struct MeasureSizeModifier: ViewModifier { + func body(content: Content) -> some View { + content.background(GeometryReader { geometry in + Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size) + }) + } +} + +struct MarqueeText: View { + @Binding var text: String + let font: Font + let nsFont: NSFont.TextStyle + let textColor: Color + let backgroundColor: Color + let minDuration: 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 + self.font = font + self.nsFont = nsFont + self.textColor = textColor + self.backgroundColor = backgroundColor + self.minDuration = minDuration + self.frameWidth = frameWidth + } + + private var needsScrolling: Bool { + textSize.width > frameWidth + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + HStack(spacing: 20) { + Text(text) + Text(text) + .opacity(needsScrolling ? 1 : 0) + } + .id(text) + .font(font) + .foregroundColor(textColor) + .fixedSize(horizontal: true, vertical: false) + .offset(x: self.animate ? offset : 0) + .animation( + self.animate ? + .linear(duration: Double(textSize.width / 30)) + .delay(minDuration) + .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) + self.animate = false + self.offset = 0 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01){ + if needsScrolling { + self.animate = true + self.offset = -(textSize.width + 10) + + } + } + } + } + .frame(width: frameWidth, alignment: .leading) + .clipped() + } + .frame(height: textSize.height * 1.3) + + } +} diff --git a/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift b/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift new file mode 100644 index 0000000..db2f196 --- /dev/null +++ b/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift @@ -0,0 +1,144 @@ + // + // SystemEventIndicatorModifier.swift + // boringNotch + // + // Created by Richard Kunkli on 12/08/2024. + // + +import SwiftUI +import Defaults + +struct SystemEventIndicatorModifier: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @Binding var eventType: SneakContentType + @Binding var value: CGFloat { + didSet { + DispatchQueue.main.async { + self.sendEventBack(value) + self.vm.objectWillChange.send() + } + } + } + @Binding var icon: String + let showSlider: Bool = false + var sendEventBack: (CGFloat) -> Void + + var body: some View { + HStack(spacing: 14) { + switch (eventType) { + case .volume: + if icon.isEmpty { + Image(systemName: SpeakerSymbol(value)) + .contentTransition(.interpolate) + .symbolVariant(value > 0 ? .none : .slash) + .frame(width: 20, height: 15, alignment: .leading) + } else { + Image(systemName: icon) + .contentTransition(.interpolate) + .opacity(value.isZero ? 0.6 : 1) + .scaleEffect(value.isZero ? 0.85 : 1) + .frame(width: 20, height: 15, alignment: .leading) + } + case .brightness: + Image(systemName: "sun.max.fill") + .contentTransition(.symbolEffect) + .frame(width: 20, height: 15) + .foregroundStyle(.white) + case .backlight: + Image(systemName: "keyboard") + .contentTransition(.symbolEffect) + .frame(width: 20, height: 15) + .foregroundStyle(.white) + case .mic: + Image(systemName: "mic") + .symbolVariant(value > 0 ? .none : .slash) + .contentTransition(.interpolate) + .frame(width: 20, height: 15) + .foregroundStyle(.white) + default: + EmptyView() + } + if (eventType != .mic) { + DraggableProgressBar(value: $value) + } else { + Text("Mic \(value > 0 ? "unmuted" : "muted")") + .foregroundStyle(.gray) + .lineLimit(1) + .allowsTightening(true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .symbolVariant(.fill) + .imageScale(.large) + } + + func SpeakerSymbol(_ value: CGFloat) -> String { + switch(value) { + case 0: + return "speaker.slash" + case 0...0.3: + return "speaker.wave.1" + case 0.3...0.8: + return "speaker.wave.2" + case 0.8...1: + return "speaker.wave.3" + default: + return "speaker.wave.2" + } + } +} + +struct DraggableProgressBar: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @Binding var value: CGFloat + + @State private var isDragging = false + @State private var dragOffset: CGFloat = 0 + + var body: some View { + VStack { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(.tertiary) + Group { + if Defaults[.enableGradient] { + Capsule() + .fill(LinearGradient(colors: Defaults[.systemEventIndicatorUseAccent] ? [Defaults[.accentColor], Defaults[.accentColor].ensureMinimumBrightness(factor: 0.2)] : [.white, .white.opacity(0.2)], startPoint: .trailing, endPoint: .leading)) + .frame(width: max(0, min(geo.size.width * value, geo.size.width))) + .shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3) + } else { + Capsule() + .fill(Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor] : .white) + .frame(width: max(0, min(geo.size.width * value, geo.size.width))) + .shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3) + } + } + .opacity(value.isZero ? 0 : 1) + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { gesture in + withAnimation(.smooth(duration: 0.3)) { + isDragging = true + updateValue(gesture: gesture, in: geo) + } + } + .onEnded { _ in + withAnimation(.smooth(duration: 0.3)) { + isDragging = false + } + } + ) + } + .frame(height: Defaults[.inlineHUD] ? isDragging ? 8 : 5 : isDragging ? 9 : 6) + } + } + + private func updateValue(gesture: DragGesture.Value, in geometry: GeometryProxy) { + let dragPosition = gesture.location.x + let newValue = dragPosition / geometry.size.width + + value = max(0, min(newValue, 1)) + } +} diff --git a/DynamicIsland/components/Music/LottieAnimationView.swift b/DynamicIsland/components/Music/LottieAnimationView.swift new file mode 100644 index 0000000..b0b7433 --- /dev/null +++ b/DynamicIsland/components/Music/LottieAnimationView.swift @@ -0,0 +1,33 @@ +// +// LottieAnimationView.swift +// boringNotch +// +// Created by Richard Kunkli on 2024. 10. 29.. +// + +import SwiftUI +import Lottie +import LottieUI +import Defaults + +struct LottieAnimationView: View { + let state1 = LUStateData(type: .loadedFrom(URL(string: "https://assets9.lottiefiles.com/packages/lf20_mniampqn.json")!), speed: 1.0, loopMode: .loop) + @Default(.selectedVisualizer) var selectedVisualizer + var body: some View { + if selectedVisualizer == nil { + LottieView(state: state1) + } else { + LottieView( + state: LUStateData( + type: .loadedFrom(selectedVisualizer!.url), + speed: selectedVisualizer!.speed, + loopMode: .loop + ) + ) + } + } +} + +#Preview { + LottieAnimationView() +} diff --git a/DynamicIsland/components/Music/MusicVisualizer.swift b/DynamicIsland/components/Music/MusicVisualizer.swift new file mode 100644 index 0000000..a9baf98 --- /dev/null +++ b/DynamicIsland/components/Music/MusicVisualizer.swift @@ -0,0 +1,115 @@ +// +// MusicVisualizer.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 02/08/24. +// +import AppKit +import Cocoa +import SwiftUI + +class AudioSpectrum: NSView { + private var barLayers: [CAShapeLayer] = [] + private var isPlaying: Bool = true + private var animationTimer: Timer? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + setupBars() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + setupBars() + } + + 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 { + let xPosition = CGFloat(i) * (barWidth + spacing) + let barLayer = CAShapeLayer() + barLayer.frame = CGRect(x: xPosition, y: 0, width: barWidth, height: totalHeight) + barLayer.position = CGPoint(x: xPosition + barWidth / 2, y: totalHeight / 2) + barLayer.fillColor = NSColor.white.cgColor + + let path = NSBezierPath(roundedRect: CGRect(x: 0, y: 0, width: barWidth, height: totalHeight), + xRadius: barWidth / 2, + yRadius: barWidth / 2) + barLayer.path = path.cgPath + + barLayers.append(barLayer) + layer?.addSublayer(barLayer) + } + } + + private func startAnimating() { + guard animationTimer == nil else { return } + animationTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] _ in + self?.updateBars() + } + } + + private func stopAnimating() { + animationTimer?.invalidate() + animationTimer = nil + resetBars() + } + + private func updateBars() { + for barLayer in barLayers { + let animation = CABasicAnimation(keyPath: "transform.scale.y") + animation.fromValue = barLayer.presentation()?.value(forKeyPath: "transform.scale.y") ?? 0.35 + animation.toValue = CGFloat.random(in: 0.35 ... 1.0) + animation.duration = 0.3 + animation.autoreverses = true + animation.fillMode = .forwards + animation.isRemovedOnCompletion = false + + barLayer.add(animation, forKey: "scaleY") + } + } + + private func resetBars() { + for barLayer in barLayers { + barLayer.removeAllAnimations() + barLayer.transform = CATransform3DMakeScale(1, 0.35, 1) + } + } + + func setPlaying(_ playing: Bool) { + isPlaying = playing + if isPlaying { + startAnimating() + } else { + stopAnimating() + } + } +} + +struct AudioSpectrumView: NSViewRepresentable { + @Binding var isPlaying: Bool + + func makeNSView(context: Context) -> AudioSpectrum { + let spectrum = AudioSpectrum() + spectrum.setPlaying(isPlaying) + return spectrum + } + + func updateNSView(_ nsView: AudioSpectrum, context: Context) { + nsView.setPlaying(isPlaying) + } +} + +#Preview { + AudioSpectrumView(isPlaying: .constant(true)) + .frame(width: 16, height: 20) + .padding() +} diff --git a/DynamicIsland/components/Notch/BoringExtrasMenu.swift b/DynamicIsland/components/Notch/BoringExtrasMenu.swift new file mode 100644 index 0000000..101c2da --- /dev/null +++ b/DynamicIsland/components/Notch/BoringExtrasMenu.swift @@ -0,0 +1,107 @@ +// +// BoringExtrasMenu.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 04/08/24. +// + +import SwiftUI + +struct BoringLargeButtons: View { + var action: () -> Void + var icon: Image + var title: String + var body: some View { + Button ( + action:action, + label: { + ZStack { + RoundedRectangle(cornerRadius: 12.0).fill(.black).frame(width: 70, height: 70) + VStack(spacing: 8) { + icon.resizable() + .aspectRatio(contentMode: .fit).frame(width:20) + Text(title).font(.body) + } + } + }).buttonStyle(PlainButtonStyle()).shadow(color: .black.opacity(0.5), radius: 10) + } +} + +struct BoringExtrasMenu : View { + @ObservedObject var vm: DynamicIslandViewModel + + var body: some View { + VStack{ + HStack(spacing: 20) { + hide + settings + close + } + } + } + + var github: some View { + BoringLargeButtons( + action: { + NSWorkspace.shared.open(productPage) + }, + icon: Image(.github), + title: "Checkout" + ) + } + + var donate: some View { + BoringLargeButtons( + action: { + NSWorkspace.shared.open(sponsorPage) + }, + icon: Image(systemName: "heart.fill"), + title: "Love Us" + ) + } + + var settings: some View { + SettingsLink(label: { + ZStack { + RoundedRectangle(cornerRadius: 12.0).fill(.black).frame(width: 70, height: 70) + VStack(spacing: 8) { + Image(systemName: "gear").resizable() + .aspectRatio(contentMode: .fit).frame(width:20) + Text("Settings").font(.body) + } + } + }) + .buttonStyle(PlainButtonStyle()).shadow(color: .black.opacity(0.5), radius: 10) + } + + var hide: some View { + BoringLargeButtons( + action: { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + //vm.openMusic() + } + }, + icon: Image(systemName: "arrow.down.forward.and.arrow.up.backward"), + title: "Hide" + ) + } + + var close: some View { + BoringLargeButtons( + action: { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + NSApp.terminate(nil) + } + } + }, + icon: Image(systemName: "xmark"), + title: "Exit" + ) + } +} + + +#Preview { + BoringExtrasMenu(vm: .init()) +} diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift new file mode 100644 index 0000000..4c1177c --- /dev/null +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -0,0 +1,85 @@ +// +// DynamicIslandHeader.swift +// DynamicIsland +// +// Created by Harsh Vardhan Goswami on 04/08/24. +// + +import Defaults +import SwiftUI + +struct DynamicIslandHeader: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var batteryModel = BatteryStatusViewModel.shared + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @StateObject var tvm = TrayDrop.shared + var body: some View { + HStack(spacing: 0) { + HStack { + if (!tvm.isEmpty || coordinator.alwaysShowTabs) && Defaults[.dynamicShelf] { + TabSelectionView() + } else if vm.notchState == .open { + EmptyView() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(vm.notchState == .closed ? 0 : 1) + .blur(radius: vm.notchState == .closed ? 20 : 0) + .animation(.smooth.delay(0.1), value: vm.notchState) + .zIndex(2) + + if vm.notchState == .open { + Rectangle() + .fill(NSScreen.screens + .first(where: { $0.localizedName == coordinator.selectedScreen })?.safeAreaInsets.top ?? 0 > 0 ? .black : .clear) + .frame(width: vm.closedNotchSize.width) + .mask { + NotchShape() + } + } + + HStack(spacing: 4) { + if vm.notchState == .open { + if Defaults[.settingsIconInNotch] { + SettingsLink(label: { + Capsule() + .fill(.black) + .frame(width: 30, height: 30) + .overlay { + Image(systemName: "gear") + .foregroundColor(.white) + .padding() + .imageScale(.medium) + } + }) + .buttonStyle(PlainButtonStyle()) + } + if Defaults[.showBatteryIndicator] { + DynamicIslandBatteryView( + batteryWidth: 30, + isCharging: batteryModel.isCharging, + isInLowPowerMode: batteryModel.isInLowPowerMode, + isPluggedIn: batteryModel.isPluggedIn, + levelBattery: batteryModel.levelBattery, + maxCapacity: batteryModel.maxCapacity, + timeToFullCharge: batteryModel.timeToFullCharge, + isForNotification: false + ) + } + } + } + .font(.system(.headline, design: .rounded)) + .frame(maxWidth: .infinity, alignment: .trailing) + .opacity(vm.notchState == .closed ? 0 : 1) + .blur(radius: vm.notchState == .closed ? 20 : 0) + .animation(.smooth.delay(0.1), value: vm.notchState) + .zIndex(2) + } + .foregroundColor(.gray) + .environmentObject(vm) + } +} + +#Preview { + DynamicIslandHeader().environmentObject(DynamicIslandViewModel()) +} diff --git a/DynamicIsland/components/Notch/DynamicIslandWindow.swift b/DynamicIsland/components/Notch/DynamicIslandWindow.swift new file mode 100644 index 0000000..df55f02 --- /dev/null +++ b/DynamicIsland/components/Notch/DynamicIslandWindow.swift @@ -0,0 +1,50 @@ +// +// DynamicIslandWindow.swift +// DynamicIsland +// +// Created by Harsh Vardhan Goswami on 06/08/24. +// + +import Cocoa + +class DynamicIslandWindow: NSPanel { + override init( + contentRect: NSRect, + styleMask: NSWindow.StyleMask, + backing: NSWindow.BackingStoreType, + defer flag: Bool + ) { + super.init( + contentRect: contentRect, + styleMask: styleMask, + backing: backing, + defer: flag + ) + + isFloatingPanel = true + isOpaque = false + titleVisibility = .hidden + titlebarAppearsTransparent = true + backgroundColor = .clear + isMovable = false + + collectionBehavior = [ + .fullScreenAuxiliary, + .stationary, + .canJoinAllSpaces, + .ignoresCycle, + ] + + isReleasedWhenClosed = false + level = .mainMenu + 3 + hasShadow = false + } + + override var canBecomeKey: Bool { + false + } + + override var canBecomeMain: Bool { + false + } +} diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift new file mode 100644 index 0000000..86efe80 --- /dev/null +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -0,0 +1,342 @@ +// +// NotchHomeView.swift +// boringNotch +// +// Created by Hugo Persson on 2024-08-18. +// Modified by Harsh Vardhan Goswami & Richard Kunkli +// + +import Combine +import Defaults +import SwiftUI + +// MARK: - Music Player Components + +struct MusicPlayerView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + let albumArtNamespace: Namespace.ID + + var body: some View { + HStack { + AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace) + MusicControlsView().drawingGroup().compositingGroup() + } + } +} + +struct AlbumArtView: View { + @ObservedObject var musicManager = MusicManager.shared + @ObservedObject var vm: DynamicIslandViewModel + let albumArtNamespace: Namespace.ID + + var body: some View { + ZStack(alignment: .bottomTrailing) { + if Defaults[.lightingEffect] { + albumArtBackground + } + albumArtButton + } + } + + private var albumArtBackground: some View { + Color.clear + .aspectRatio(1, contentMode: .fit) + .background( + Image(nsImage: musicManager.albumArt) + .resizable() + .aspectRatio(contentMode: .fill) + ) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.opened : MusicPlayerImageSizes.cornerRadiusInset.closed)) + .scaleEffect(x: 1.3, y: 1.4) + .rotationEffect(.degrees(92)) + .blur(radius: 35) + .opacity(min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3))) + } + + private var albumArtButton: some View { + Button { + musicManager.openMusicApp() + } label: { + ZStack(alignment: .bottomTrailing) { + albumArtImage + appIconOverlay + } + } + .buttonStyle(PlainButtonStyle()) + .opacity(musicManager.isPlaying ? 1 : 0.4) + .scaleEffect(musicManager.isPlaying ? 1 : 0.85) + } + + private var albumArtImage: some View { + Color.clear + .aspectRatio(1, contentMode: .fit) + .background( + Image(nsImage: musicManager.albumArt) + .resizable() + .aspectRatio(contentMode: .fill) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: musicManager.isFlipping) + ) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.opened : MusicPlayerImageSizes.cornerRadiusInset.closed)) + .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) + } + + @ViewBuilder + private var appIconOverlay: some View { + if vm.notchState == .open && !musicManager.usingAppIconForArtwork { + AppIcon(for: musicManager.bundleIdentifier ?? "com.apple.Music") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 30, height: 30) + .offset(x: 10, y: 10) + .transition(.scale.combined(with: .opacity).animation(.bouncy.delay(0.3))) + } + } +} + +struct MusicControlsView: View { + @ObservedObject var musicManager = MusicManager.shared + @State private var sliderValue: Double = 0 + @State private var dragging: Bool = false + @State private var lastDragged: Date = .distantPast + + var body: some View { + VStack(alignment: .leading) { + songInfoAndSlider + playbackControls + } + .buttonStyle(PlainButtonStyle()) + .frame(minWidth: Defaults[.showMirror] && Defaults[.showCalendar] ? 140 : 180) + } + + private var songInfoAndSlider: some View { + GeometryReader { geo in + VStack(alignment: .leading, spacing: 4) { + songInfo(width: geo.size.width) + musicSlider + } + } + .padding(.top, 10) + .padding(.leading, 5) + } + + private func songInfo(width: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 0) { + MarqueeText($musicManager.songTitle, font: .headline, nsFont: .headline, textColor: .white, frameWidth: width) + MarqueeText( + $musicManager.artistName, + font: .headline, + nsFont: .headline, + textColor: Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor) + .ensureMinimumBrightness(factor: 0.6) : .gray, + frameWidth: width + ) + .fontWeight(.medium) + } + } + + private var musicSlider: some View { + TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in + MusicSliderView( + sliderValue: $sliderValue, + duration: $musicManager.songDuration, + lastDragged: $lastDragged, + color: musicManager.avgColor, + dragging: $dragging, + currentDate: timeline.date, + lastUpdated: musicManager.lastUpdated, + ignoreLastUpdated: musicManager.ignoreLastUpdated, + timestampDate: musicManager.timestampDate, + elapsedTime: musicManager.elapsedTime, + playbackRate: musicManager.playbackRate, + isPlaying: musicManager.isPlaying + ) { newValue in + MusicManager.shared.seek(to: newValue) + } + .padding(.top, 5) + .frame(height: 36) + } + } + + private var playbackControls: some View { + HStack(spacing: 8) { + HoverButton(icon: "backward.fill", scale: .medium) { + MusicManager.shared.previousTrack() + } + HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) { + MusicManager.shared.togglePlay() + } + HoverButton(icon: "forward.fill", scale: .medium) { + MusicManager.shared.nextTrack() + } + } + .frame(maxWidth: .infinity, alignment: .center) + } +} + +// MARK: - Main View + +struct NotchHomeView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @EnvironmentObject var webcamManager: WebcamManager + @ObservedObject var batteryModel = BatteryStatusViewModel.shared + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + let albumArtNamespace: Namespace.ID + + var body: some View { + Group { + if !coordinator.firstLaunch { + mainContent + } + } + .transition(.opacity.combined(with: .blurReplace)) + } + + private var mainContent: some View { + HStack(alignment: .top, spacing: 20) { + MusicPlayerView(albumArtNamespace: albumArtNamespace) + + if Defaults[.showCalendar] { + CalendarView() + .onHover { isHovering in + vm.isHoveringCalendar = isHovering + } + .environmentObject(vm) + } + + if Defaults[.showMirror] && webcamManager.cameraAvailable { + CameraPreviewView(webcamManager: webcamManager) + .scaledToFit() + .opacity(vm.notchState == .closed ? 0 : 1) + .blur(radius: vm.notchState == .closed ? 20 : 0) + } + } + .transition(.opacity.animation(.smooth.speed(0.9)) + .combined(with: .blurReplace.animation(.smooth.speed(0.9))) + .combined(with: .move(edge: .top))) + .blur(radius: vm.notchState == .closed ? 30 : 0) + } +} + +struct MusicSliderView: View { + @Binding var sliderValue: Double + @Binding var duration: Double + @Binding var lastDragged: Date + var color: NSColor + @Binding var dragging: Bool + let currentDate: Date + let lastUpdated: Date + let ignoreLastUpdated: Bool + let timestampDate: Date + let elapsedTime: Double + let playbackRate: Double + let isPlaying: Bool + var onValueChange: (Double) -> Void + + var currentElapsedTime: Double { + // A small buffer is needed to ensure a meaningful difference between the two dates + guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -0.0001, (timestampDate > lastUpdated || ignoreLastUpdated) else { return sliderValue } + let timeDifference = isPlaying ? currentDate.timeIntervalSince(timestampDate) : 0 + let elapsed = elapsedTime + (timeDifference * playbackRate) + return min(elapsed, duration) + } + + var body: some View { + VStack { + CustomSlider( + value: $sliderValue, + range: 0 ... duration, + color: Defaults[.sliderColor] == SliderColorEnum.albumArt ? Color( + nsColor: color + ).ensureMinimumBrightness(factor: 0.8) : Defaults[.sliderColor] == SliderColorEnum.accent ? Defaults[.accentColor] : .white, + dragging: $dragging, + lastDragged: $lastDragged, + onValueChange: onValueChange + ) + .frame(height: 10, alignment: .center) + HStack { + Text(timeString(from: sliderValue)) + Spacer() + Text(timeString(from: duration)) + } + .fontWeight(.medium) + .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: color) + .ensureMinimumBrightness(factor: 0.6) : .gray) + .font(.caption) + } + .onChange(of: currentDate) { + sliderValue = currentElapsedTime + } + } + + func timeString(from seconds: Double) -> String { + let totalMinutes = Int(seconds) / 60 + let remainingSeconds = Int(seconds) % 60 + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, remainingSeconds) + } else { + return String(format: "%d:%02d", minutes, remainingSeconds) + } + } +} + +struct CustomSlider: View { + @Binding var value: Double + var range: ClosedRange + var color: Color = .white + @Binding var dragging: Bool + @Binding var lastDragged: Date + var onValueChange: ((Double) -> Void)? + var thumbSize: CGFloat = 12 + + var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let height = CGFloat(dragging ? 9 : 5) + let rangeSpan = range.upperBound - range.lowerBound + + let filledTrackWidth = min(rangeSpan == .zero ? 0 : ((value - range.lowerBound) / rangeSpan) * width, width) + + ZStack(alignment: .leading) { + // Background track + Rectangle() + .fill(.gray.opacity(0.3)) + .frame(height: height) + + // Filled track + Rectangle() + .fill(color) + .frame(width: filledTrackWidth, height: height) + } + .cornerRadius(height / 2) + .frame(height: 10) + .contentShape(Rectangle()) + .highPriorityGesture( + DragGesture(minimumDistance: 0) + .onChanged { gesture in + withAnimation { + dragging = true + } + let newValue = range.lowerBound + Double(gesture.location.x / width) * rangeSpan + value = min(max(newValue, range.lowerBound), range.upperBound) + } + .onEnded { _ in + onValueChange?(value) + dragging = false + lastDragged = Date() + } + ) + .animation(.bouncy.speed(1.4), value: dragging) + } + } +} + +#Preview { + NotchHomeView(albumArtNamespace: Namespace().wrappedValue) + .environmentObject(DynamicIslandViewModel()) + .environmentObject(WebcamManager()) +} diff --git a/DynamicIsland/components/Notch/NotchShape.swift b/DynamicIsland/components/Notch/NotchShape.swift new file mode 100644 index 0000000..be2cfa9 --- /dev/null +++ b/DynamicIsland/components/Notch/NotchShape.swift @@ -0,0 +1,126 @@ +// +// NotchShape.swift +// boringNotch +// +// Created by Kai Azim on 2023-08-24. +// Original source: https://github.com/MrKai77/DynamicNotchKit +// Modified by Alexander Greco on 2025-05-18. + +import SwiftUI + +struct NotchShape: Shape { + private var topCornerRadius: CGFloat + private var bottomCornerRadius: CGFloat + + init( + topCornerRadius: CGFloat? = nil, + bottomCornerRadius: CGFloat? = nil + ) { + self.topCornerRadius = topCornerRadius ?? 6 + self.bottomCornerRadius = bottomCornerRadius ?? 14 + } + + var animatableData: AnimatablePair { + get { + .init( + topCornerRadius, + bottomCornerRadius + ) + } + set { + topCornerRadius = newValue.first + bottomCornerRadius = newValue.second + } + } + + func path(in rect: CGRect) -> Path { + var path = Path() + + path.move( + to: CGPoint( + x: rect.minX, + y: rect.minY + ) + ) + + path.addQuadCurve( + to: CGPoint( + x: rect.minX + topCornerRadius, + y: rect.minY + topCornerRadius + ), + control: CGPoint( + x: rect.minX + topCornerRadius, + y: rect.minY + ) + ) + + path.addLine( + to: CGPoint( + x: rect.minX + topCornerRadius, + y: rect.maxY - bottomCornerRadius + ) + ) + + path.addQuadCurve( + to: CGPoint( + x: rect.minX + topCornerRadius + bottomCornerRadius, + y: rect.maxY + ), + control: CGPoint( + x: rect.minX + topCornerRadius, + y: rect.maxY + ) + ) + + path.addLine( + to: CGPoint( + x: rect.maxX - topCornerRadius - bottomCornerRadius, + y: rect.maxY + ) + ) + + path.addQuadCurve( + to: CGPoint( + x: rect.maxX - topCornerRadius, + y: rect.maxY - bottomCornerRadius + ), + control: CGPoint( + x: rect.maxX - topCornerRadius, + y: rect.maxY + ) + ) + + path.addLine( + to: CGPoint( + x: rect.maxX - topCornerRadius, + y: rect.minY + topCornerRadius + ) + ) + + path.addQuadCurve( + to: CGPoint( + x: rect.maxX, + y: rect.minY + ), + control: CGPoint( + x: rect.maxX - topCornerRadius, + y: rect.minY + ) + ) + + path.addLine( + to: CGPoint( + x: rect.minX, + y: rect.minY + ) + ) + + return path + } +} + +#Preview { + NotchShape(topCornerRadius: 6, bottomCornerRadius: 14) + .frame(width: 200, height: 32) + .padding(10) +} diff --git a/DynamicIsland/components/Notch/NotchShelfView.swift b/DynamicIsland/components/Notch/NotchShelfView.swift new file mode 100644 index 0000000..5ec7528 --- /dev/null +++ b/DynamicIsland/components/Notch/NotchShelfView.swift @@ -0,0 +1,64 @@ + +import SwiftUI + +struct NotchShelfView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @StateObject var tvm = TrayDrop.shared + + var body: some View { + HStack { + AirDropView() + panel + .onDrop(of: [.data], isTargeted: $vm.dropZoneTargeting) { providers in + vm.dropEvent = true + DispatchQueue.global().async { + tvm.load(providers) + } + return true + } + } + } + + var panel: some View { + RoundedRectangle(cornerRadius: 16) + .strokeBorder(style: StrokeStyle(lineWidth: 4, dash: [10])) + .foregroundStyle(.white.opacity(0.1)) + .overlay { + content + .padding() + } + .animation(vm.animation, value: tvm.items) + .animation(vm.animation, value: tvm.isLoading) + } + + var content: some View { + Group { + if tvm.isEmpty { + VStack(spacing: 10) { + Image(systemName: "tray.and.arrow.down") + .symbolVariant(.fill) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.white, .gray) + .imageScale(.large) + + Text("Drop files here") + .foregroundStyle(.gray) + .font(.system(.title3, design: .rounded)) + .fontWeight(.medium) + } + } else { + ScrollView(.horizontal) { + HStack(spacing: spacing) { + ForEach(tvm.items) { item in + DropItemView(item: item) + } + } + .padding(spacing) + } + .padding(-spacing) + .scrollIndicators(.never) + } + } + } +} + diff --git a/DynamicIsland/components/Onboarding/ActivationView.swift b/DynamicIsland/components/Onboarding/ActivationView.swift new file mode 100644 index 0000000..bafb5b2 --- /dev/null +++ b/DynamicIsland/components/Onboarding/ActivationView.swift @@ -0,0 +1,80 @@ +// +// OnboardingSettings.swift +// boringNotch +// +// Created by Richard Kunkli on 2024. 09. 26.. +// + +import SwiftUI + +struct ActivationWindow: View { + @State private var email: String = "" + @State private var key: String = "" + var body: some View { + VStack { + Image("logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 80) + .padding(.top, 30) + .padding(.bottom, 10) + Text("Activate your license") + .font(.largeTitle.bold()) + .fontDesign(.rounded) + Text("Transform your notch truly yours") + .foregroundStyle(.secondary) + .font(.title2) + .padding(.bottom, 20) + Group { + TextField("Email address", text: $email) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + TextField("License key", text: $key) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + } + .textFieldStyle(PlainTextFieldStyle()) + .scrollContentBackground(.hidden) + .toggleStyle(.switch) + Spacer() + VStack(alignment: .center, spacing: 14) { + HStack(alignment: .center) { + HStack { + Button {} label: { + Text("Cancel") + .padding(.horizontal, 18) + } + .buttonStyle(AccessoryBarButtonStyle()) + } + .frame(maxWidth: .infinity, alignment: .leading) + Image("dynamicisland") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 18) + .offset(y: 4) + .blendMode(.overlay) + HStack { + Button {} label: { + Text("Activate") + .padding(.horizontal, 18) + } + .buttonStyle(BorderedProminentButtonStyle()) + } + .frame(maxWidth: .infinity, alignment: .trailing) + + } + .controlSize(.extraLarge) + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .ignoresSafeArea() + .background { + VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) + .ignoresSafeArea() + } + .frame(width: 350, height: 350) + } +} diff --git a/DynamicIsland/components/Onboarding/ProOnboarding.swift b/DynamicIsland/components/Onboarding/ProOnboarding.swift new file mode 100644 index 0000000..55a6bce --- /dev/null +++ b/DynamicIsland/components/Onboarding/ProOnboarding.swift @@ -0,0 +1,105 @@ +// +// ProOnboarding.swift +// boringNotch +// +// Created by Richard Kunkli on 2024. 09. 26.. +// + +import SwiftUI +import SwiftUIIntrospect + +struct ProOnboard: View { + var body: some View { + ZStack(alignment: .top) { + ZStack { + Image("spotlight") + .resizable() + .aspectRatio(contentMode: .fit) + .padding(.bottom) + .blur(radius: 3) + .offset(y: -5) + .background(SparkleView().opacity(0.6)) + VStack(spacing: 8) { + Image("logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + .padding(.bottom, 8) + Text("TheDynamicIsland") + .font(.system(.largeTitle, design: .serif)) + Text("Welcome") + .font(.title) + .foregroundStyle(.secondary) + .padding(.bottom, 30) + if false { + Text("PRO") + .font(.system(size: 18, design: .rounded)) + .fontWeight(.bold) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 3) + .background( + Capsule() + .fill(LinearGradient(colors: [.white.opacity(0.7), .white.opacity(0.3)], startPoint: .topLeading, endPoint: .bottomTrailing)) + .strokeBorder(LinearGradient(stops: [.init(color: .white.opacity(0.7), location: 0.3), .init(color: .clear, location: 0.6)], startPoint: .topLeading, endPoint: .bottomTrailing)) + .blendMode(.overlay) + ) + .padding(.bottom, 30) + } + Button { + if let url = URL(string: "https://buymeacoffee.com/jfxh67wvfxq") { + NSWorkspace.shared.open(url) + } + } label: { + Label("Buy us a coffee", systemImage: "cup.and.saucer.fill") + .font(.headline) + .foregroundColor(Color.black) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.yellow) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + .padding(.bottom, 20) + + Button { + NSApp.keyWindow?.close() + } label: { + Text("Get started") + .padding(.horizontal, 20) + .padding(.vertical, 6) + } + .buttonStyle(BorderedProminentButtonStyle()) + } + .padding(.top) + } + + Image("dynamicisland") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 22) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .padding() + .padding(.bottom, 36) + .blendMode(.overlay) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .ignoresSafeArea() + .background { + VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) + .ignoresSafeArea() + } + .onAppear { + NSApp.mainWindow?.standardWindowButton(.zoomButton)?.isHidden = true + NSApp.mainWindow?.standardWindowButton(.closeButton)?.isHidden = true + NSApp.mainWindow?.standardWindowButton(.miniaturizeButton)?.isHidden = true + NSApp.mainWindow?.styleMask.remove(.resizable) + } + .frame(width: 350, height: 500) + .introspect(.window, on: .macOS(.v14, .v15)) { window in + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.center() + } + } +} diff --git a/DynamicIsland/components/Onboarding/SparkleView.swift b/DynamicIsland/components/Onboarding/SparkleView.swift new file mode 100644 index 0000000..5fb190e --- /dev/null +++ b/DynamicIsland/components/Onboarding/SparkleView.swift @@ -0,0 +1,76 @@ +// +// SparkleView.swift +// boringNotch +// +// Created by Richard Kunkli on 2024. 09. 26.. +// + +import SwiftUI +import AppKit + +class SparkleNSView: NSView { + private var emitterLayer: CAEmitterLayer? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.wantsLayer = true + setupEmitterLayer() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupEmitterLayer() { + let emitterLayer = CAEmitterLayer() + emitterLayer.emitterShape = .rectangle + emitterLayer.emitterMode = .surface + emitterLayer.renderMode = .oldestFirst + + let cell = CAEmitterCell() + cell.contents = NSImage(named: "sparkle")?.cgImage(forProposedRect: nil, context: nil, hints: nil) + cell.birthRate = 50 + cell.lifetime = 5 + cell.velocity = 10 + cell.velocityRange = 5 + cell.emissionRange = .pi * 2 + cell.scale = 0.2 + cell.scaleRange = 0.1 + cell.alphaSpeed = -0.5 + cell.yAcceleration = 10 // Add a slight downward motion + + emitterLayer.emitterCells = [cell] + + self.layer?.addSublayer(emitterLayer) + self.emitterLayer = emitterLayer + + updateEmitterForCurrentBounds() + } + + private func updateEmitterForCurrentBounds() { + guard let emitterLayer = self.emitterLayer else { return } + + emitterLayer.frame = self.bounds + emitterLayer.emitterSize = self.bounds.size + emitterLayer.emitterPosition = CGPoint(x: bounds.width / 2, y: bounds.height / 2) + + // Adjust birth rate based on view size + let area = bounds.width * bounds.height + let baseBirthRate: Float = 50 + let adjustedBirthRate = 20 // Assuming 200x200 as base size + emitterLayer.emitterCells?.first?.birthRate = Float(adjustedBirthRate) + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + updateEmitterForCurrentBounds() + } +} + +struct SparkleView: NSViewRepresentable { + func makeNSView(context: Context) -> SparkleNSView { + return SparkleNSView() + } + + func updateNSView(_ nsView: SparkleNSView, context: Context) {} +} diff --git a/DynamicIsland/components/ProgressIndicator.swift b/DynamicIsland/components/ProgressIndicator.swift new file mode 100644 index 0000000..04b6607 --- /dev/null +++ b/DynamicIsland/components/ProgressIndicator.swift @@ -0,0 +1,63 @@ + // + // ProgressIndicator.swift + // boringNotch + // + // Created by Harsh Vardhan Goswami on 11/08/24. + // + +import Foundation +import SwiftUI + +struct CircularProgressView: View { + let progress: Double + let color: Color + + var body: some View { + ZStack { + Circle() + .stroke( + Color.white.opacity(0.2), + lineWidth: 6 + ) + Circle() + .trim(from: 0, to: progress) + .stroke( + color, + // 1 + style: StrokeStyle( + lineWidth: 6, + lineCap: .round + ) + ) + .rotationEffect(.degrees(-90)) + } + } +} + +enum ProgressIndicatorType { + case circle + case text +} + + + // based on type .circle or .text +struct ProgressIndicator: View { + var type: ProgressIndicatorType + var progress: Double + var color: Color + + var body: some View { + switch type { + case .circle: + CircularProgressView(progress: progress, color: color).frame( + width: 20, height: 20) + case .text: + Text("\(Int(progress * 100))%") + } + } +} + +#Preview { + ProgressIndicator(type: .circle, progress: 0.8, color: Color.blue).padding() + .frame(width: 200, height: 200) +} diff --git a/DynamicIsland/components/Settings/EditPanelView.swift b/DynamicIsland/components/Settings/EditPanelView.swift new file mode 100644 index 0000000..0d3c025 --- /dev/null +++ b/DynamicIsland/components/Settings/EditPanelView.swift @@ -0,0 +1,54 @@ +// +// EditPanelView.swift +// boringNotch +// +// Created by Richard Kunkli on 12/08/2024. +// + +import SwiftUI + +struct EditPanelView: View { + @State var wallpaperPath: URL? + var body: some View { + VStack { + HStack { + Text("Edit layout") + .font(.system(.largeTitle, design: .rounded)) + .foregroundColor(.white.opacity(0.5)) + Spacer() + Button { + exit(0) + } label: { + Label("Close", systemImage: "xmark") + } + .controlSize(.extraLarge) + .buttonStyle(AccessoryBarButtonStyle()) + } + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } +} + +#Preview { + EditPanelView() +} + +struct VisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context _: Context) -> NSVisualEffectView { + let visualEffectView = NSVisualEffectView() + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + visualEffectView.state = NSVisualEffectView.State.active + visualEffectView.isEmphasized = true + return visualEffectView + } + + func updateNSView(_ visualEffectView: NSVisualEffectView, context _: Context) { + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + } +} diff --git a/DynamicIsland/components/Settings/ListItemPopover.swift b/DynamicIsland/components/Settings/ListItemPopover.swift new file mode 100644 index 0000000..0a32422 --- /dev/null +++ b/DynamicIsland/components/Settings/ListItemPopover.swift @@ -0,0 +1,28 @@ +// +// ListItemPopover.swift +// boringNotch +// +// Created by Richard Kunkli on 15/09/2024. +// + +import SwiftUI + +struct ListItemPopover: View { + let content: () -> Content + + @State private var isPresented: Bool = false + var body: some View { + Button { + isPresented.toggle() + } label: { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .controlSize(.regular) + .popover(isPresented: $isPresented, attachmentAnchor: .rect(.bounds), arrowEdge: .trailing, content: { + content() + .padding() + }) + } +} + diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift new file mode 100644 index 0000000..efe1cd5 --- /dev/null +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -0,0 +1,1172 @@ +// +// SettingsView.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import AVFoundation +import Defaults +import EventKit +import KeyboardShortcuts +import LaunchAtLogin +import LottieUI +import Sparkle +import SwiftUI +import SwiftUIIntrospect + +struct SettingsView: View { + @Environment(\.scenePhase) private var scenePhase + @StateObject var extensionManager = DynamicIslandExtensionManager() + let updaterController: SPUStandardUpdaterController + @StateObject private var calendarManager = CalendarManager() + + @State private var selectedTab = "General" + + var body: some View { + NavigationSplitView { + List(selection: $selectedTab) { + NavigationLink(destination: GeneralSettings()) { + Label("General", systemImage: "gear") + } + NavigationLink(destination: Appearance()) { + Label("Appearance", systemImage: "eye") + } + NavigationLink(destination: Media()) { + Label("Media", systemImage: "play.laptopcomputer") + } + NavigationLink(destination: CalendarSettings()) { + Label("Calendar", systemImage: "calendar") + } + if extensionManager.installedExtensions + .contains( + where: { $0.bundleIdentifier == hudExtension + }) + { + NavigationLink(destination: HUD()) { + Label("HUDs", systemImage: "dial.medium.fill") + } + } + NavigationLink(destination: Charge()) { + Label("Battery", systemImage: "battery.100.bolt") + } + if extensionManager.installedExtensions + .contains( + where: { $0.bundleIdentifier == downloadManagerExtension + }) + { + NavigationLink(destination: Downloads()) { + Label("Downloads", systemImage: "square.and.arrow.down") + } + } + NavigationLink(destination: Shelf()) { + Label("Shelf", systemImage: "books.vertical") + } + NavigationLink(destination: Shortcuts()) { + Label("Shortcuts", systemImage: "keyboard") + } + NavigationLink(destination: Extensions()) { + Label("Extensions", systemImage: "puzzlepiece.extension") + } + NavigationLink( + destination: About(updaterController: updaterController) + ) { + Label("About", systemImage: "info.circle") + } + } + .tint(Defaults[.accentColor]) + } detail: { + GeneralSettings() + } + .environmentObject(extensionManager) + .environmentObject(calendarManager) + .formStyle(.grouped) + .onChange(of: scenePhase) { + if scenePhase == .active { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + NSApp.keyWindow?.makeKeyAndOrderFront(nil) + } + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)) { _ in + NSApp.setActivationPolicy(.accessory) + NSApp.deactivate() + } + .introspect(.window, on: .macOS(.v14, .v15)) { window in + window.toolbarStyle = .unified + window.styleMask.update(with: .resizable) + } + } +} + +struct GeneralSettings: View { + @State private var screens: [String] = NSScreen.screens.compactMap { $0.localizedName } + let accentColors: [Color] = [.blue, .purple, .pink, .red, .orange, .yellow, .green, .gray] + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @Default(.accentColor) var accentColor + @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 + @Default(.alwaysHideInFullscreen) var alwaysHideInFullscreen + + var body: some View { + Form { + Section { + HStack { + ForEach(accentColors, id: \.self) { color in + Button(action: { + withAnimation { + Defaults[.accentColor] = color + } + }) { + Circle() + .fill(color) + .frame(width: 20, height: 20) + .overlay( + Circle() + .stroke(Color.white, lineWidth: Defaults[.accentColor] == color ? 2 : 0) + .overlay { + Circle() + .fill(.white) + .frame(width: 7, height: 7) + .opacity(Defaults[.accentColor] == color ? 1 : 0) + } + ) + } + .buttonStyle(PlainButtonStyle()) + } + Spacer() + ColorPicker("Custom color", selection: $accentColor) + .labelsHidden() + } + } header: { + Text("Accent color") + } + + Section { + Defaults.Toggle("Menubar icon", key: .menubarIcon) + 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("Show on a specific display", selection: $coordinator.preferredScreen) { + ForEach(screens, id: \.self) { screen in + Text(screen) + } + } + .onChange(of: NSScreen.screens) { + screens = NSScreen.screens.compactMap({$0.localizedName}) + } + .disabled(showOnAllDisplays) + Defaults.Toggle("Automatically switch displays", key: .automaticallySwitchDisplay) + .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 display height")) { + Text("Match real notch size") + .tag(WindowHeightMode.matchRealNotchSize) + Text("Match menubar 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("Non-notch display height", selection: $nonNotchHeightMode) { + Text("Match menubar height") + .tag(WindowHeightMode.matchMenuBar) + Text("Match real notch size") + .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 Height") + } + + NotchBehaviour() + + gestureControls() + } + .tint(Defaults[.accentColor]) + .toolbar { + Button("Quit app") { + NSApp.terminate(self) + } + .controlSize(.extraLarge) + } + .navigationTitle("General") + .onChange(of: openNotchOnHover) { + if !openNotchOnHover { + enableGestures = true + } + } + } + + @ViewBuilder + func gestureControls() -> some View { + Section { + Defaults.Toggle("Enable gestures", key: .enableGestures) + .disabled(!openNotchOnHover) + if enableGestures { + Toggle("Media change with horizontal gestures", isOn: .constant(false)) + .disabled(true) + Defaults.Toggle("Close gesture", key: .closeGestureEnabled) + 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("Extend hover area", key: .extendHoverArea) + Defaults.Toggle("Enable haptics", key: .enableHaptics) + Defaults.Toggle("Open notch on hover", key: .openNotchOnHover) + Toggle("Remember last tab", isOn: $coordinator.openLastTabByDefault) + if openNotchOnHover { + Slider(value: $minimumHoverDuration, in: 0...1, step: 0.1) { + HStack { + Text("Minimum hover duration") + 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("Show battery indicator", key: .showBatteryIndicator) + Defaults.Toggle("Show power status notifications", key: .showPowerStatusNotifications) + } header: { + Text("General") + } + Section { + Defaults.Toggle("Show battery percentage", key: .showBatteryPercentage) + Defaults.Toggle("Show power status icons", key: .showPowerStatusIcons) + } header: { + Text("Battery Information") + } + } + .tint(Defaults[.accentColor]) + .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("Show download progress", key: .enableDownloadListener) + .disabled(true) + Defaults.Toggle("Enable Safari Downloads", key: .enableSafariDownloads) + .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() + } + } + } + .tint(Defaults[.accentColor]) + .navigationTitle("Downloads") + } +} + +struct HUD: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @Default(.inlineHUD) var inlineHUD + @Default(.enableGradient) var enableGradient + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + var body: some View { + Form { + Section { + Toggle("Enable HUD replacement", isOn: $coordinator.hudReplacement) + } header: { + Text("General") + } + 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 + } + } + } + Picker("Progressbar style", selection: $enableGradient) { + Text("Hierarchical") + .tag(false) + Text("Gradient") + .tag(true) + } + Defaults.Toggle("Enable glowing effect", key: .systemEventIndicatorShadow) + Defaults.Toggle("Use accent color", key: .systemEventIndicatorUseAccent) + } header: { + HStack { + Text("Appearance") + } + } + } + .tint(Defaults[.accentColor]) + .navigationTitle("HUDs") + } +} + +struct Media: View { + @Default(.waitInterval) var waitInterval + @Default(.mediaController) var mediaController + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @Default(.hideNotchOption) var hideNotchOption + @Default(.enableSneakPeek) private var enableSneakPeek + @Default(.sneakPeekStyles) var sneakPeekStyles + + 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/th-ch/youtube-music", destination: URL(string: "https://github.com/th-ch/youtube-music")!) + .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( + "Enable music live activity", + isOn: $coordinator.musicLiveActivityEnabled.animation() + ) + Toggle("Enable sneak peek", isOn: $enableSneakPeek) + Picker("Sneak Peek Style", selection: $sneakPeekStyles){ + ForEach(SneakPeekStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + }.disabled(!enableSneakPeek) + HStack { + Stepper(value: $waitInterval, in: 0...10, step: 1) { + HStack { + Text("Media inactivity timeout") + Spacer() + Text("\(Defaults[.waitInterval], specifier: "%.0f") seconds") + .foregroundStyle(.secondary) + } + } + } + } header: { + Text("Media playback live activity") + } + + Picker(selection: $hideNotchOption, label: + HStack { + Text("Hide DynamicIsland Options") + customBadge(text: "Beta") + }) { + Text("Always hide in fullscreen").tag(HideNotchOption.always) + Text("Hide only when NowPlaying app is in fullscreen").tag(HideNotchOption.nowPlayingOnly) + Text("Never hide").tag(HideNotchOption.never) + } + .onChange(of: hideNotchOption) { _, newValue in + Defaults[.alwaysHideInFullscreen] = newValue == .always + Defaults[.enableFullscreenMediaDetection] = newValue != .never + } + } + .tint(Defaults[.accentColor]) + .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() + @Default(.showCalendar) var showCalendar: Bool + + var body: some View { + Form { + if calendarManager.authorizationStatus != .fullAccess { + Text("Calendar access is denied. Please enable it in System Settings.") + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding() + Button("Open System Settings") { + if let settingsURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") { + NSWorkspace.shared.open(settingsURL) + } + } + } else { + Toggle("Show calendar", isOn: $showCalendar) + Section(header: Text("Select Calendars")) { + List { + ForEach(calendarManager.allCalendars, id: \.calendarIdentifier) { calendar in + Toggle(isOn: Binding( + get: { calendarManager.getCalendarSelected(calendar) }, + set: { isSelected in + calendarManager.setCalendarSelected(calendar, isSelected: isSelected) + } + )) { + Text(calendar.title) + } + } + } + } + } + } + .onAppear { + calendarManager.checkCalendarAuthorization() + } + } +} + +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 { + NSWorkspace.shared.open(sponsorPage) + } label: { + VStack(spacing: 5) { + Image(systemName: "cup.and.saucer.fill") + .imageScale(.large) + Text("Support Us") + .foregroundStyle(.white) + } + .contentShape(Rectangle()) + } + Spacer(minLength: 0) + Button { + NSWorkspace.shared.open(productPage) + } label: { + VStack(spacing: 5) { + Image("Github") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18) + Text("GitHub") + .foregroundStyle(.white) + } + .contentShape(Rectangle()) + } + Spacer(minLength: 0) + } + .buttonStyle(PlainButtonStyle()) + } + VStack(spacing: 0) { + Divider() + Text("Made with 🫢🏻 by the DynamicIsland team") + .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) + } + .tint(Defaults[.accentColor]) + .navigationTitle("About") + } +} + +struct Shelf: View { + var body: some View { + Form { + Section { + Defaults.Toggle("Enable shelf", key: .dynamicShelf) + Defaults.Toggle("Open shelf tab by default if items added", key: .openShelfByDefault) + } header: { + HStack { + Text("General") + } + } + } + .tint(Defaults[.accentColor]) + .navigationTitle("Shelf") + } +} + +struct Extensions: View { + @EnvironmentObject var extensionManager: DynamicIslandExtensionManager + @State private var effectTrigger: Bool = false + var body: some View { + Form { + //warningBadge("We don't support extensions yet") // Uhhhh You do? <><><> Oori.S + 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) + } + } + } + } + .tint(Defaults[.accentColor]) + .navigationTitle("Extensions") + // TipsView() + // .padding(.horizontal, 19) + } +} + +struct Appearance: View { + @ObservedObject var coordinator = DynamicIslandViewCoordinator.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("Settings icon in notch", key: .settingsIconInNotch) + Defaults.Toggle("Enable window shadow", key: .enableShadow) + Defaults.Toggle("Corner radius scaling", key: .cornerRadiusScaling) + Defaults.Toggle("Use simpler close animation", key: .useModernCloseAnimation) + } header: { + Text("General") + } + + Section { + Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram) + Defaults + .Toggle("Player tinting", key: .playerColorTinting) + Defaults.Toggle("Enable blur effect behind album art", key: .lightingEffect) + 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(state: LUStateData(type: .loadedFrom(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 ? Defaults[.accentColor] : 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("Enable dynamic mirror", key: .showMirror) + .disabled(!checkVideoInput()) + Picker("Mirror shape", selection: $mirrorShape) { + Text("Circle") + .tag(MirrorShapeEnum.circle) + Text("Square") + .tag(MirrorShapeEnum.rectangle) + } + Defaults.Toggle("Show cool face animation while inactivity", key: .showNotHumanFace) + } header: { + HStack { + Text("Additional features") + } + } + + 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 ? Defaults[.accentColor] : .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 ? Defaults[.accentColor] : .clear) + ) + } + .onTapGesture { + withAnimation { + selectedIcon = icon + } + NSApp.applicationIconImage = NSImage(named: icon) + } + Spacer() + } + } + .disabled(true) + } header: { + HStack { + Text("App icon") + customBadge(text: "Coming soon") + } + } + } + .tint(Defaults[.accentColor]) + .navigationTitle("Appearance") + } + + func checkVideoInput() -> Bool { + if let _ = AVCaptureDevice.default(for: .video) { + return true + } + + return false + } +} + +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) + } + } + .tint(Defaults[.accentColor]) + .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/DynamicIsland/components/Settings/SoftwareUpdater.swift b/DynamicIsland/components/Settings/SoftwareUpdater.swift new file mode 100644 index 0000000..f9db680 --- /dev/null +++ b/DynamicIsland/components/Settings/SoftwareUpdater.swift @@ -0,0 +1,67 @@ +// +// SoftwareUpdater.swift +// boringNotch +// +// Created by Richard Kunkli on 09/08/2024. +// + +import SwiftUI +import Sparkle + +final class CheckForUpdatesViewModel: ObservableObject { + @Published var canCheckForUpdates = false + + init(updater: SPUUpdater) { + updater.publisher(for: \.canCheckForUpdates) + .assign(to: &$canCheckForUpdates) + } +} + +struct CheckForUpdatesView: View { + @ObservedObject private var checkForUpdatesViewModel: CheckForUpdatesViewModel + private let updater: SPUUpdater + + init(updater: SPUUpdater) { + self.updater = updater + + // Create our view model for our CheckForUpdatesView + self.checkForUpdatesViewModel = CheckForUpdatesViewModel(updater: updater) + } + + var body: some View { + Button("Check for Updates…", action: updater.checkForUpdates) + .disabled(!checkForUpdatesViewModel.canCheckForUpdates) + } +} + +struct UpdaterSettingsView: View { + private let updater: SPUUpdater + + @State private var automaticallyChecksForUpdates: Bool + @State private var automaticallyDownloadsUpdates: Bool + + init(updater: SPUUpdater) { + self.updater = updater + self.automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates + self.automaticallyDownloadsUpdates = updater.automaticallyDownloadsUpdates + } + + var body: some View { + Section { + Toggle("Automatically check for updates", isOn: $automaticallyChecksForUpdates) + .onChange(of: automaticallyChecksForUpdates) { _, newValue in + updater.automaticallyChecksForUpdates = newValue + } + + Toggle("Automatically download updates", isOn: $automaticallyDownloadsUpdates) + .disabled(!automaticallyChecksForUpdates) + .onChange(of: automaticallyDownloadsUpdates) { _, newValue in + updater.automaticallyDownloadsUpdates = newValue + } + } header: { + HStack { + Text("Software updates") + } + } + } +} diff --git a/DynamicIsland/components/Shelf/AirDrop.swift b/DynamicIsland/components/Shelf/AirDrop.swift new file mode 100644 index 0000000..a46f7c5 --- /dev/null +++ b/DynamicIsland/components/Shelf/AirDrop.swift @@ -0,0 +1,40 @@ +// +// AirDrop.swift +// NotchDrop +// +// Created by η§‹ζ˜Ÿζ‘₯ on 2024/7/7. +// + +import Cocoa + +class AirDrop: NSObject, NSSharingServiceDelegate { + let files: [URL] + + init(files: [URL]) { + self.files = files + super.init() + } + + func begin() { + do { + try sendEx(files) + } catch { + NSAlert.popError(error) + } + } + + private func sendEx(_ files: [URL]) throws { + guard let service = NSSharingService(named: .sendViaAirDrop) else { + throw NSError(domain: "AirDrop", code: 1, userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString("AirDrop service not available", comment: ""), + ]) + } + guard service.canPerform(withItems: files) else { + throw NSError(domain: "AirDrop", code: 2, userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString("AirDrop service not available", comment: ""), + ]) + } + service.delegate = self + service.perform(withItems: files) + } +} diff --git a/DynamicIsland/components/Shelf/AirDropView.swift b/DynamicIsland/components/Shelf/AirDropView.swift new file mode 100644 index 0000000..d6e3b2d --- /dev/null +++ b/DynamicIsland/components/Shelf/AirDropView.swift @@ -0,0 +1,70 @@ +// +// AirDrop+View.swift +// NotchDrop +// +// Created by η§‹ζ˜Ÿζ‘₯ on 2024/7/8. +// + +import SwiftUI +import UniformTypeIdentifiers + +struct AirDropView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + + @State var trigger: UUID = .init() + @State var targeting = false + + var body: some View { + dropArea + .onDrop(of: [.data], isTargeted: $vm.dropZoneTargeting) { providers in + trigger = .init() + vm.dropEvent = true + DispatchQueue.global().async { beginDrop(providers) } + return true + } + } + + var dropArea: some View { + Rectangle() + .fill(.white.opacity(0.1)) + .opacity(0.5) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay { dropLabel } + .aspectRatio(1, contentMode: .fit) + .contentShape(Rectangle()) + } + + var dropLabel: some View { + VStack(spacing: 8) { + Image(systemName: "airplayaudio") + Text("AirDrop") + } + .foregroundStyle(.gray) + .font(.system(.headline, design: .rounded)) + .contentShape(Rectangle()) + .onTapGesture { + trigger = .init() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + let picker = NSOpenPanel() + picker.allowsMultipleSelection = true + picker.canChooseDirectories = true + picker.canChooseFiles = true + picker.begin { response in + if response == .OK { + let drop = AirDrop(files: picker.urls) + drop.begin() + } + } + } + } + } + + func beginDrop(_ providers: [NSItemProvider]) { + assert(!Thread.isMainThread) + guard let urls = providers.interfaceConvert() else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + let drop = AirDrop(files: urls) + drop.begin() + } + } +} diff --git a/DynamicIsland/components/Shelf/DragDropView.swift b/DynamicIsland/components/Shelf/DragDropView.swift new file mode 100644 index 0000000..fa2a395 --- /dev/null +++ b/DynamicIsland/components/Shelf/DragDropView.swift @@ -0,0 +1,56 @@ +// +// DragDropView.swift +// boringNotch +// +// Created by Richard Kunkli on 2024. 10. 19.. +// + +import SwiftUI +import AppKit + +class DragDropView: NSView { + var onDragEntered: () -> Void = {} + var onDragExited: () -> Void = {} + var onDrop: () -> Void = {} + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([.fileURL]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + onDragEntered() + return .copy + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + onDragExited() + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + onDrop() + return true + } +} + +struct DragDropViewRepresentable: NSViewRepresentable { + @Binding var isTargeted: Bool + var onDrop: () -> Void + + func makeNSView(context: Context) -> DragDropView { + let view = DragDropView() + view.onDragEntered = { isTargeted = true } + view.onDragExited = { isTargeted = false } + view.onDrop = onDrop + + view.autoresizingMask = [.width, .height] + + return view + } + + func updateNSView(_ nsView: DragDropView, context: Context) {} +} diff --git a/DynamicIsland/components/Shelf/DropItem.swift b/DynamicIsland/components/Shelf/DropItem.swift new file mode 100644 index 0000000..0748cdd --- /dev/null +++ b/DynamicIsland/components/Shelf/DropItem.swift @@ -0,0 +1,58 @@ +// +// TrayDrop+DropItem.swift +// TrayDrop +// +// Created by η§‹ζ˜Ÿζ‘₯ on 2024/7/8. +// + +import Cocoa +import Foundation +import QuickLook + +extension TrayDrop { + struct DropItem: Identifiable, Codable, Equatable, Hashable { + let id: UUID + + let fileName: String + let size: Int + + let copiedDate: Date + let workspacePreviewImageData: Data + + init(url: URL) throws { + assert(!Thread.isMainThread) + + id = UUID() + fileName = url.lastPathComponent + + size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 + copiedDate = Date() + workspacePreviewImageData = url.snapshotPreview().pngRepresentation + + try FileManager.default.createDirectory( + at: storageURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.copyItem(at: url, to: storageURL) + } + } +} + +extension TrayDrop.DropItem { + static let mainDir = "CopiedItems" + + var storageURL: URL { + documentsDirectory + .appendingPathComponent(Self.mainDir) + .appendingPathComponent(id.uuidString) + .appendingPathComponent(fileName) + } + + var workspacePreviewImage: NSImage { + .init(data: workspacePreviewImageData) ?? .init() + } + + var shouldClean: Bool { // TODO: In the future clean if old + return false + } +} diff --git a/DynamicIsland/components/Shelf/DropItemView.swift b/DynamicIsland/components/Shelf/DropItemView.swift new file mode 100644 index 0000000..131d70c --- /dev/null +++ b/DynamicIsland/components/Shelf/DropItemView.swift @@ -0,0 +1,79 @@ +// +// TrayDrop+DropItemView.swift +// NotchDrop +// +// Created by η§‹ζ˜Ÿζ‘₯ on 2024/7/8. +// + +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +struct DropItemView: View { + let item: TrayDrop.DropItem + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @StateObject var tvm = TrayDrop.shared + + @State var hover = false + + var body: some View { + ZStack(alignment: .topTrailing) { + VStack { + RoundedRectangle(cornerRadius: 6) + .fill(.clear) + .background { + Image(nsImage: item.workspacePreviewImage) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + Text(item.fileName) + .multilineTextAlignment(.center) + .font(.footnote) + .foregroundStyle(hover ? .white : .gray) + .lineLimit(2) + .allowsTightening(true) + } + .contentShape(Rectangle()) + .onDrag { + handleOnDrag(for: item) + } + .frame(width: 64, height: 64) + + if hover { + Circle() + .fill(.white) + .overlay(Image(systemName: "xmark").foregroundStyle(.black).font(.system(size: 7)).fontWeight(.semibold)) + .frame(width: spacing, height: spacing) + .opacity(hover ? 1 : 0) // TODO: Use option key pressed to show delete + .scaleEffect(coordinator.optionKeyPressed ? 1 : 0.5) + .transition(.blurReplace.combined(with: .scale)) + .offset(x: spacing / 2, y: -spacing / 2) + .onTapGesture { tvm.delete(item.id) } + .shadow(color: .black, radius: 3) + } + } + .onHover { hovering in + withAnimation(.smooth) { + if hovering { + hover.toggle() + } else { + hover.toggle() + } + } + } + } + + private func handleOnDrag(for item: TrayDrop.DropItem) -> NSItemProvider { + guard let itemProvider = NSItemProvider(contentsOf: item.storageURL) else { + return NSItemProvider() + } + + let nameWithoutExtension = (item.fileName as NSString).deletingPathExtension + itemProvider.suggestedName = nameWithoutExtension + + return itemProvider + } +} diff --git a/DynamicIsland/components/Shelf/Ext+FileProvider.swift b/DynamicIsland/components/Shelf/Ext+FileProvider.swift new file mode 100644 index 0000000..c755079 --- /dev/null +++ b/DynamicIsland/components/Shelf/Ext+FileProvider.swift @@ -0,0 +1,61 @@ +// +// Ext+FileProvider.swift +// NotchDrop +// +// Created by η§‹ζ˜Ÿζ‘₯ on 2024/7/8. +// + +import Cocoa +import Foundation +import UniformTypeIdentifiers + +extension NSItemProvider { + private func duplicateToOurStorage(_ url: URL?) throws -> URL? { + guard let url else { return nil } + let temp = temporaryDirectory + .appendingPathComponent("TemporaryDrop") + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent(url.lastPathComponent) + try? FileManager.default.createDirectory( + at: temp.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.copyItem(at: url, to: temp) + return temp + } + + func convertToFilePathThatIsWhatWeThinkItWillWorkWithNotchDrop() -> URL? { + var url: URL? + let sem = DispatchSemaphore(value: 0) + _ = loadObject(ofClass: URL.self) { item, _ in + url = try? self.duplicateToOurStorage(item) + sem.signal() + } + sem.wait() + if url == nil { + loadInPlaceFileRepresentation( + forTypeIdentifier: UTType.data.identifier + ) { input, _, _ in + defer { sem.signal() } + url = try? self.duplicateToOurStorage(input) + } + sem.wait() + } + return url + } +} + +extension [NSItemProvider] { + func interfaceConvert() -> [URL]? { + let urls = compactMap { provider -> URL? in + provider.convertToFilePathThatIsWhatWeThinkItWillWorkWithNotchDrop() + } + guard urls.count == count else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + NSAlert.popError(NSLocalizedString("One or more files failed to load", comment: "")) + } + return nil + } + return urls + } +} diff --git a/DynamicIsland/components/Shelf/Ext+NSAlert.swift b/DynamicIsland/components/Shelf/Ext+NSAlert.swift new file mode 100644 index 0000000..4c72ab7 --- /dev/null +++ b/DynamicIsland/components/Shelf/Ext+NSAlert.swift @@ -0,0 +1,36 @@ +// +// Ext+NSAlert.swift +// NotchDrop +// +// Created by η§‹ζ˜Ÿζ‘₯ on 2024/7/9. +// + +import Cocoa + +extension NSAlert { + static func popError(_ error: String) { + let alert = NSAlert() + alert.messageText = NSLocalizedString("Error", comment: "") + alert.alertStyle = .critical + alert.informativeText = error + alert.addButton(withTitle: NSLocalizedString("OK", comment: "")) + alert.runModal() + } + + static func popRestart(_ error: String, completion: @escaping () -> Void) { + let alert = NSAlert() + alert.messageText = NSLocalizedString("Need Restart", comment: "") + alert.alertStyle = .critical + alert.informativeText = error + alert.addButton(withTitle: NSLocalizedString("Exit", comment: "")) + alert.addButton(withTitle: NSLocalizedString("Later", comment: "")) + let response = alert.runModal() + if response == .alertFirstButtonReturn { + completion() + } + } + + static func popError(_ error: Error) { + popError(error.localizedDescription) + } +} diff --git a/DynamicIsland/components/Shelf/Ext+NSImage.swift b/DynamicIsland/components/Shelf/Ext+NSImage.swift new file mode 100644 index 0000000..32d357a --- /dev/null +++ b/DynamicIsland/components/Shelf/Ext+NSImage.swift @@ -0,0 +1,19 @@ +// +// Ext+NSImage.swift +// NotchDrop +// +// Created by η§‹ζ˜Ÿζ‘₯ on 2024/7/8. +// + +import Cocoa + +extension NSImage { + var pngRepresentation: Data { + guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return .init() + } + let imageRep = NSBitmapImageRep(cgImage: cgImage) + imageRep.size = size + return imageRep.representation(using: .png, properties: [:]) ?? .init() + } +} diff --git a/DynamicIsland/components/Shelf/Ext+URL.swift b/DynamicIsland/components/Shelf/Ext+URL.swift new file mode 100644 index 0000000..0eaf442 --- /dev/null +++ b/DynamicIsland/components/Shelf/Ext+URL.swift @@ -0,0 +1,24 @@ +// +// Ext+URL.swift +// NotchDrop +// +// Created by η§‹ζ˜Ÿζ‘₯ on 2024/7/8. +// + +import Cocoa +import Foundation +import QuickLook + +extension URL { + func snapshotPreview() -> NSImage { + if let preview = QLThumbnailImageCreate( + kCFAllocatorDefault, + self as CFURL, + CGSize(width: 128, height: 128), + nil + )?.takeRetainedValue() { + return NSImage(cgImage: preview, size: .zero) + } + return NSWorkspace.shared.icon(forFile: path) + } +} diff --git a/DynamicIsland/components/Shelf/TrayDrop.swift b/DynamicIsland/components/Shelf/TrayDrop.swift new file mode 100644 index 0000000..b701b6a --- /dev/null +++ b/DynamicIsland/components/Shelf/TrayDrop.swift @@ -0,0 +1,77 @@ +import Cocoa +import Combine +import Foundation +import OrderedCollections + +class TrayDrop: ObservableObject { + static let shared = TrayDrop() + + @Published var items: OrderedSet + var isEmpty: Bool { items.isEmpty } + @Published var isLoading: Int = 0 + + init(items: OrderedSet = .init(), isLoading: Int = 0) { + self.items = items + self.isLoading = isLoading + } + + func load(_ providers: [NSItemProvider]) { + assert(!Thread.isMainThread) + DispatchQueue.main.asyncAndWait { isLoading += 1 } + + guard let urls = providers.interfaceConvert() else { + DispatchQueue.main.asyncAndWait { isLoading -= 1 } + print("Faield to load items") + return + } + let dropItems = urls.map { url in + try? DropItem(url: url) + }.compactMap { $0 } + + DispatchQueue.main.async { + dropItems.forEach { self.items.updateOrInsert($0, at: 0) } + self.isLoading -= 1 + } + print("DONE") + } + + func cleanExpiredFiles() { + var inEdit = items + let shouldCleanItems = items.filter(\.shouldClean) + for item in shouldCleanItems { + inEdit.remove(item) + } + items = inEdit + } + + func delete(_ item: DropItem.ID) { + guard let item = items.first(where: { $0.id == item }) else { return } + delete(item: item) + } + + private func delete(item: DropItem) { + var inEdit = items + + var url = item.storageURL + try? FileManager.default.removeItem(at: url) + + do { + // loops up to the main directory + url = url.deletingLastPathComponent() + while url.lastPathComponent != DropItem.mainDir, url != documentsDirectory { + let contents = try FileManager.default.contentsOfDirectory(atPath: url.path) + guard contents.isEmpty else { break } + try FileManager.default.removeItem(at: url) + url = url.deletingLastPathComponent() + } + } catch {} + + inEdit.remove(item) + items = inEdit + } + + func removeAll() { + items.forEach { delete(item: $0) } + } +} + diff --git a/DynamicIsland/components/Tabs/TabButton.swift b/DynamicIsland/components/Tabs/TabButton.swift new file mode 100644 index 0000000..e6eb6b4 --- /dev/null +++ b/DynamicIsland/components/Tabs/TabButton.swift @@ -0,0 +1,30 @@ +// +// TabButton.swift +// boringNotch +// +// Created by Hugo Persson on 2024-08-24. +// + +import SwiftUI + +struct TabButton: View { + let label: String + let icon: String + let selected: Bool + let onClick: () -> Void + + var body: some View { + Button(action: onClick) { + Image(systemName: icon) + .padding(.horizontal, 15) + .contentShape(Capsule()) + } + .buttonStyle(PlainButtonStyle()) + } +} + +#Preview { + TabButton(label: "Home", icon: "tray.fill", selected: true) { + print("Tapped") + } +} diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift new file mode 100644 index 0000000..5832c30 --- /dev/null +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -0,0 +1,55 @@ +// +// TabSelectionView.swift +// boringNotch +// +// Created by Hugo Persson on 2024-08-25. +// + +import SwiftUI + +struct TabModel: Identifiable { + let id = UUID() + let label: String + let icon: String + let view: NotchViews +} + +let tabs = [ + TabModel(label: "Home", icon: "house.fill", view: .home), + TabModel(label: "Shelf", icon: "tray.fill", view: .shelf) +] + +struct TabSelectionView: View { + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @Namespace var animation + var body: some View { + HStack(spacing: 0) { + ForEach(tabs) { tab in + TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) { + withAnimation(.smooth) { + coordinator.currentView = tab.view + } + } + .frame(height: 26) + .foregroundStyle(tab.view == coordinator.currentView ? .white : .gray) + .background { + if tab.view == coordinator.currentView { + Capsule() + .fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear) + .matchedGeometryEffect(id: "capsule", in: animation) + } else { + Capsule() + .fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear) + .matchedGeometryEffect(id: "capsule", in: animation) + .hidden() + } + } + } + } + .clipShape(Capsule()) + } +} + +#Preview { + DynamicIslandHeader().environmentObject(DynamicIslandViewModel()) +} diff --git a/DynamicIsland/components/TestView.swift b/DynamicIsland/components/TestView.swift new file mode 100644 index 0000000..fbee749 --- /dev/null +++ b/DynamicIsland/components/TestView.swift @@ -0,0 +1,92 @@ +// +// TestView.swift +// boringNotch +// +// Created by Richard Kunkli on 14/08/2024. +// + +import SwiftUI + +struct FluidSlider: View { + private let color: Color = Color.white + @State private var offset: CGFloat = 0 + var rectSize = CGSize(width: 300, height: 50) + var rectSize2 = CGSize(width: 200, height: 18) + var circleSize: CGFloat = 35 + @GestureState var isDragging: Bool = false + @State var previousOffset: CGFloat = 0 + @State private var isBeating: Bool = false + + var body: some View { + HStack { + slider + .frame(width: rectSize2.width, height: circleSize) + } + .padding() + .background(.black) + } + + private var slider: some View { + ZStack { + Canvas { context, size in + context.addFilter(.alphaThreshold(min: 0.5, max: 1, color: color)) + context.addFilter(.blur(radius: 10)) + + context.drawLayer { ctx in + if let rectangle = ctx.resolveSymbol(id: "Capsule") { + ctx.draw(rectangle, at: CGPoint(x: size.width/2, y: size.height/2)) + } + if let circle = ctx.resolveSymbol(id: "Circle") { + ctx.draw(circle, at: CGPoint(x: size.width/2 - rectSize2.width/2 + circleSize/2, y: size.height/2)) + } + } + } symbols: { + Capsule() + .frame(width: rectSize2.width, height: rectSize2.height, alignment: .center) + .tag("Capsule") + + Circle() + .frame(width: circleSize, height: circleSize, alignment: .center) + .offset(x: offset) + .animation(.spring(), value: isDragging) + .tag("Circle") + } + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .updating($isDragging, body: { _, state, _ in + state = true + }) + .onChanged({ value in + self.offset = min(max(self.previousOffset + value.translation.width, 0), rectSize2.width - circleSize) + }) + .onEnded({ value in + self.previousOffset = self.offset + }) + ) + Circle() + .fill(Color.black) + .frame(width: circleSize * 0.6) + .overlay { + Image(systemName: "speaker.wave.2.fill") + .imageScale(.small) + } + .offset(x: (-rectSize2.width/2) + (circleSize/2)) + .offset(x: offset) + .animation(.spring(), value: isDragging) + .allowsHitTesting(false) + } + } + + + private var animation: Animation { + .spring(response: 0.5, dampingFraction: 0.6, blendDuration: 0.5) + } + + private var percentage: Int { + Int((offset) / (rectSize.width - circleSize) * 100) + } +} + +#Preview { + FluidSlider() +} diff --git a/DynamicIsland/components/Tips/TipStore.swift b/DynamicIsland/components/Tips/TipStore.swift new file mode 100644 index 0000000..51b5d45 --- /dev/null +++ b/DynamicIsland/components/Tips/TipStore.swift @@ -0,0 +1,74 @@ +// +// TipStore.swift +// boringNotch +// +// Created by Richard Kunkli on 15/09/2024. +// + +import SwiftUI +import TipKit + +struct HUDsTip: Tip { + var title: Text { + Text("Enhance your experience with HUDs") + } + + + var message: Text? { + Text("Unlock advanced features and improve your experience. Upgrade now for more customizations!") + } + + + var image: Image? { + AppIcon(for: "dynamicisland.DynamicIsland") + } + + var actions: [Action] { + Action { + Text("More") + } + } +} + +struct CBTip: Tip { + var title: Text { + Text("Boost your productivity with Clipboard Manager") + } + + + var message: Text? { + Text("Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!") + } + + + var image: Image? { + AppIcon(for: "dynamicisland.DynamicIsland") + } + + var actions: [Action] { + Action { + Text("More") + } + } +} + +struct TipsView: View { + var hudTip = HUDsTip() + var cbTip = CBTip() + var body: some View { + VStack { + TipView(hudTip) + TipView(cbTip) + } + .task { + try? Tips.configure([ + .displayFrequency(.immediate), + .datastoreLocation(.applicationDefault) + ]) + } + } +} + +#Preview { + TipsView() +} diff --git a/DynamicIsland/components/Webcam/WebcamView.swift b/DynamicIsland/components/Webcam/WebcamView.swift new file mode 100644 index 0000000..e77f9c0 --- /dev/null +++ b/DynamicIsland/components/Webcam/WebcamView.swift @@ -0,0 +1,122 @@ +// +// WebcamView.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 19/08/24. +// + +import AVFoundation +import Defaults +import SwiftUI + +struct CameraPreviewView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var webcamManager: WebcamManager + + // Track if authorization request is in progress to avoid multiple requests + @State private var isRequestingAuthorization: Bool = false + + var body: some View { + GeometryReader { geometry in + ZStack { + 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)) + .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) + .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) + VStack(spacing: 8) { + Image(systemName: webcamManager.authorizationStatus == .denied ? "exclamationmark.triangle" : "web.camera") + .foregroundStyle(.gray) + .font(.system(size: geometry.size.width/3.5)) + Text(webcamManager.authorizationStatus == .denied ? "Access Denied" : "Mirror") + .font(.caption2) + .foregroundColor(.gray) + } + } + } + } + .onTapGesture { + handleCameraTap() + } + .onDisappear { + webcamManager.stopSession() + } + } + .aspectRatio(1, contentMode: .fit) + } + + private func handleCameraTap() { + if isRequestingAuthorization { + return // Prevent multiple authorization requests + } + + switch webcamManager.authorizationStatus { + case .authorized: + if webcamManager.isSessionRunning { + webcamManager.stopSession() + } else if webcamManager.cameraAvailable { + webcamManager.startSession() + } + case .denied, .restricted: + DispatchQueue.main.async { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.messageText = "Camera Access Required" + alert.informativeText = "Please allow camera access in System Settings to use the mirror feature." + alert.addButton(withTitle: "Open Settings") + alert.addButton(withTitle: "Cancel") + + if alert.runModal() == .alertFirstButtonReturn { + if let settingsURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera") { + NSWorkspace.shared.open(settingsURL) + } + } + NSApp.setActivationPolicy(.accessory) + NSApp.deactivate() + } + case .notDetermined: + isRequestingAuthorization = true + webcamManager.checkAndRequestVideoAuthorization() + // Reset the request flag after a reasonable delay + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + isRequestingAuthorization = false + } + @unknown default: + break + } + } +} + +struct CameraPreviewLayerView: NSViewRepresentable { + let previewLayer: AVCaptureVideoPreviewLayer + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + previewLayer.frame = view.bounds + previewLayer.videoGravity = .resizeAspectFill + view.layer = previewLayer + view.wantsLayer = true + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + CATransaction.begin() + CATransaction.setDisableActions(true) + previewLayer.frame = nsView.bounds + CATransaction.commit() + } +} + +#Preview { + CameraPreviewView(webcamManager: WebcamManager()) +} diff --git a/DynamicIsland/components/WhatsNewView.swift b/DynamicIsland/components/WhatsNewView.swift new file mode 100644 index 0000000..154558a --- /dev/null +++ b/DynamicIsland/components/WhatsNewView.swift @@ -0,0 +1,35 @@ +// +// WhatsNewView.swift +// boringNotch +// +// Created by Richard Kunkli on 09/08/2024. +// + +import SwiftUI + +struct WhatsNewView: View { + @Binding var isPresented: Bool + + var body: some View { + VStack(spacing: 20) { + Text("What's New") + .font(.largeTitle) + + VStack(alignment: .leading, spacing: 10) { + Text("β€’ New feature 1") + Text("β€’ Improved performance") + Text("β€’ Bug fixes") + } + + Button("Got it!") { + isPresented = false + } + } + .frame(width: 300, height: 200) + .padding() + } +} + +#Preview { + WhatsNewView(isPresented: .constant(true)) +} diff --git a/DynamicIsland/boring.m4a b/DynamicIsland/dynamic.m4a similarity index 100% rename from DynamicIsland/boring.m4a rename to DynamicIsland/dynamic.m4a diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift new file mode 100644 index 0000000..e70b459 --- /dev/null +++ b/DynamicIsland/enums/generic.swift @@ -0,0 +1,69 @@ +// +// generic.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 04/08/24. +// + +import Foundation +import Defaults + +public enum Style { + case notch + case floating +} + +public enum ContentType: Int, Codable, Hashable, Equatable { + case normal + case menu + case settings +} + +public enum NotchState { + case closed + case open +} + +public enum NotchViews { + case home + case shelf +} + +enum SettingsEnum { + case general + case about + case charge + case download + case mediaPlayback + case hud + case shelf + case extensions +} + +enum DownloadIndicatorStyle: String, Defaults.Serializable { + case progress = "Progress" + case percentage = "Percentage" +} + +enum DownloadIconStyle: String, Defaults.Serializable { + case onlyAppIcon = "Only app icon" + case onlyIcon = "Only download icon" + case iconAndAppIcon = "Icon and app icon" +} + +enum MirrorShapeEnum: String, Defaults.Serializable { + case rectangle = "Rectangular" + case circle = "Circular" +} + +enum WindowHeightMode: String, Defaults.Serializable { + case matchMenuBar = "Match menubar height" + case matchRealNotchSize = "Match real notch height" + case custom = "Custom height" +} + +enum SliderColorEnum: String, CaseIterable, Defaults.Serializable { + case white = "White" + case albumArt = "Match album art" + case accent = "Accent color" +} diff --git a/DynamicIsland/extensions/ActionBar.swift b/DynamicIsland/extensions/ActionBar.swift new file mode 100644 index 0000000..28d0881 --- /dev/null +++ b/DynamicIsland/extensions/ActionBar.swift @@ -0,0 +1,30 @@ +// +// ActionBar.swift +// boringNotch +// +// Created by Richard Kunkli on 15/09/2024. +// + +import SwiftUI + +extension View { + func actionBar(padding: CGFloat = 10, @ViewBuilder content: () -> Content) -> some View { + self + .padding(.bottom, 24) + .overlay(alignment: .bottom) { + VStack(spacing: -1) { + Divider() + HStack(spacing: 0) { + content() + .buttonStyle(PlainButtonStyle()) + } + .frame(height: 16) + .padding(.vertical, 4) + .padding(.horizontal, padding) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 24) + .background(.separator) + } + } +} diff --git a/DynamicIsland/extensions/BundleInfos.swift b/DynamicIsland/extensions/BundleInfos.swift new file mode 100644 index 0000000..02de5d1 --- /dev/null +++ b/DynamicIsland/extensions/BundleInfos.swift @@ -0,0 +1,57 @@ +// +// BundleInfos.swift +// boringNotch +// +// Created by Richard Kunkli on 08/08/2024. +// + +import SwiftUI + +extension Bundle { + var releaseVersionNumber: String? { + return infoDictionary?["CFBundleShortVersionString"] as? String + } + var buildVersionNumber: String? { + return infoDictionary?["CFBundleVersion"] as? String + } + var releaseVersionNumberPretty: String { + return "v\(releaseVersionNumber ?? "1.0.0")" + } + + var iconFileName: String? { + guard let icons = infoDictionary?["CFBundleIcons"] as? [String: Any], + let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any], + let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String], + let iconFileName = iconFiles.last + else { return nil } + return iconFileName + } +} + +struct BundleAppIcon: View { + var body: some View { + Bundle.main.iconFileName + .flatMap { NSImage(named: $0) } + .map { Image(nsImage: $0) } + } +} + +func isNewVersion() -> Bool { + let defaults = UserDefaults.standard + let currentVersion = Bundle.main.releaseVersionNumber ?? "1.0" + let savedVersion = defaults.string(forKey: "LastVersionRun") ?? "" + + if currentVersion != savedVersion { + defaults.set(currentVersion, forKey: "LastVersionRun") + return true + } + return false +} + +func isExtensionRunning(_ bundleID: String) -> Bool { + if let _ = NSWorkspace.shared.runningApplications.first(where: {$0.bundleIdentifier == bundleID}) { + return true + } + + return false +} diff --git a/DynamicIsland/extensions/Button+Bouncing.swift b/DynamicIsland/extensions/Button+Bouncing.swift new file mode 100644 index 0000000..17ca476 --- /dev/null +++ b/DynamicIsland/extensions/Button+Bouncing.swift @@ -0,0 +1,35 @@ +// +// Button+Bouncing.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 19/08/24. +// +import SwiftUI +import Defaults + +struct BouncingButtonStyle: ButtonStyle { + let vm: DynamicIslandViewModel + @State private var isPressed = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(12) + .background( + RoundedRectangle(cornerRadius: Defaults[.cornerRadiusScaling] ? 10 : MusicPlayerImageSizes.cornerRadiusInset.closed) + .fill(Color(red: 20/255, green: 20/255, blue: 20/255)) + .strokeBorder(.white.opacity(0.04), lineWidth: 1) + ) + .scaleEffect(isPressed ? 0.9 : 1.0) + .onChange(of: configuration.isPressed) { _, _ in + withAnimation(.spring(response: 0.3, dampingFraction: 0.3, blendDuration: 0.3)) { + isPressed.toggle() + } + } + } +} + +extension Button { + func bouncingStyle(vm: DynamicIslandViewModel) -> some View { + self.buttonStyle(BouncingButtonStyle(vm: vm)) + } +} diff --git a/DynamicIsland/extensions/ConditionalModifier.swift b/DynamicIsland/extensions/ConditionalModifier.swift new file mode 100644 index 0000000..9903e74 --- /dev/null +++ b/DynamicIsland/extensions/ConditionalModifier.swift @@ -0,0 +1,18 @@ +// +// ConditionalModifier.swift +// boringNotch +// +// Created by Richard Kunkli on 20/08/2024. +// + +import SwiftUI + +extension View { + @ViewBuilder func conditionalModifier(_ condition: Bool, transform: (Self) -> Content) -> some View { + if condition { + transform(self) + } else { + self + } + } +} diff --git a/DynamicIsland/extensions/DataTypes+Extensions.swift b/DynamicIsland/extensions/DataTypes+Extensions.swift new file mode 100644 index 0000000..1b662e2 --- /dev/null +++ b/DynamicIsland/extensions/DataTypes+Extensions.swift @@ -0,0 +1,94 @@ + // + // DataTypes+Extensions.swift + // boringNotch + // + // Created by Harsh Vardhan Goswami on 27/08/24. + // + +import Foundation + + + +extension Date { + static var yesterday: Date { return Date().dayBefore } + static var tomorrow: Date { return Date().dayAfter } + var dayBefore: Date { + return Calendar.current.date(byAdding: .day, value: -1, to: noon)! + } + var dayAfter: Date { + return Calendar.current.date(byAdding: .day, value: 1, to: noon)! + } + var noon: Date { + return Calendar.current.date(bySettingHour: 12, minute: 0, second: 0, of: self)! + } + + var date: String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd" + return dateFormatter.string(from: self) + } + + var month: String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMMM" + return dateFormatter.string(from: self) + } + + func dayOfTheWeek(dayOfWeek: Int) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "EEE" + let date = Calendar.current.date(bySetting: .weekday, value: dayOfWeek, of: self) ?? self + return dateFormatter.string(from: date) + } +} + +extension NSSize { + var s: String { "\(width.i)Γ—\(height.i)" } + + var aspectRatio: Double { + width / height + } + func scaled(by factor: Double) -> CGSize { + CGSize(width: (width * factor).evenInt, height: (height * factor).evenInt) + } + +} + +extension Int { + var s: String { + String(self) + } + var d: Double { + Double(self) + } +} + +extension Double { + @inline(__always) @inlinable var intround: Int { + rounded().i + } + + @inline(__always) @inlinable var i: Int { + Int(self) + } + + var evenInt: Int { + let x = intround + return x + x % 2 + } +} + +extension CGFloat { + @inline(__always) @inlinable var intround: Int { + rounded().i + } + + @inline(__always) @inlinable var i: Int { + Int(self) + } + + var evenInt: Int { + let x = intround + return x + x % 2 + } +} diff --git a/DynamicIsland/extensions/KeyboardShortcutsHelper.swift b/DynamicIsland/extensions/KeyboardShortcutsHelper.swift new file mode 100644 index 0000000..1e6548e --- /dev/null +++ b/DynamicIsland/extensions/KeyboardShortcutsHelper.swift @@ -0,0 +1,88 @@ +// +// KeyboardShortcutsHelper.swift +// boringNotch +// +// Created by Richard Kunkli on 16/08/2024. +// + +import KeyboardShortcuts +import SwiftUI +import Carbon + + +extension View { + + public func keyboardShortcut(_ shortcut: KeyboardShortcuts.Name) -> some View { + if let shortcut = shortcut.shortcut { + if let keyEquivalent = shortcut.toKeyEquivalent() { + return AnyView(self.keyboardShortcut(keyEquivalent, modifiers: shortcut.toEventModifiers())) + } + } + + return AnyView(self) + } + +} + +extension KeyboardShortcuts.Shortcut { + + func toKeyEquivalent() -> KeyEquivalent? { + let carbonKeyCode = UInt16(self.carbonKeyCode) + let maxNameLength = 4 + var nameBuffer = [UniChar](repeating: 0, count : maxNameLength) + var nameLength = 0 + + let modifierKeys = UInt32(alphaLock >> 8) & 0xFF // Caps Lock + var deadKeys: UInt32 = 0 + let keyboardType = UInt32(LMGetKbdType()) + + let source = TISCopyCurrentKeyboardLayoutInputSource().takeRetainedValue() + guard let ptr = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { + NSLog("Could not get keyboard layout data") + return nil + } + let layoutData = Unmanaged.fromOpaque(ptr).takeUnretainedValue() as Data + let osStatus = layoutData.withUnsafeBytes { + UCKeyTranslate($0.bindMemory(to: UCKeyboardLayout.self).baseAddress, carbonKeyCode, UInt16(kUCKeyActionDown), + modifierKeys, keyboardType, UInt32(kUCKeyTranslateNoDeadKeysMask), + &deadKeys, maxNameLength, &nameLength, &nameBuffer) + } + guard osStatus == noErr else { + NSLog("Code: 0x%04X Status: %+i", carbonKeyCode, osStatus); + return nil + } + + return KeyEquivalent(Character(String(utf16CodeUnits: nameBuffer, count: nameLength))) + } + + func toEventModifiers() -> SwiftUI.EventModifiers { + var modifiers: SwiftUI.EventModifiers = [] + + if self.modifiers.contains(NSEvent.ModifierFlags.command) { + modifiers.update(with: EventModifiers.command) + } + + if self.modifiers.contains(NSEvent.ModifierFlags.control) { + modifiers.update(with: EventModifiers.control) + } + + if self.modifiers.contains(NSEvent.ModifierFlags.option) { + modifiers.update(with: EventModifiers.option) + } + + if self.modifiers.contains(NSEvent.ModifierFlags.shift) { + modifiers.update(with: EventModifiers.shift) + } + + if self.modifiers.contains(NSEvent.ModifierFlags.capsLock) { + modifiers.update(with: EventModifiers.capsLock) + } + + if self.modifiers.contains(NSEvent.ModifierFlags.numericPad) { + modifiers.update(with: EventModifiers.numericPad) + } + + return modifiers + } + +} diff --git a/DynamicIsland/extensions/MouseTracker.swift b/DynamicIsland/extensions/MouseTracker.swift new file mode 100644 index 0000000..6b8eb7f --- /dev/null +++ b/DynamicIsland/extensions/MouseTracker.swift @@ -0,0 +1,18 @@ +// +// MouseTracker.swift +// boringNotch +// +// Created by Richard Kunkli on 12/08/2024. +// + +import SwiftUI + +extension NSScreen { + static var screenWithMouse: NSScreen? { + let mouseLocation = NSEvent.mouseLocation + let screens = NSScreen.screens + let screenWithMouse = (screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }) + + return screenWithMouse + } +} diff --git a/DynamicIsland/extensions/NSImage+Extensions.swift b/DynamicIsland/extensions/NSImage+Extensions.swift new file mode 100644 index 0000000..c47a98b --- /dev/null +++ b/DynamicIsland/extensions/NSImage+Extensions.swift @@ -0,0 +1,169 @@ +// +// Image2Color.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import SwiftUI +import AppKit +import Cocoa +import Foundation +import CoreImage +import CoreGraphics +import CoreImage.CIFilterBuiltins + +extension NSImage { + + + func averageColor(completion: @escaping (NSColor?) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + DispatchQueue.main.async { + completion(nil) + } + return + } + + let width = cgImage.width + let height = cgImage.height + let totalPixels = width * height + + guard let context = CGContext(data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { + DispatchQueue.main.async { + completion(nil) + } + return + } + + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + guard let data = context.data else { + DispatchQueue.main.async { + completion(nil) + } + return + } + + let pointer = data.bindMemory(to: UInt32.self, capacity: totalPixels) + + var totalRed: UInt64 = 0 + var totalGreen: UInt64 = 0 + var totalBlue: UInt64 = 0 + + for i in 0..> 8) & 0xFF) + totalBlue += UInt64((color >> 16) & 0xFF) + } + + let averageRed = CGFloat(totalRed) / CGFloat(totalPixels) / 255.0 + let averageGreen = CGFloat(totalGreen) / CGFloat(totalPixels) / 255.0 + let averageBlue = CGFloat(totalBlue) / CGFloat(totalPixels) / 255.0 + + let minBrightness: CGFloat = 0.5 + let isNearBlack = averageRed < 0.03 && averageGreen < 0.03 && averageBlue < 0.03 + + var finalColor: NSColor + + if isNearBlack { + // If it's near black, just return a gray color with the minimum brightness + finalColor = NSColor(white: minBrightness, alpha: 1.0) + } else { + var color = NSColor(red: averageRed, green: averageGreen, blue: averageBlue, alpha: 1.0) + + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + + color.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + if brightness < minBrightness { + // Increase brightness while maintaining hue and reducing saturation + let saturationScale = brightness / minBrightness + color = NSColor(hue: hue, + saturation: saturation * saturationScale, + brightness: minBrightness, + alpha: alpha) + } + + finalColor = color + } + + DispatchQueue.main.async { + completion(finalColor) + } + } + + } + + func getBrightness() -> CGFloat { + guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return 0 + } + + let inputImage = CIImage(cgImage: cgImage) + + let filter = CIFilter.areaAverage() + filter.inputImage = inputImage + filter.extent = inputImage.extent + + guard let outputImage = filter.outputImage else { + return 0 + } + + let context = CIContext(options: nil) + + var bitmap = [UInt8](repeating: 0, count: 4) + context.render(outputImage, + toBitmap: &bitmap, + rowBytes: 4, + bounds: CGRect(x: 0, y: 0, width: 1, height: 1), + format: .RGBA8, + colorSpace: CGColorSpaceCreateDeviceRGB()) + + let brightness = (0.2126 * CGFloat(bitmap[0]) + 0.7152 * CGFloat(bitmap[1]) + 0.0722 * CGFloat(bitmap[2])) / 255.0 + + return brightness + } +} + +extension Color { + func ensureMinimumBrightness(factor: CGFloat) -> Color { + guard factor >= 0 && factor <= 1 else { + return self // Return original color if factor is out of bounds + } + + let nsColor = NSColor(self) + + // Convert to RGB color space + guard let rgbColor = nsColor.usingColorSpace(.sRGB) else { + return self // Return original color if conversion fails + } + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + rgbColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + // Calculate perceived brightness using the formula: (0.299*R + 0.587*G + 0.114*B) + let perceivedBrightness = (0.2126 * red + 0.7152 * green + 0.0722 * blue) + + let scale = factor / perceivedBrightness + red = min(red * scale, 1.0) + green = min(green * scale, 1.0) + blue = min(blue * scale, 1.0) + + return Color(red: Double(red), green: Double(green), blue: Double(blue), opacity: Double(alpha)) + } +} diff --git a/DynamicIsland/extensions/PanGesture.swift b/DynamicIsland/extensions/PanGesture.swift new file mode 100644 index 0000000..4915a23 --- /dev/null +++ b/DynamicIsland/extensions/PanGesture.swift @@ -0,0 +1,101 @@ +// +// PanGesture.swift +// boringNotch +// +// Created by Richard Kunkli on 21/08/2024. +// + +import SwiftUI +import AppKit + +extension View { + func panGesture(direction: PanDirection, action: @escaping (CGFloat, NSEvent.Phase) -> Void) -> some View { + background( + PanGestureView(direction: direction, action: action) + .frame(maxWidth: 0, maxHeight: 0) + ) + } +} + +struct PanGestureView: NSViewRepresentable { + let direction: PanDirection + let action: (CGFloat, NSEvent.Phase) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { event in + if event.window == view.window { + context.coordinator.handleEvent(event) + } + return event + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + + } + + func makeCoordinator() -> Coordinator { + Coordinator(direction: direction, action: action) + } + + class Coordinator: NSObject { + let direction: PanDirection + let action: (CGFloat, NSEvent.Phase) -> Void + + var accumulatedScrollDeltaX: CGFloat = 0 + var accumulatedScrollDeltaY: CGFloat = 0 + + init(direction: PanDirection, action: @escaping (CGFloat, NSEvent.Phase) -> Void) { + self.direction = direction + self.action = action + } + + @objc func handleEvent(_ event: NSEvent) { + if event.type == .scrollWheel { + accumulatedScrollDeltaX += event.scrollingDeltaX + accumulatedScrollDeltaY += event.scrollingDeltaY + + switch direction { + case .down: + if accumulatedScrollDeltaY > 0 { + handle() + } + case .up: + if accumulatedScrollDeltaY < 0 { + handle() + } + case .left: + if accumulatedScrollDeltaX < 0 { + handle() + } + case .right: + if accumulatedScrollDeltaX > 0 { + handle() + } + } + + func handle() { + if (direction == .left || direction == .right) { + action(abs(accumulatedScrollDeltaX), event.phase) + } else { + action(abs(accumulatedScrollDeltaY), event.phase) + } + } + + if event.phase == .ended { + accumulatedScrollDeltaY = 0 + accumulatedScrollDeltaX = 0 + } + } + } + } +} + +enum PanDirection { + case left + case right + case up + case down +} diff --git a/DynamicIsland/helpers/AppIcons.swift b/DynamicIsland/helpers/AppIcons.swift new file mode 100644 index 0000000..ecb58b6 --- /dev/null +++ b/DynamicIsland/helpers/AppIcons.swift @@ -0,0 +1,67 @@ +// +// AppIcons.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 16/08/24. +// + +import SwiftUI +import AppKit + +struct AppIcons { + + func getIcon(file path: String) -> NSImage? { + guard FileManager.default.fileExists(atPath: path) + else { return nil } + + return NSWorkspace.shared.icon(forFile: path) + } + + func getIcon(bundleID: String) -> NSImage? { + guard let path = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: bundleID + )?.absoluteString + else { return nil } + + return getIcon(file: path) + } + + func getIcon(application: String) -> NSImage? { + guard let path = NSWorkspace.shared.fullPath(forApplication: application) + else { return nil } + + return getIcon(file: path) + } + + /// Easily read Info.plist as a Dictionary from any bundle by accessing .infoDictionary on Bundle + func bundle(forBundleID: String) -> Bundle? { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: forBundleID) + else { return nil } + + return Bundle(url: url) + } + +} + +func AppIcon(for bundleID: String) -> Image { + let workspace = NSWorkspace.shared + + if let appURL = workspace.urlForApplication(withBundleIdentifier: bundleID) { + let appIcon = workspace.icon(forFile: appURL.path) + return Image(nsImage: appIcon) + } + + return Image(nsImage: workspace.icon(for: .applicationBundle)) +} + + +func AppIconAsNSImage(for bundleID: String) -> NSImage? { + let workspace = NSWorkspace.shared + + if let appURL = workspace.urlForApplication(withBundleIdentifier: bundleID) { + let appIcon = workspace.icon(forFile: appURL.path) + return appIcon + } + return nil +} + diff --git a/DynamicIsland/helpers/AppleScriptHelper.swift b/DynamicIsland/helpers/AppleScriptHelper.swift new file mode 100644 index 0000000..966b84a --- /dev/null +++ b/DynamicIsland/helpers/AppleScriptHelper.swift @@ -0,0 +1,31 @@ +// +// AppleScriptHelper.swift +// boringNotch +// +// Created by Alexander Greco on 2025-03-29. +// + +import Foundation + +class AppleScriptHelper { + @discardableResult + class func execute(_ scriptText: String) async throws -> NSAppleEventDescriptor? { + try await withCheckedThrowingContinuation { continuation in + Task.detached(priority: .userInitiated) { + let script = NSAppleScript(source: scriptText) + var error: NSDictionary? + if let descriptor = script?.executeAndReturnError(&error) { + continuation.resume(returning: descriptor) + } else if let error = error { + continuation.resume(throwing: NSError(domain: "AppleScriptError", code: 1, userInfo: error as? [String: Any])) + } else { + continuation.resume(throwing: NSError(domain: "AppleScriptError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unknown error"])) + } + } + } + } + + class func executeVoid(_ scriptText: String) async throws { + _ = try await execute(scriptText) + } +} diff --git a/DynamicIsland/helpers/AudioPlayer.swift b/DynamicIsland/helpers/AudioPlayer.swift new file mode 100644 index 0000000..c202873 --- /dev/null +++ b/DynamicIsland/helpers/AudioPlayer.swift @@ -0,0 +1,15 @@ +// +// AudioPlayer.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 09/08/24. +// + +import Foundation +import AppKit + +class AudioPlayer { + func play(fileName: String, fileExtension: String) { + NSSound(contentsOf:Bundle.main.url(forResource: fileName, withExtension: fileExtension)!, byReference: false)?.play() + } +} diff --git a/DynamicIsland/helpers/Clipboard+Content.swift b/DynamicIsland/helpers/Clipboard+Content.swift new file mode 100644 index 0000000..8b7d02e --- /dev/null +++ b/DynamicIsland/helpers/Clipboard+Content.swift @@ -0,0 +1,25 @@ +import AppKit + +func getAttributedString(content: Any, type: NSPasteboard.PasteboardType) -> NSAttributedString? { + if let stringContent = content as? String { + return NSAttributedString(string: stringContent) + } + if type == .rtf, let data = content as? Data { + return NSAttributedString(rtf: data, documentAttributes: nil) + } else if type == .html, let data = content as? Data { + return try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) + } else if type.rawValue == "public.utf8-plain-text", let data = content as? Data { + return try? NSAttributedString(data: data, documentAttributes: nil) + } else if type == .string { + return NSAttributedString(string: content as? String ?? "") + } else if type == .fileURL { + return NSAttributedString(string: content as? String ?? "") + } else if type == NSPasteboard.PasteboardType("com.apple.webarchive"), let data = content as? Data { + return try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.webArchive], documentAttributes: nil) + } + return nil +} + +func isText(type: NSPasteboard.PasteboardType) -> Bool { + return type == .string || type == .html || type == .rtf || type == .html || type == .string || type.rawValue == "public.utf8-plain-text" || type.rawValue == "public.utf16-external-plain-text" || type.rawValue == "com.apple.webarchive" +} diff --git a/DynamicIsland/managers/BatteryActivityManager.swift b/DynamicIsland/managers/BatteryActivityManager.swift new file mode 100644 index 0000000..61b1c66 --- /dev/null +++ b/DynamicIsland/managers/BatteryActivityManager.swift @@ -0,0 +1,322 @@ +import Foundation +import IOKit.ps + +/// Manages and monitors battery status changes on the device +/// - Note: This class uses the IOKit framework to monitor battery status +class BatteryActivityManager { + + static let shared = BatteryActivityManager() + + var onBatteryLevelChange: ((Float) -> Void)? + var onMaxCapacityChange: ((Float) -> Void)? + var onPowerModeChange: ((Bool) -> Void)? + var onPowerSourceChange: ((Bool) -> Void)? + var onChargingChange: ((Bool) -> Void)? + var onTimeToFullChargeChange: ((Int) -> Void)? + + private var batterySource: CFRunLoopSource? + private var observers: [(BatteryEvent) -> Void] = [] + private var previousBatteryInfo: BatteryInfo? + private var notificationQueue: [BatteryEvent] = [] + private var isProcessingNotifications = false + + enum BatteryEvent { + case powerSourceChanged(isPluggedIn: Bool) + case batteryLevelChanged(level: Float) + case lowPowerModeChanged(isEnabled: Bool) + case isChargingChanged(isCharging: Bool) + case timeToFullChargeChanged(time: Int) + case maxCapacityChanged(capacity: Float) + case error(description: String) + } + + enum BatteryError: Error { + case powerSourceUnavailable + case batteryInfoUnavailable(String) + case batteryParameterMissing(String) + } + + private let defaultBatteryInfo = BatteryInfo( + isPluggedIn: false, + isCharging: false, + currentCapacity: 0, + maxCapacity: 0, + isInLowPowerMode: false, + timeToFullCharge: 0 + ) + + private init() { + startMonitoring() + setupLowPowerModeObserver() + } + + /// Setup observer for low power mode changes + private func setupLowPowerModeObserver() { + NotificationCenter.default.addObserver( + self, + selector: #selector(lowPowerModeChanged), + name: NSNotification.Name.NSProcessInfoPowerStateDidChange, + object: nil + ) + } + + /// Called when low power mode is enabled or disabled + @objc private func lowPowerModeChanged() { + notifyBatteryChanges() + } + + /// Starts monitoring battery changes + private func startMonitoring() { + guard let powerSource = IOPSNotificationCreateRunLoopSource({ context in + guard let context = context else { return } + let manager = Unmanaged.fromOpaque(context).takeUnretainedValue() + manager.notifyBatteryChanges() + }, Unmanaged.passUnretained(self).toOpaque())?.takeRetainedValue() else { + return + } + batterySource = powerSource + CFRunLoopAddSource(CFRunLoopGetCurrent(), powerSource, .defaultMode) + } + + /// Stops monitoring battery changes + private func stopMonitoring() { + if let powerSource = batterySource { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), powerSource, .defaultMode) + batterySource = nil + } + } + + /// Checks for changes in a property and notifies observers + private func checkAndNotify( + previous: T, + current: T, + eventGenerator: (T) -> BatteryEvent + ) { + if previous != current { + enqueueNotification(eventGenerator(current)) + } + } + + /// Notifies the observers of battery changes + /// Checks for changes in battery status and notifies observers + private func notifyBatteryChanges() { + let batteryInfo = getBatteryInfo() + + // Check for changes + if let previousInfo = previousBatteryInfo { + // Usar la funciΓ³n auxiliar para cada propiedad + checkAndNotify( + previous: previousInfo.isPluggedIn, + current: batteryInfo.isPluggedIn, + eventGenerator: { .powerSourceChanged(isPluggedIn: $0) } + ) + + checkAndNotify( + previous: previousInfo.currentCapacity, + current: batteryInfo.currentCapacity, + eventGenerator: { .batteryLevelChanged(level: $0) } + ) + + checkAndNotify( + previous: previousInfo.isCharging, + current: batteryInfo.isCharging, + eventGenerator: { .isChargingChanged(isCharging: $0) } + ) + + checkAndNotify( + previous: previousInfo.isInLowPowerMode, + current: batteryInfo.isInLowPowerMode, + eventGenerator: { .lowPowerModeChanged(isEnabled: $0) } + ) + + checkAndNotify( + previous: previousInfo.timeToFullCharge, + current: batteryInfo.timeToFullCharge, + eventGenerator: { .timeToFullChargeChanged(time: $0) } + ) + + checkAndNotify( + previous: previousInfo.maxCapacity, + current: batteryInfo.maxCapacity, + eventGenerator: { .maxCapacityChanged(capacity: $0) } + ) + } else { + // First time notification + enqueueNotification(.powerSourceChanged(isPluggedIn: batteryInfo.isPluggedIn)) + enqueueNotification(.batteryLevelChanged(level: batteryInfo.currentCapacity)) + enqueueNotification(.isChargingChanged(isCharging: batteryInfo.isCharging)) + enqueueNotification(.lowPowerModeChanged(isEnabled: batteryInfo.isInLowPowerMode)) + enqueueNotification(.timeToFullChargeChanged(time: batteryInfo.timeToFullCharge)) + enqueueNotification(.maxCapacityChanged(capacity: batteryInfo.maxCapacity)) + } + + // Update previous battery info + previousBatteryInfo = batteryInfo + + // Trigger optional callbacks + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.onBatteryLevelChange?(batteryInfo.currentCapacity) + self.onPowerSourceChange?(batteryInfo.isPluggedIn) + self.onChargingChange?(batteryInfo.isCharging) + self.onPowerModeChange?(batteryInfo.isInLowPowerMode) + self.onTimeToFullChargeChange?(batteryInfo.timeToFullCharge) + self.onMaxCapacityChange?(batteryInfo.maxCapacity) + } + } + + /// Enqueues a notification to be processed + /// - Parameter event: The battery event + private func enqueueNotification(_ event: BatteryEvent) { + notificationQueue.append(event) + processNextNotification() + } + + /// Processes the next notification in the queue + /// If there are no more notifications, the queue is cleared + /// and the processing flag is set to false + private func processNextNotification() { + guard !isProcessingNotifications, !notificationQueue.isEmpty else { return } + isProcessingNotifications = true + + let event = notificationQueue.removeFirst() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + guard let self = self else { return } + self.notifyObservers(event: event) + self.isProcessingNotifications = false + + // Check if there are more items in the queue + if !self.notificationQueue.isEmpty { + self.processNextNotification() + } + } + } + + /// Initializes the battery information when the manager starts + /// - Returns: Current battery information + func initializeBatteryInfo() -> BatteryInfo { + previousBatteryInfo = getBatteryInfo() + guard let batteryInfo = previousBatteryInfo else { + return BatteryInfo( + isPluggedIn: false, + isCharging: false, + currentCapacity: 0, + maxCapacity: 0, + isInLowPowerMode: false, + timeToFullCharge: 0 + ) + } + return batteryInfo + } + + /// Get the current battery information + /// - Returns: The current battery information + private func getBatteryInfo() -> BatteryInfo { + do { + // Get power source information + guard let snapshot = IOPSCopyPowerSourcesInfo()?.takeRetainedValue() else { + throw BatteryError.powerSourceUnavailable + } + + guard let sources = IOPSCopyPowerSourcesList(snapshot)?.takeRetainedValue() as? [CFTypeRef], + !sources.isEmpty else { + throw BatteryError.batteryInfoUnavailable("No power sources available") + } + + let source = sources.first! + + guard let description = IOPSGetPowerSourceDescription(snapshot, source)?.takeUnretainedValue() as? [String: Any] else { + throw BatteryError.batteryInfoUnavailable("Could not get power source description") + } + + // Extract required battery parameters with error handling + guard let currentCapacity = description[kIOPSCurrentCapacityKey] as? Float else { + throw BatteryError.batteryParameterMissing("Current capacity") + } + + guard let maxCapacity = description[kIOPSMaxCapacityKey] as? Float else { + throw BatteryError.batteryParameterMissing("Max capacity") + } + + guard let isCharging = description["Is Charging"] as? Bool else { + throw BatteryError.batteryParameterMissing("Charging state") + } + + guard let powerSource = description[kIOPSPowerSourceStateKey] as? String else { + throw BatteryError.batteryParameterMissing("Power source state") + } + + // Create battery info with the extracted parameters + var batteryInfo = BatteryInfo( + isPluggedIn: powerSource == kIOPSACPowerValue, + isCharging: isCharging, + currentCapacity: currentCapacity, + maxCapacity: maxCapacity, + isInLowPowerMode: ProcessInfo.processInfo.isLowPowerModeEnabled, + timeToFullCharge: 0 + ) + + // Optional parameters + if let timeToFullCharge = description[kIOPSTimeToFullChargeKey] as? Int { + batteryInfo.timeToFullCharge = timeToFullCharge + } + + return batteryInfo + + } catch BatteryError.powerSourceUnavailable { + print("⚠️ Error: Power source information unavailable") + return defaultBatteryInfo + } catch BatteryError.batteryInfoUnavailable(let reason) { + print("⚠️ Error: Battery information unavailable - \(reason)") + return defaultBatteryInfo + } catch BatteryError.batteryParameterMissing(let parameter) { + print("⚠️ Error: Battery parameter missing - \(parameter)") + return defaultBatteryInfo + } catch { + print("⚠️ Error: Unexpected error getting battery info - \(error.localizedDescription)") + return defaultBatteryInfo + } + } + + /// Adds an observer to listen to battery changes + /// - Parameter observer: The observer closure to be called on battery events + /// - Returns: The ID of the observer for later removal + func addObserver(_ observer: @escaping (BatteryEvent) -> Void) -> Int { + observers.append(observer) + return observers.count - 1 + } + + /// Removes an observer by its ID + /// - Parameter id: The ID of the observer to be removed + func removeObserver(byId id: Int) { + guard id >= 0 && id < observers.count else { return } + observers.remove(at: id) + } + + /// Notifies all observers of a battery event + /// - Parameter event: The battery event to notify + private func notifyObservers(event: BatteryEvent) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + for observer in self.observers { + observer(event) + } + } + } + + deinit { + stopMonitoring() + NotificationCenter.default.removeObserver(self) + } + +} + +/// Struct to hold battery information +struct BatteryInfo { + var isPluggedIn: Bool + var isCharging: Bool + var currentCapacity: Float + var maxCapacity: Float + var isInLowPowerMode: Bool + var timeToFullCharge: Int +} diff --git a/DynamicIsland/managers/CalendarManager.swift b/DynamicIsland/managers/CalendarManager.swift new file mode 100644 index 0000000..9922659 --- /dev/null +++ b/DynamicIsland/managers/CalendarManager.swift @@ -0,0 +1,136 @@ +// +// CalenderManager.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 08/09/24. +// + +import EventKit +import SwiftUI +import Defaults + +// MARK: - CalendarManager + +class CalendarManager: ObservableObject { + @Published var currentWeekStartDate: Date + @Published var events: [EKEvent] = [] + @Published var allCalendars: [EKCalendar] = [] + private var selectedCalendars: [EKCalendar] = [] + private let eventStore = EKEventStore() + @Published var authorizationStatus: EKAuthorizationStatus = .notDetermined + + init() { + self.currentWeekStartDate = CalendarManager.startOfDay(Date()) + if(Defaults[.showCalendar]) { + checkCalendarAuthorization() + } + } + + func checkCalendarAuthorization() { + let status = EKEventStore.authorizationStatus(for: .event) + DispatchQueue.main.async { + print("πŸ“… Current calendar authorization status: \(status)") + self.authorizationStatus = status + } + + switch status { + case .notDetermined: + requestCalendarAccess() + case .restricted, .denied: + // Handle the case where the user has denied or restricted access + NSLog("Calendar access denied or restricted") + case .fullAccess: + NSLog("Full access") + self.allCalendars = eventStore.calendars(for: .event) + updateSelectedCalendars() + fetchEvents() + case .writeOnly: + NSLog("Write only") + @unknown default: + print("Unknown authorization status") + } + } + + func requestCalendarAccess() { + eventStore.requestFullAccessToEvents { [weak self] granted, error in + DispatchQueue.main.async { + if let error = error { + print("πŸ“… Calendar access error: \(error.localizedDescription)") + } + + self?.authorizationStatus = granted ? .fullAccess : .denied + if granted { + print("πŸ“… Calendar access granted") + self?.fetchEvents() + } else { + print("πŸ“… Calendar access denied") + } + } + } + } + + func updateSelectedCalendars() { + selectedCalendars = allCalendars.filter { getCalendarSelected($0) } + } + + func getCalendarSelected(_ calendar: EKCalendar) -> Bool { + switch Defaults[.calendarSelectionState] { + case .all: + return true + case .selected(let identifiers): + return identifiers.contains(calendar.calendarIdentifier) + } + } + + func setCalendarSelected(_ calendar: EKCalendar, isSelected: Bool) { + var selectionState = Defaults[.calendarSelectionState] + + switch selectionState { + case .all: + if !isSelected { + let identifiers = Set(allCalendars.map { $0.calendarIdentifier }).subtracting([calendar.calendarIdentifier]) + selectionState = .selected(identifiers) + } + + case .selected(var identifiers): + if isSelected { + identifiers.insert(calendar.calendarIdentifier) + } else { + identifiers.remove(calendar.calendarIdentifier) + } + + selectionState = identifiers.count == allCalendars.count ? .all : .selected(identifiers) + } + + Defaults[.calendarSelectionState] = selectionState + fetchEvents() + } + + func fetchEvents() { + guard !self.selectedCalendars.isEmpty else { + DispatchQueue.main.async { + self.events = [] + } + return + } + + let endOfWeek = Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)! + let predicate = eventStore.predicateForEvents(withStart: currentWeekStartDate, end: endOfWeek, calendars: self.selectedCalendars) + let fetchedEvents = eventStore.events(matching: predicate) + + DispatchQueue.main.async { + self.events = fetchedEvents.sorted { $0.startDate < $1.startDate } + print("πŸ“… Fetched \(self.events.count) calendar events") + } + } + + static func startOfDay(_ date: Date) -> Date { + return Calendar.current.startOfDay(for: date) + } + + func updateCurrentDate(_ date: Date) { + print("πŸ“… Updating current date to: \(date)") + currentWeekStartDate = Calendar.current.startOfDay(for: date) + fetchEvents() + } +} diff --git a/DynamicIsland/managers/DynamicIslandExtensionManager.swift b/DynamicIsland/managers/DynamicIslandExtensionManager.swift new file mode 100644 index 0000000..881ae41 --- /dev/null +++ b/DynamicIsland/managers/DynamicIslandExtensionManager.swift @@ -0,0 +1,59 @@ +// +// DynamicIslandExtensionManager.swift +// DynamicIsland +// +// Created by Harsh Vardhan Goswami on 04/08/24. +// BoringExtensionManager.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 07/09/24. +// + +import Foundation +import SwiftUI + +var clipboardExtension: String = "dynamicisland.TheDynamicClipboard" +var hudExtension: String = "dynamicisland.TheDynamicHUDs" +var downloadManagerExtension: String = "dynamicisland.TheDynamicDownloadManager" + +struct Extension: Identifiable, Hashable { + var id = UUID() + var name: String + var bundleIdentifier: String + var status: StatusModel = .enabled +} + +enum StatusModel { + case disabled + case enabled +} + +class DynamicIslandExtensionManager: ObservableObject { + + @Published var installedExtensions: [Extension] = [] { + didSet { + print("Extensions installed: \(installedExtensions)") + } + } + + var extensions = [ + clipboardExtension, + hudExtension + ] + + init() { + checkIfExtensionsAreInstalled() + + DistributedNotificationCenter.default().addObserver(self, selector: #selector(checkIfExtensionsAreInstalled), name: NSNotification.Name("NSWorkspaceDidLaunchApplicationNotification"), object: nil) + } + + @objc func checkIfExtensionsAreInstalled() { + installedExtensions = [] + for extensionName in extensions { + if NSWorkspace.shared.urlForApplication(withBundleIdentifier: extensionName) != nil { + let ext = Extension(name: extensionName.components(separatedBy: ".").last ?? extensionName, bundleIdentifier: extensionName) + installedExtensions.append(ext) + } + } + } +} diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift new file mode 100644 index 0000000..a72ef83 --- /dev/null +++ b/DynamicIsland/managers/MusicManager.swift @@ -0,0 +1,397 @@ +// +// MusicManager.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 03/08/24. +// +import AppKit +import Combine +import Defaults +import SwiftUI + +let defaultImage: NSImage = .init( + systemSymbolName: "heart.fill", + accessibilityDescription: "Album Art" +)! + +class MusicManager: ObservableObject { + // MARK: - Properties + static let shared = MusicManager() + private var cancellables = Set() + private var controllerCancellables = Set() + private var debounceToggle: DispatchWorkItem? + + // Helper to check if macOS is too new for NowPlayingController + public var isNowPlayingDeprecated: Bool { + if #available(macOS 15.4, *) { + return true + } + return false + } + + // Active controller + private var activeController: (any MediaControllerProtocol)? + + // Published properties for UI + @Published var songTitle: String = "I'm Handsome" + @Published var artistName: String = "Me" + @Published var albumArt: NSImage = defaultImage + @Published var isPlaying = false + @Published var album: String = "Self Love" + @Published var lastUpdated: Date = .distantPast + @Published var ignoreLastUpdated = true + @Published var isPlayerIdle: Bool = true + @Published var animations: BoringAnimations = .init() + @Published var avgColor: NSColor = .white + @Published var bundleIdentifier: String? = nil + @Published var songDuration: TimeInterval = 0 + @Published var elapsedTime: TimeInterval = 0 + @Published var timestampDate: Date = .init() + @Published var playbackRate: Double = 1 + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @Published var usingAppIconForArtwork: Bool = false + + private var artworkData: Data? = nil + + @Published var isFlipping: Bool = false + private var flipWorkItem: DispatchWorkItem? + + @Published var isTransitioning: Bool = false + private var transitionWorkItem: DispatchWorkItem? + + // MARK: - Initialization + init() { + // Listen for changes to the default controller preference + NotificationCenter.default.publisher(for: Notification.Name.mediaControllerChanged) + .sink { [weak self] _ in + self?.setActiveControllerBasedOnPreference() + } + .store(in: &cancellables) + + // Initialize the active controller + setActiveControllerBasedOnPreference() + } + + deinit { + debounceToggle?.cancel() + cancellables.removeAll() + controllerCancellables.removeAll() + flipWorkItem?.cancel() + transitionWorkItem?.cancel() + + // Release active controller + activeController = nil + } + + // MARK: - Setup Methods + private func createController(for type: MediaControllerType) -> (any MediaControllerProtocol)? { + // Cleanup previous controller + if let _ = activeController { + controllerCancellables.removeAll() + activeController = nil + } + + let newController: (any MediaControllerProtocol)? + + switch type { + case .nowPlaying: + // Only create NowPlayingController if not deprecated on this macOS version + if !self.isNowPlayingDeprecated { + ignoreLastUpdated = false + newController = NowPlayingController() + } else { + return nil + } + case .appleMusic: + ignoreLastUpdated = true + newController = AppleMusicController() + case .spotify: + ignoreLastUpdated = true + newController = SpotifyController() + case .youtubeMusic: + ignoreLastUpdated = true + newController = YouTubeMusicController() + } + + // Set up state observation for the new controller + if let controller = newController { + controller.playbackStatePublisher + .sink { [weak self] state in + guard let self = self, + self.activeController === controller else { return } + self.updateFromPlaybackState(state) + } + .store(in: &controllerCancellables) + } + + return newController + } + + private func setActiveControllerBasedOnPreference() { + let preferredType = Defaults[.mediaController] + print("Preferred Media Controller: \(preferredType)") + + // If NowPlaying is deprecated but that's the preference, use Apple Music instead + let controllerType = (self.isNowPlayingDeprecated && preferredType == .nowPlaying) + ? .appleMusic + : preferredType + + if let controller = createController(for: controllerType) { + setActiveController(controller) + } else if controllerType != .appleMusic, let fallbackController = createController(for: .appleMusic) { + // Fallback to Apple Music if preferred controller couldn't be created + setActiveController(fallbackController) + } + } + + private func setActiveController(_ controller: any MediaControllerProtocol) { + // Transition animation when changing controllers + transitionWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.isTransitioning = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self?.isTransitioning = false + } + } + transitionWorkItem = workItem + DispatchQueue.main.async(execute: workItem) + + // Set new active controller + activeController = controller + + // Get current state from active controller + if let state = Mirror(reflecting: controller).children.first(where: { $0.label == "playbackState" })?.value as? PlaybackState { + updateFromPlaybackState(state) + } + } + + // MARK: - Update Methods + private func updateFromPlaybackState(_ state: PlaybackState) { + // Create a batch of updates to apply together + let updateBatch = DispatchWorkItem { [weak self] in + guard let self = self else { return } + + // Check for playback state changes (playing/paused) + if state.isPlaying != self.isPlaying { + self.lastUpdated = Date() + withAnimation(.smooth) { + self.isPlaying = state.isPlaying + self.updateIdleState(state: state.isPlaying) + } + + if state.isPlaying && !state.title.isEmpty && !state.artist.isEmpty { + self.updateSneakPeek() + } + } + + // Check for changes in track metadata + let titleChanged = state.title != self.songTitle + let artistChanged = state.artist != self.artistName + let albumChanged = state.album != self.album + + // Check for artwork changes + let artworkChanged = state.artwork != nil && state.artwork != self.artworkData + let hasContentChange = titleChanged || artistChanged || albumChanged || artworkChanged + + // Handle artwork and visual transitions for changed content + if hasContentChange { + self.triggerFlipAnimation() + + if artworkChanged, let artwork = state.artwork { + self.updateArtwork(artwork) + } else if hasContentChange && state.artwork == nil { + // Try to use app icon if no artwork but track changed + if let appIconImage = AppIconAsNSImage(for: state.bundleIdentifier) { + self.usingAppIconForArtwork = true + self.updateAlbumArt(newAlbumArt: appIconImage) + } + } + self.artworkData = state.artwork + + // Only update sneak peek if there's actual content and something changed + if !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying { + self.updateSneakPeek() + } + } + + let timeChanged = state.currentTime != self.elapsedTime + let durationChanged = state.duration != self.songDuration + let playbackRateChanged = state.playbackRate != self.playbackRate + + if titleChanged { + self.songTitle = state.title + } + + if artistChanged { + self.artistName = state.artist + } + + if albumChanged { + self.album = state.album + } + + if timeChanged { + self.elapsedTime = state.currentTime + } + + if durationChanged { + self.songDuration = state.duration + } + + if playbackRateChanged { + self.playbackRate = state.playbackRate + } + + if state.bundleIdentifier != self.bundleIdentifier { + self.bundleIdentifier = state.bundleIdentifier + } + + self.timestampDate = state.lastUpdated + } + + // Execute the batch update on the main thread + DispatchQueue.main.async(execute: updateBatch) + } + + private func triggerFlipAnimation() { + // Cancel any existing animation + flipWorkItem?.cancel() + + // Create a new animation + let workItem = DispatchWorkItem { [weak self] in + self?.isFlipping = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + self?.isFlipping = false + } + } + + flipWorkItem = workItem + DispatchQueue.main.async(execute: workItem) + } + + private func updateArtwork(_ artworkData: Data) { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + + if let artworkImage = NSImage(data: artworkData) { + DispatchQueue.main.async { + self.usingAppIconForArtwork = false + self.updateAlbumArt(newAlbumArt: artworkImage) + } + } + } + } + + private func updateIdleState(state: Bool) { + if state { + isPlayerIdle = false + debounceToggle?.cancel() + } else { + debounceToggle = DispatchWorkItem { [weak self] in + guard let self = self else { return } + if self.lastUpdated.timeIntervalSinceNow < -Defaults[.waitInterval] { + withAnimation { + self.isPlayerIdle = !self.isPlaying + } + } + } + + DispatchQueue.main.asyncAfter( + deadline: .now() + Defaults[.waitInterval], execute: debounceToggle!) + } + } + + private var workItem: DispatchWorkItem? + + func updateAlbumArt(newAlbumArt: NSImage) { + workItem?.cancel() + workItem = DispatchWorkItem { [weak self] in + withAnimation(.smooth) { + self?.albumArt = newAlbumArt + if Defaults[.coloredSpectrogram] { + self?.calculateAverageColor() + } + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem!) + } + + func calculateAverageColor() { + albumArt.averageColor { [weak self] color in + DispatchQueue.main.async { + withAnimation(.smooth) { + self?.avgColor = color ?? .white + } + } + } + } + + private func updateSneakPeek() { + if isPlaying && Defaults[.enableSneakPeek] { + if Defaults[.sneakPeekStyles] == .standard { + coordinator.toggleSneakPeek(status: true, type: .music) + } else { + coordinator.toggleExpandingView(status: true, type: .music) + } + } + } + + // MARK: - Public Methods for controlling playback + func playPause() { + activeController?.togglePlay() + } + + func play() { + activeController?.play() + } + + func pause() { + activeController?.pause() + } + + func togglePlay() { + activeController?.togglePlay() + } + + func nextTrack() { + activeController?.nextTrack() + } + + func previousTrack() { + activeController?.previousTrack() + } + + func seek(to position: TimeInterval) { + activeController?.seek(to: position) + } + + func openMusicApp() { + guard let bundleID = bundleIdentifier else { + print("Error: appBundleIdentifier is nil") + return + } + + let workspace = NSWorkspace.shared + if let appURL = workspace.urlForApplication(withBundleIdentifier: bundleID) { + let configuration = NSWorkspace.OpenConfiguration() + workspace.openApplication(at: appURL, configuration: configuration) { (app, error) in + if let error = error { + print("Failed to launch app with bundle ID: \(bundleID), error: \(error)") + } else { + print("Launched app with bundle ID: \(bundleID)") + } + } + } else { + print("Failed to find app with bundle ID: \(bundleID)") + } + } + + func forceUpdate() { + // Request immediate update from the active controller + DispatchQueue.main.async { [weak self] in + if self?.activeController?.isActive() == true { + self?.activeController?.updatePlaybackInfo() + } + } + } +} diff --git a/DynamicIsland/managers/NotchSpaceManager.swift b/DynamicIsland/managers/NotchSpaceManager.swift new file mode 100644 index 0000000..5e45495 --- /dev/null +++ b/DynamicIsland/managers/NotchSpaceManager.swift @@ -0,0 +1,19 @@ +// +// NotchSpaceManager.swift +// boringNotch +// +// Created by Alexander Greco on 2024-10-27. +// + +import Foundation + +class NotchSpaceManager { + static let shared = NotchSpaceManager() + let notchSpace: CGSSpace + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + + private init() { + notchSpace = CGSSpace(level: 2147483647) // Max level + } +} diff --git a/DynamicIsland/managers/WebcamManager.swift b/DynamicIsland/managers/WebcamManager.swift new file mode 100644 index 0000000..6a92286 --- /dev/null +++ b/DynamicIsland/managers/WebcamManager.swift @@ -0,0 +1,312 @@ +// +// WebcamManager.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 19/08/24. +// +import AVFoundation +import SwiftUI + +class WebcamManager: NSObject, ObservableObject { + @Published var previewLayer: AVCaptureVideoPreviewLayer? { + didSet { + objectWillChange.send() + } + } + + private var captureSession: AVCaptureSession? + @Published var isSessionRunning: Bool = false { + didSet { + objectWillChange.send() + } + } + + @Published var authorizationStatus: AVAuthorizationStatus = .notDetermined { + didSet { + objectWillChange.send() + } + } + + @Published var cameraAvailable: Bool = false { + didSet { + objectWillChange.send() + } + } + + private let sessionQueue = DispatchQueue(label: "BoringNotch.WebcamManager.SessionQueue", qos: .userInitiated) + + private var isCleaningUp: Bool = false + + // MARK: - Constants + + enum WebcamError: Error, LocalizedError { + case deviceUnavailable + case accessDenied + case configurationFailed(String) + + var errorDescription: String? { + switch self { + case .deviceUnavailable: + return "No camera devices available" + case .accessDenied: + return "Camera access denied" + case .configurationFailed(let message): + return "Camera configuration failed: \(message)" + } + } + } + + // MARK: - Properties + + override init() { + super.init() + checkAndRequestVideoAuthorization() + NotificationCenter.default.addObserver(self, selector: #selector(deviceWasDisconnected), name: .AVCaptureDeviceWasDisconnected, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(deviceWasConnected), name: .AVCaptureDeviceWasConnected, object: nil) + checkCameraAvailability() + } + + deinit { + NotificationCenter.default.removeObserver(self) + + if let session = captureSession { + if session.isRunning { + session.stopRunning() + } + } + captureSession = nil + + previewLayer = nil + } + + // MARK: - Camera Management + + /// Checks current authorization status and requests access if needed + func checkAndRequestVideoAuthorization() { + let status = AVCaptureDevice.authorizationStatus(for: .video) + DispatchQueue.main.async { + self.authorizationStatus = status + } + + switch status { + case .authorized: + checkCameraAvailability() // Check availability if authorized + case .notDetermined: + requestVideoAccess() + case .denied, .restricted: + NSLog("Camera access denied or restricted") + @unknown default: + NSLog("Unknown authorization status") + } + } + + /// Requests access to the camera + private func requestVideoAccess() { + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + self?.authorizationStatus = granted ? .authorized : .denied + if granted { + self?.checkCameraAvailability() // Check availability if access granted + } + } + } + } + + /// Checks if any camera devices are available and sets up capture session if needed + func checkCameraAvailability() { + let availableDevices = AVCaptureDevice.DiscoverySession( + deviceTypes: [.external, .builtInWideAngleCamera], + mediaType: .video, + position: .unspecified + ).devices + + let hasAvailableDevices = !availableDevices.isEmpty + + DispatchQueue.main.async { + self.cameraAvailable = hasAvailableDevices + } + } + + /// Sets up the capture session with a completion handler + private func setupCaptureSession(completion: @escaping (Bool) -> Void) { + sessionQueue.async { [weak self] in + guard let self = self else { + completion(false) + return + } + + // Clean up any existing session before creating a new one + self.cleanupExistingSession() + + let session = AVCaptureSession() + + do { + // Get available devices and prefer external camera if available + let discoverySession = AVCaptureDevice.DiscoverySession( + deviceTypes: [.external, .builtInWideAngleCamera], + mediaType: .video, + position: .unspecified + ) + + guard let videoDevice = discoverySession.devices.first else { + NSLog("No video devices available") + DispatchQueue.main.async { + self.isSessionRunning = false + self.cameraAvailable = false + } + completion(false) + return + } + + NSLog("Using camera: \(videoDevice.localizedName)") + + // Lock device for configuration + try videoDevice.lockForConfiguration() + defer { videoDevice.unlockForConfiguration() } + + let videoInput = try AVCaptureDeviceInput(device: videoDevice) + guard session.canAddInput(videoInput) else { + throw NSError(domain: "BoringNotch.WebcamManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Cannot add video input"]) + } + + session.beginConfiguration() + session.sessionPreset = .high + session.addInput(videoInput) + + let videoOutput = AVCaptureVideoDataOutput() + videoOutput.setSampleBufferDelegate(nil, queue: nil) + if session.canAddOutput(videoOutput) { + session.addOutput(videoOutput) + } + session.commitConfiguration() + + self.captureSession = session + + // Create and set up preview layer on main thread + DispatchQueue.main.async { + self.cameraAvailable = true + let previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.videoGravity = .resizeAspectFill + self.previewLayer = previewLayer + + // Setup is complete, let the caller know + completion(true) + } + + NSLog("Capture session setup completed successfully") + } catch { + NSLog("Failed to setup capture session: \(error.localizedDescription)") + DispatchQueue.main.async { + self.isSessionRunning = false + self.cameraAvailable = false + self.previewLayer = nil + } + completion(false) + } + } + } + + /// Cleans up an existing capture session, removing all inputs and outputs + private func cleanupExistingSession() { + if let existingSession = self.captureSession { + // First stop the session if running + if existingSession.isRunning { + existingSession.stopRunning() + } + + // Then perform configuration cleanup + existingSession.beginConfiguration() + + // Remove all inputs and outputs + for input in existingSession.inputs { + existingSession.removeInput(input) + } + for output in existingSession.outputs { + existingSession.removeOutput(output) + } + + existingSession.commitConfiguration() + self.captureSession = nil + + // Clear preview layer on main thread + DispatchQueue.main.async { + self.previewLayer = nil + } + } + } + + @objc private func deviceWasDisconnected(notification: Notification) { + NSLog("Camera device was disconnected") + sessionQueue.async { [weak self] in + guard let self = self else { return } + self.stopSession() + DispatchQueue.main.async { + self.cameraAvailable = false + } + } + } + + @objc private func deviceWasConnected(notification: Notification) { + NSLog("Camera device was connected") + sessionQueue.async { [weak self] in + guard let self = self else { return } + self.checkCameraAvailability() + } + } + + private func updateSessionState() { + let isRunning = self.captureSession?.isRunning ?? false + DispatchQueue.main.async { + self.isSessionRunning = isRunning + } + } + + func startSession() { + sessionQueue.async { [weak self] in + guard let self = self else { return } + + // If no session exists, create new session + if self.captureSession == nil { + self.setupCaptureSession { success in + if success { + // Only start the session if setup was successful + self.startRunningCaptureSession() + } + } + } else { + // Session already exists, just start it + self.startRunningCaptureSession() + } + } + } + + private func startRunningCaptureSession() { + sessionQueue.async { [weak self] in + guard let self = self, let session = self.captureSession, !session.isRunning else { + return + } + + session.startRunning() + + // Update state on main thread + self.updateSessionState() + + NSLog("Capture session started successfully") + } + } + + func stopSession() { + sessionQueue.async { [weak self] in + guard let self = self else { return } + + // Update state to indicate we're stopping + DispatchQueue.main.async { + self.isSessionRunning = false + } + + self.cleanupExistingSession() + + NSLog("Capture session stopped and cleaned up") + } + } +} diff --git a/DynamicIsland/menu/StatusBarMenu.swift b/DynamicIsland/menu/StatusBarMenu.swift new file mode 100644 index 0000000..2bcbae2 --- /dev/null +++ b/DynamicIsland/menu/StatusBarMenu.swift @@ -0,0 +1,24 @@ +import Cocoa + +class BoringStatusMenu: NSMenu { + + var statusItem: NSStatusItem! + + override init() { + super.init() + + // Initialize the status item + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + if let button = statusItem.button { + button.image = NSImage(systemSymbolName: "music.note", accessibilityDescription: "BoringNotch") + button.action = #selector(showMenu) + } + + // Set up the menu + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Quit", action: #selector(quitAction), keyEquivalent: "q")) + statusItem.menu = menu + } + +} diff --git a/DynamicIsland/metal/visualizer.metal b/DynamicIsland/metal/visualizer.metal new file mode 100644 index 0000000..9bd88b0 --- /dev/null +++ b/DynamicIsland/metal/visualizer.metal @@ -0,0 +1,18 @@ +// +// 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/DynamicIsland/models/BatteryStatusViewModel.swift b/DynamicIsland/models/BatteryStatusViewModel.swift new file mode 100644 index 0000000..f439248 --- /dev/null +++ b/DynamicIsland/models/BatteryStatusViewModel.swift @@ -0,0 +1,147 @@ +import Foundation +import Cocoa +import SwiftUI +import IOKit.ps +import Defaults + +/// A view model that manages and monitors the battery status of the device +class BatteryStatusViewModel: ObservableObject { + + private var wasCharging: Bool = false + private var powerSourceChangedCallback: IOPowerSourceCallbackType? + private var runLoopSource: Unmanaged? + var animations: DynamicIslandAnimations = DynamicIslandAnimations() + + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + + @Published private(set) var levelBattery: Float = 0.0 + @Published private(set) var maxCapacity: Float = 0.0 + @Published private(set) var isPluggedIn: Bool = false + @Published private(set) var isCharging: Bool = false + @Published private(set) var isInLowPowerMode: Bool = false + @Published private(set) var isInitial: Bool = false + @Published private(set) var timeToFullCharge: Int = 0 + @Published private(set) var statusText: String = "" + + private let managerBattery = BatteryActivityManager.shared + private var managerBatteryId: Int? + + static let shared = BatteryStatusViewModel() + + /// Initializes the view model with a given DynamicIslandViewModel instance + /// - Parameter vm: The DynamicIslandViewModel instance + private init() { + setupPowerStatus() + setupMonitor() + } + + /// Sets up the initial power status by fetching battery information + private func setupPowerStatus() { + let batteryInfo = managerBattery.initializeBatteryInfo() + updateBatteryInfo(batteryInfo) + } + + /// Sets up the monitor to observe battery events + private func setupMonitor() { + managerBatteryId = managerBattery.addObserver { [weak self] event in + guard let self = self else { return } + self.handleBatteryEvent(event) + } + } + + /// Handles battery events and updates the corresponding properties + /// - Parameter event: The battery event to handle + private func handleBatteryEvent(_ event: BatteryActivityManager.BatteryEvent) { + switch event { + case .powerSourceChanged(let isPluggedIn): + print("πŸ”Œ Power source: \(isPluggedIn ? "Connected" : "Disconnected")") + withAnimation { + self.isPluggedIn = isPluggedIn + self.statusText = isPluggedIn ? "Plugged In" : "Unplugged" + self.notifyImportanChangeStatus() + } + + case .batteryLevelChanged(let level): + print("πŸ”‹ Battery level: \(Int(level))%") + withAnimation { + self.levelBattery = level + } + + case .lowPowerModeChanged(let isEnabled): + print("⚑ Low power mode: \(isEnabled ? "Enabled" : "Disabled")") + self.notifyImportanChangeStatus() + withAnimation { + self.isInLowPowerMode = isEnabled + self.statusText = "Low Power: \(self.isInLowPowerMode ? "On" : "Off")" + } + + case .isChargingChanged(let isCharging): + print("πŸ”Œ Charging: \(isCharging ? "Yes" : "No")") + print("maxCapacity: \(self.maxCapacity)") + print("levelBattery: \(self.levelBattery)") + self.notifyImportanChangeStatus() + withAnimation { + self.isCharging = isCharging + self.statusText = isCharging ? "Charging battery" : (self.levelBattery < self.maxCapacity ? "Not charging" : "Full charge") + } + + case .timeToFullChargeChanged(let time): + print("πŸ•’ Time to full charge: \(time) minutes") + withAnimation { + self.timeToFullCharge = time + } + + case .maxCapacityChanged(let capacity): + print("πŸ”‹ Max capacity: \(capacity)") + withAnimation { + self.maxCapacity = capacity + } + + case .error(let description): + print("⚠️ Error: \(description)") + } + } + + /// Updates the battery information with the given BatteryInfo instance + /// - Parameter batteryInfo: The BatteryInfo instance containing the battery data + private func updateBatteryInfo(_ batteryInfo: BatteryInfo) { + withAnimation { + self.levelBattery = batteryInfo.currentCapacity + self.isPluggedIn = batteryInfo.isPluggedIn + self.isCharging = batteryInfo.isCharging + self.isInLowPowerMode = batteryInfo.isInLowPowerMode + self.timeToFullCharge = batteryInfo.timeToFullCharge + self.maxCapacity = batteryInfo.maxCapacity + self.statusText = batteryInfo.isPluggedIn ? "Plugged In" : "Unplugged" + } + notifyImportanChangeStatus(delay: coordinator.firstLaunch ? 6 : 0.0) + withAnimation { + if self.isCharging { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.statusText = "Charging: Yes" + } + } + if self.isInLowPowerMode { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.statusText = "Low Power: On" + } + } + } + } + + /// Notifies important changes in the battery status with an optional delay + /// - Parameter delay: The delay before notifying the change, default is 0.0 + private func notifyImportanChangeStatus(delay: Double = 0.0) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + self.coordinator.toggleExpandingView(status: true, type: .battery) + } + } + + deinit { + print("πŸ”Œ Cleaning up battery monitoring...") + if let managerBatteryId: Int = managerBatteryId { + managerBattery.removeObserver(byId: managerBatteryId) + } + } + +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift new file mode 100644 index 0000000..150db79 --- /dev/null +++ b/DynamicIsland/models/Constants.swift @@ -0,0 +1,165 @@ +// +// Constants.swift +// boringNotch +// +// Created by Richard Kunkli on 2024. 10. 17.. +// + +import SwiftUI +import Defaults + +private let availableDirectories = FileManager + .default + .urls(for: .documentDirectory, in: .userDomainMask) +let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! +let bundleIdentifier = Bundle.main.bundleIdentifier! +let appVersion = "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""))" + +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) +} + +enum HideNotchOption: String, Defaults.Serializable { + case always + case nowPlayingOnly + case never +} + +// Define notification names at file scope +extension Notification.Name { + static let mediaControllerChanged = Notification.Name("mediaControllerChanged") +} + +// Media controller types for selection in settings +enum MediaControllerType: String, CaseIterable, Identifiable, Defaults.Serializable { + case nowPlaying = "Now Playing" + case appleMusic = "Apple Music" + case spotify = "Spotify" + case youtubeMusic = "Youtube Music" + + var id: String { self.rawValue } +} + +// Sneak peek styles for selection in settings +enum SneakPeekStyle: String, CaseIterable, Identifiable, Defaults.Serializable { + case standard = "Default" + case inline = "Inline" + + var id: String { self.rawValue } +} + +extension Defaults.Keys { + // MARK: General + static let menubarIcon = Key("menubarIcon", default: true) + static let showOnAllDisplays = Key("showOnAllDisplays", default: false) + static let automaticallySwitchDisplay = Key("automaticallySwitchDisplay", default: true) + static let releaseName = Key("releaseName", default: "Wolf Painting 🐺🎨") + + // MARK: Behavior + static let minimumHoverDuration = Key("minimumHoverDuration", default: 0.3) + static let enableHaptics = Key("enableHaptics", default: true) + static let openNotchOnHover = Key("openNotchOnHover", default: true) + static let extendHoverArea = Key("extendHoverArea", default: false) + static let notchHeightMode = Key( + "notchHeightMode", + default: WindowHeightMode.matchRealNotchSize + ) + static let nonNotchHeightMode = Key( + "nonNotchHeightMode", + default: WindowHeightMode.matchMenuBar + ) + static let nonNotchHeight = Key("nonNotchHeight", default: 32) + static let notchHeight = Key("notchHeight", default: 32) + //static let openLastTabByDefault = Key("openLastTabByDefault", 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) + static let settingsIconInNotch = Key("settingsIconInNotch", default: true) + static let lightingEffect = Key("lightingEffect", default: true) + static let accentColor = Key("accentColor", default: Color.blue) + static let enableShadow = Key("enableShadow", default: true) + static let cornerRadiusScaling = Key("cornerRadiusScaling", default: true) + static let useModernCloseAnimation = Key("useModernCloseAnimation", default: true) + static let showNotHumanFace = Key("showNotHumanFace", default: false) + static let tileShowLabels = Key("tileShowLabels", default: false) + static let showCalendar = Key("showCalendar", default: false) + static let sliderColor = Key( + "sliderUseAlbumArtColor", + 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) + static let closeGestureEnabled = Key("closeGestureEnabled", default: true) + static let gestureSensitivity = Key("gestureSensitivity", default: 200.0) + + // MARK: Media playback + static let coloredSpectrogram = Key("coloredSpectrogram", default: true) + static let enableSneakPeek = Key("enableSneakPeek", default: false) + static let sneakPeekStyles = Key("sneakPeekStyles", default: .standard) + static let enableFullscreenMediaDetection = Key("enableFullscreenMediaDetection", default: true) + static let waitInterval = Key("waitInterval", default: 3) + + // MARK: Battery + static let showPowerStatusNotifications = Key("showPowerStatusNotifications", default: true) + static let showBatteryIndicator = Key("showBatteryIndicator", default: true) + static let showBatteryPercentage = Key("showBatteryPercentage", default: true) + static let showPowerStatusIcons = Key("showPowerStatusIcons", default: true) + + // MARK: Downloads + static let enableDownloadListener = Key("enableDownloadListener", default: true) + static let enableSafariDownloads = Key("enableSafariDownloads", default: true) + static let selectedDownloadIndicatorStyle = Key("selectedDownloadIndicatorStyle", default: DownloadIndicatorStyle.progress) + static let selectedDownloadIconStyle = Key("selectedDownloadIconStyle", default: DownloadIconStyle.onlyAppIcon) + + // MARK: HUD + static let inlineHUD = Key("inlineHUD", default: false) + static let enableGradient = Key("enableGradient", default: false) + static let systemEventIndicatorShadow = Key("systemEventIndicatorShadow", default: false) + static let systemEventIndicatorUseAccent = Key("systemEventIndicatorUseAccent", default: false) + + // MARK: Shelf + static let dynamicShelf = Key("dynamicShelf", default: true) + static let openShelfByDefault = Key("openShelfByDefault", default: true) + + // MARK: Calendar + static let calendarSelectionState = Key("calendarSelectionState", default: .all) + + // MARK: Fullscreen Media Detection + static let alwaysHideInFullscreen = Key("alwaysHideInFullscreen", default: false) + + static let hideNotchOption = Key("hideNotchOption", default: .nowPlayingOnly) + + // MARK: Wobble Animation + static let enableWobbleAnimation = Key("enableWobbleAnimation", default: false) + + // MARK: Media Controller + static let mediaController = Key("mediaController", default: defaultMediaController) + + // Helper to determine the default media controller based on macOS version + static var defaultMediaController: MediaControllerType { + if #available(macOS 15.4, *) { + return .appleMusic + } else { + return .nowPlaying + } + } +} diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift new file mode 100644 index 0000000..a7efe00 --- /dev/null +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -0,0 +1,151 @@ +// +// DynamicIslandViewModel.swift +// DynamicIsland +// +// Created by Harsh Vardhan Goswami on 04/08/24. +// + +import Combine +import Defaults +import SwiftUI +import TheBoringWorkerNotifier + +class DynamicIslandViewModel: NSObject, ObservableObject { + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @ObservedObject var detector = FullscreenMediaDetector.shared + + let animationLibrary: DynamicIslandAnimations = .init() + let animation: Animation? + + @Published var contentType: ContentType = .normal + @Published private(set) var notchState: NotchState = .closed + + @Published var dragDetectorTargeting: Bool = false + @Published var dropZoneTargeting: Bool = false + @Published var dropEvent: Bool = false + @Published var anyDropZoneTargeting: Bool = false + var cancellables: Set = [] + + @Published var hideOnClosed: Bool = true + @Published var isHoveringCalendar: Bool = false + @Published var isBatteryPopoverActive: Bool = false + + @Published var screen: String? + + @Published var notchSize: CGSize = getClosedNotchSize() + @Published var closedNotchSize: CGSize = getClosedNotchSize() + + deinit { + destroy() + } + + func destroy() { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } + + init(screen: String? = nil) { + animation = animationLibrary.animation + + super.init() + + self.screen = screen + notchSize = getClosedNotchSize(screen: screen) + closedNotchSize = notchSize + + Publishers.CombineLatest($dropZoneTargeting, $dragDetectorTargeting) + .map { value1, value2 in + value1 || value2 + } + .assign(to: \.anyDropZoneTargeting, on: self) + .store(in: &cancellables) + + setupDetectorObserver() + } + + private func setupDetectorObserver() { + // 1) Publisher for the user’s fullscreen detection setting + let enabledPublisher = Defaults + .publisher(.enableFullscreenMediaDetection) + .map(\.newValue) + + // 2) For each non‑nil screen name, map to a Bool publisher for that screen's status + let statusPublisher = $screen + .compactMap { $0 } + .removeDuplicates() + .map { screenName in + self.detector.$fullscreenStatus + .map { $0[screenName] ?? false } + .removeDuplicates() + } + .switchToLatest() + + // 3) Combine enabled & status, animate only on changes + Publishers.CombineLatest(statusPublisher, enabledPublisher) + .map { status, enabled in enabled && status } + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] shouldHide in + withAnimation(.smooth) { + self?.hideOnClosed = shouldHide + } + } + .store(in: &cancellables) + } + + // Computed property for effective notch height + var effectiveClosedNotchHeight: CGFloat { + let currentScreen = NSScreen.screens.first { $0.localizedName == screen } + let noNotchAndFullscreen = hideOnClosed && (currentScreen?.safeAreaInsets.top ?? 0 <= 0 || currentScreen == nil) + return noNotchAndFullscreen ? 0 : closedNotchSize.height + } + + func isMouseHovering(position: NSPoint = NSEvent.mouseLocation) -> Bool { + let screenFrame = getScreenFrame(screen) + if let frame = screenFrame { + + let baseY = frame.maxY - notchSize.height + let baseX = frame.midX - notchSize.width / 2 + + return position.y >= baseY && position.x >= baseX && position.x <= baseX + notchSize.width + } + + return false + } + + func open() { + withAnimation(.bouncy) { + self.notchSize = openNotchSize + self.notchState = .open + } + + // Force music information update when notch is opened + MusicManager.shared.forceUpdate() + } + + func close() { + withAnimation(.smooth) { [weak self] in + guard let self = self else { return } + self.notchSize = getClosedNotchSize(screen: self.screen) + self.closedNotchSize = self.notchSize + self.notchState = .closed + } + + // Set the current view to shelf if it contains files and the user enables openShelfByDefault + // Otherwise, if the user has not enabled openLastShelfByDefault, set the view to home + if !TrayDrop.shared.isEmpty && Defaults[.openShelfByDefault] { + coordinator.currentView = .shelf + } else if !coordinator.openLastTabByDefault { + coordinator.currentView = .home + } + } + + func closeHello() { + DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in + self?.coordinator.firstLaunch = false + withAnimation(self?.animationLibrary.animation) { + self?.close() + } + } + } +} diff --git a/DynamicIsland/models/PlaybackState.swift b/DynamicIsland/models/PlaybackState.swift new file mode 100644 index 0000000..b14e17a --- /dev/null +++ b/DynamicIsland/models/PlaybackState.swift @@ -0,0 +1,23 @@ +// +// PlaybackState.swift +// boringNotch +// +// Created by Alexander Greco on 2025-03-29. +// + +import Foundation + +struct PlaybackState { + var bundleIdentifier: String + var isPlaying: Bool = false + var title: String = "I'm Handsome" + var artist: String = "Me" + var album: String = "Self Love" + var currentTime: Double = 0 + var duration: Double = 0 + var playbackRate: Double = 1 + var isShuffled: Bool? = nil + var isRepeating: Bool? = nil + var lastUpdated: Date = Date.distantPast + var artwork: Data? +} diff --git a/DynamicIsland/observers/FullscreenMediaDetection.swift b/DynamicIsland/observers/FullscreenMediaDetection.swift new file mode 100644 index 0000000..b7e4d25 --- /dev/null +++ b/DynamicIsland/observers/FullscreenMediaDetection.swift @@ -0,0 +1,88 @@ +// +// FullscreenMediaDetection.swift +// boringNotch +// +// Created by Richard Kunkli on 06/09/2024. +// + +import Accessibility +import Cocoa +import CoreAudio +import Defaults +import MacroVisionKit +import SwiftUI + +@MainActor +class FullscreenMediaDetector: ObservableObject { + static let shared = FullscreenMediaDetector() + private let detector: MacroVisionKit + + // changed from [NSScreen:Bool] to [screenName:Bool] + @Published private(set) var fullscreenStatus: [String: Bool] = [:] + + private init() { + self.detector = MacroVisionKit.shared + detector.configuration.includeSystemApps = true + setupNotificationObservers() + updateFullScreenStatus() // now on MainActor + } + + private func setupNotificationObservers() { + let nc = NSWorkspace.shared.notificationCenter + nc.addObserver(self, selector: #selector(handleChange), + name: NSWorkspace.activeSpaceDidChangeNotification, object: nil) + nc.addObserver(self, selector: #selector(handleChange), + name: NSApplication.didChangeScreenParametersNotification, object: nil) + } + + @objc private func handleChange() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.updateFullScreenStatus() + } + } + + private func updateFullScreenStatus() { + NSLog("πŸ”„ Fullscreen status update triggered") + guard Defaults[.enableFullscreenMediaDetection] else { + NSLog("❌ Fullscreen media detection disabled") + let reset = Dictionary(uniqueKeysWithValues: NSScreen.screens.map { ($0.localizedName, false) } + ) + if reset != fullscreenStatus { + fullscreenStatus = reset + NSLog("⏸ Fullscreen disabled β†’ reset all screens") + } + return + } + + NSLog("πŸ”„ Fullscreen media detection enabled") + + let apps = detector.detectFullscreenApps(debug: false) + let names = NSScreen.screens.map { $0.localizedName } + var newStatus: [String: Bool] = [:] + for name in names { + newStatus[name] = apps.contains { $0.screen.localizedName == name && $0.bundleIdentifier != "com.apple.finder" } + } + + if newStatus != fullscreenStatus { + fullscreenStatus = newStatus + NSLog("βœ… Fullscreen status: \(newStatus)") + } + } + + func isScreenInFullScreen(_ screen: NSScreen) -> Bool { + let screenName = screen.localizedName + return fullscreenStatus[screenName] ?? false + } + + func isAnyScreenInFullScreen() -> Bool { + return fullscreenStatus.values.contains(true) + } + + private func cleanupNotificationObservers() { + NSWorkspace.shared.notificationCenter.removeObserver(self) + } + + deinit { + NSWorkspace.shared.notificationCenter.removeObserver(self) + } +} diff --git a/DynamicIsland/private/CGSSpace.swift b/DynamicIsland/private/CGSSpace.swift new file mode 100644 index 0000000..ccbe25a --- /dev/null +++ b/DynamicIsland/private/CGSSpace.swift @@ -0,0 +1,76 @@ +// +// CGSSpace.swift +// boringNotch +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Original source: https://github.com/avaidyam/Parrot/ +// Modified by Alexander Greco on 2024-10-27 + +import AppKit + +/// Small Spaces API wrapper. +public final class CGSSpace { + private let identifier: CGSSpaceID + private let createdByInit: Bool + + public var windows: Set = [] { + didSet { + let remove = oldValue.subtracting(self.windows) + let add = self.windows.subtracting(oldValue) + + CGSRemoveWindowsFromSpaces(_CGSDefaultConnection(), + remove.map { $0.windowNumber } as NSArray, + [self.identifier]) + CGSAddWindowsToSpaces(_CGSDefaultConnection(), + add.map { $0.windowNumber } as NSArray, + [self.identifier]) + } + } + + /// Initialized `CGSSpace`s *MUST* be de-initialized upon app exit! + public init(level: Int = 0) { + let flag = 0x1 // this value MUST be 1, otherwise, Finder decides to draw desktop icons + self.identifier = CGSSpaceCreate(_CGSDefaultConnection(), flag, nil) + CGSSpaceSetAbsoluteLevel(_CGSDefaultConnection(), self.identifier, level) + CGSShowSpaces(_CGSDefaultConnection(), [self.identifier]) + self.createdByInit = true // Mark as created by the first init + } + + public init(id: UInt64) { + let flag = 0x1 // this value MUST be 1, otherwise, Finder decides to draw desktop icons + self.identifier = id + CGSShowSpaces(_CGSDefaultConnection(), [self.identifier]) + self.createdByInit = false // Mark as created externally + } + + deinit { + CGSHideSpaces(_CGSDefaultConnection(), [self.identifier]) + // Only call CGSSpaceDestroy if the space was created by the first init + if createdByInit { + CGSSpaceDestroy(_CGSDefaultConnection(), self.identifier) + } + } +} + +// CGSSpace stuff: +fileprivate typealias CGSConnectionID = UInt +fileprivate typealias CGSSpaceID = UInt64 +@_silgen_name("_CGSDefaultConnection") +fileprivate func _CGSDefaultConnection() -> CGSConnectionID +@_silgen_name("CGSSpaceCreate") +fileprivate func CGSSpaceCreate(_ cid: CGSConnectionID, _ unknown: Int, _ options: NSDictionary?) -> CGSSpaceID +@_silgen_name("CGSSpaceDestroy") +fileprivate func CGSSpaceDestroy(_ cid: CGSConnectionID, _ space: CGSSpaceID) +@_silgen_name("CGSSpaceSetAbsoluteLevel") +fileprivate func CGSSpaceSetAbsoluteLevel(_ cid: CGSConnectionID, _ space: CGSSpaceID, _ level: Int) +@_silgen_name("CGSAddWindowsToSpaces") +fileprivate func CGSAddWindowsToSpaces(_ cid: CGSConnectionID, _ windows: NSArray, _ spaces: NSArray) +@_silgen_name("CGSRemoveWindowsFromSpaces") +fileprivate func CGSRemoveWindowsFromSpaces(_ cid: CGSConnectionID, _ windows: NSArray, _ spaces: NSArray) +@_silgen_name("CGSHideSpaces") +fileprivate func CGSHideSpaces(_ cid: CGSConnectionID, _ spaces: NSArray) +@_silgen_name("CGSShowSpaces") +fileprivate func CGSShowSpaces(_ cid: CGSConnectionID, _ spaces: NSArray) diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift new file mode 100644 index 0000000..506749b --- /dev/null +++ b/DynamicIsland/sizing/matters.swift @@ -0,0 +1,76 @@ +// +// sizeMatters.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 05/08/24. +// + +import Defaults +import Foundation +import SwiftUI + +let downloadSneakSize: CGSize = .init(width: 65, height: 1) +let batterySneakSize: CGSize = .init(width: 160, height: 1) + +let openNotchSize: CGSize = .init(width: 610, height: 200) +let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14)) + +enum MusicPlayerImageSizes { + static let cornerRadiusInset: (opened: CGFloat, closed: CGFloat) = (opened: 13.0, closed: 4.0) + static let size = (opened: CGSize(width: 90, height: 90), closed: CGSize(width: 20, height: 20)) +} + +func getScreenFrame(_ screen: String? = nil) -> CGRect? { + var selectedScreen = NSScreen.main + + if let customScreen = screen { + selectedScreen = NSScreen.screens.first(where: { $0.localizedName == customScreen }) + } + + if let screen = selectedScreen { + return screen.frame + } + + return nil +} + +func getClosedNotchSize(screen: String? = nil) -> CGSize { + // Default notch size, to avoid using optionals + var notchHeight: CGFloat = Defaults[.nonNotchHeight] + var notchWidth: CGFloat = 185 + + var selectedScreen = NSScreen.main + + if let customScreen = screen { + selectedScreen = NSScreen.screens.first(where: { $0.localizedName == customScreen }) + } + + // Check if the screen is available + if let screen = selectedScreen { + // Calculate and set the exact width of the notch + if let topLeftNotchpadding: CGFloat = screen.auxiliaryTopLeftArea?.width, + let topRightNotchpadding: CGFloat = screen.auxiliaryTopRightArea?.width + { + 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 + } + } + } + + return .init(width: notchWidth, height: notchHeight) +} diff --git a/DynamicIsland/strings/constants.swift b/DynamicIsland/strings/constants.swift new file mode 100644 index 0000000..5272416 --- /dev/null +++ b/DynamicIsland/strings/constants.swift @@ -0,0 +1,13 @@ +// +// constants.swift +// boringNotch +// +// Created by Harsh Vardhan Goswami on 04/08/24. +// + +import Foundation + +let productPage = URL(string: "https://github.com/TheBoredTeam/boring.notch")! +let sponsorPage = URL(string: "https://buymeacoffee.com/jfxh67wvfxq")! + + diff --git a/DynamicIsland/utils/Logger.swift b/DynamicIsland/utils/Logger.swift new file mode 100644 index 0000000..e95cdba --- /dev/null +++ b/DynamicIsland/utils/Logger.swift @@ -0,0 +1,77 @@ +import Foundation +import SwiftUI + +enum LogCategory: String { + case lifecycle = "πŸ”„" + case memory = "πŸ’Ύ" + case performance = "⚑️" + case ui = "🎨" + case network = "🌐" + case error = "❌" + case warning = "⚠️" + case success = "βœ…" + case debug = "πŸ”" +} + +struct Logger { + static func log( + _ message: String, + category: LogCategory, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + let fileName = (file as NSString).lastPathComponent + let timestamp = ISO8601DateFormatter().string(from: Date()) + print("\(category.rawValue) [\(timestamp)] [\(fileName):\(line)] \(function) - \(message)") + } + + static func trackMemory( + file: String = #file, + function: String = #function, + line: Int = #line + ) { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size)/4 + + let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + task_info(mach_task_self_, + task_flavor_t(MACH_TASK_BASIC_INFO), + $0, + &count) + } + } + + if kerr == KERN_SUCCESS { + let usedMB = Double(info.resident_size) / 1024.0 / 1024.0 + log(String(format: "Memory used: %.2f MB", usedMB), + category: .memory, + file: file, + function: function, + line: line) + } + } +} + +extension View { + func trackLifecycle(_ identifier: String) -> some View { + self.modifier(ViewLifecycleTracker(identifier: identifier)) + } +} + +struct ViewLifecycleTracker: ViewModifier { + let identifier: String + + func body(content: Content) -> some View { + content + .onAppear { + Logger.log("\(identifier) appeared", category: .lifecycle) + Logger.trackMemory() + } + .onDisappear { + Logger.log("\(identifier) disappeared", category: .lifecycle) + Logger.trackMemory() + } + } +} \ No newline at end of file