diff --git a/.github/assets/Atoll_1024.png b/.github/assets/Atoll_1024.png new file mode 100644 index 0000000..e13b268 Binary files /dev/null and b/.github/assets/Atoll_1024.png differ diff --git a/.gitignore b/.gitignore index d59c3d2..72d7545 100644 --- a/.gitignore +++ b/.gitignore @@ -145,4 +145,5 @@ huds.md borrow_migrations.md borrowing/ MoveToSky.xcodeproj -/MoveToSky/ \ No newline at end of file +/MoveToSky/ +mew-notch/ \ No newline at end of file diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 84d3ad6..e6c6510 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */; }; 7E61181B2E24071E0050006C /* LottieUI in Frameworks */ = {isa = PBXBuildFile; productRef = B194240A2CD10017003E5DC2 /* LottieUI */; }; 7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 146BC4362D366E9B0047C66A /* MacroVisionKit */; }; + 7E6856482EA963FD00D261F5 /* Lock.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E6856472EA963FD00D261F5 /* Lock.json */; }; 7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */; }; 7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */; }; 7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D62E9A346700C5CB8C /* Dog waiting.json */; }; @@ -49,6 +50,7 @@ 7E25ECA02E6C6C3800700E72 /* CoreBrightness.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBrightness.framework; path = /System/Library/PrivateFrameworks/CoreBrightness.framework; sourceTree = ""; }; 7E25ECA22E6C6E2600700E72 /* OSD.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OSD.framework; path = /System/Library/PrivateFrameworks/OSD.framework; sourceTree = ""; }; 7E25ECA42E6C6E7800700E72 /* DisplayServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DisplayServices.framework; path = /System/Library/PrivateFrameworks/DisplayServices.framework; sourceTree = ""; }; + 7E6856472EA963FD00D261F5 /* Lock.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Lock.json; sourceTree = ""; }; 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */ = {isa = PBXFileReference; lastKnownFileType = text.script.perl; path = "mediaremote-adapter.pl"; sourceTree = ""; }; 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = NowPlayingTestClient; sourceTree = ""; }; 7E8840D62E9A346700C5CB8C /* Dog waiting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Dog waiting.json"; sourceTree = ""; }; @@ -120,6 +122,7 @@ 7E8840D52E9A341F00C5CB8C /* LottieAnimations */ = { isa = PBXGroup; children = ( + 7E6856472EA963FD00D261F5 /* Lock.json */, 7E8840D62E9A346700C5CB8C /* Dog waiting.json */, 7E8840D82E9A347000C5CB8C /* Moody Dog.json */, 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */, @@ -236,6 +239,7 @@ files = ( 7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */, 7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */, + 7E6856482EA963FD00D261F5 /* Lock.json in Resources */, 7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */, 7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */, 7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */, diff --git a/DynamicIsland/components/LockScreen/LockIconAnimationView.swift b/DynamicIsland/components/LockScreen/LockIconAnimationView.swift new file mode 100644 index 0000000..cd7646e --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockIconAnimationView.swift @@ -0,0 +1,121 @@ +// +// LockIconAnimationView.swift +// DynamicIsland +// +// Shared animator and SwiftUI Lottie rendering for the lock/unlock sequence. +// + +import SwiftUI +import Lottie + +@MainActor +final class LockIconAnimator: ObservableObject { + @Published private(set) var progress: CGFloat + + private var animationTask: Task? + private let animationDuration: TimeInterval = 0.35 + private let animationSteps: Int = 48 + + init(initiallyLocked: Bool) { + progress = initiallyLocked ? 1.0 : 0.0 + } + + deinit { + animationTask?.cancel() + } + + func update(isLocked: Bool, animated: Bool = true) { + let target = isLocked ? 1.0 : 0.0 + let clampedTarget = max(0.0, min(1.0, target)) + + if !animated { + animationTask?.cancel() + progress = clampedTarget + return + } + + guard abs(progress - clampedTarget) > 0.0005 else { + progress = clampedTarget + return + } + + animationTask?.cancel() + + let startProgress = progress + let delta = clampedTarget - startProgress + let stepDuration = animationDuration / Double(animationSteps) + let stepNanoseconds = UInt64(stepDuration * 1_000_000_000) + + animationTask = Task { [weak self] in + guard let self else { return } + + for step in 0...animationSteps { + if Task.isCancelled { return } + + if step > 0 { + try? await Task.sleep(nanoseconds: stepNanoseconds) + } + + let fraction = Double(step) / Double(animationSteps) + let eased = easeOutCubic(fraction) + progress = startProgress + CGFloat(eased) * CGFloat(delta) + } + + progress = clampedTarget + } + } + + private func easeOutCubic(_ t: Double) -> Double { + let clamped = max(0.0, min(1.0, t)) + return 1.0 - pow(1.0 - clamped, 3) + } +} + +struct LockIconProgressView: View { + var progress: CGFloat + var iconColor: Color = .white + + var body: some View { + if LockIconLottieView.isAvailable { + Rectangle() + .fill(iconColor) + .mask { + LockIconLottieView(progress: 1 - progress) + .scaleEffect(1.12) + } + } else { + Image(systemName: progress >= 0.5 ? "lock.fill" : "lock.open.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(iconColor) + } + } +} + +struct LockIconLottieView: View { + var progress: CGFloat + + private static let animation: LottieAnimation? = { + if let animation = LottieAnimation.named("Lock") { + return animation + } else { + print("⚠️ [LockIconLottieView] Missing Lock.json animation – falling back to SF Symbols") + return nil + } + }() + + static var isAvailable: Bool { + animation != nil + } + + var body: some View { + Group { + if let animation = Self.animation { + LottieView(animation: animation) + .currentProgress(progress) + .configuration(.init(renderingEngine: .mainThread)) + } else { + Color.clear + } + } + } +} diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift index 32ba769..53b75d2 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift @@ -11,30 +11,27 @@ import Defaults struct LockScreenLiveActivity: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject private var lockScreenManager = LockScreenManager.shared + @StateObject private var iconAnimator = LockIconAnimator(initiallyLocked: LockScreenManager.shared.isLocked) @State private var isHovering: Bool = false @State private var gestureProgress: CGFloat = 0 @State private var isExpanded: Bool = false - @Namespace private var lockIconSpace - - private var iconName: String { - lockScreenManager.isLocked ? "lock.fill" : "lock.open.fill" - } private var iconColor: Color { .white } + private var indicatorDimension: CGFloat { + max(0, vm.effectiveClosedNotchHeight - 12) + } + var body: some View { HStack(spacing: 0) { // Left - Lock icon with subtle glow Color.clear .overlay(alignment: .leading) { if isExpanded { - Image(systemName: iconName) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(iconColor) - .matchedGeometryEffect(id: "lock-icon", in: lockIconSpace) - .frame(width: vm.effectiveClosedNotchHeight - 12, height: vm.effectiveClosedNotchHeight - 12) + LockIconProgressView(progress: iconAnimator.progress, iconColor: iconColor) + .frame(width: indicatorDimension, height: indicatorDimension) } } .frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) @@ -50,6 +47,7 @@ struct LockScreenLiveActivity: View { } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) .onAppear { + iconAnimator.update(isLocked: lockScreenManager.isLocked, animated: false) // Expand immediately without animation to avoid conflicts withAnimation(.smooth(duration: 0.4)) { isExpanded = true @@ -70,6 +68,9 @@ struct LockScreenLiveActivity: View { } } } + .onChange(of: lockScreenManager.isLocked) { _, newValue in + iconAnimator.update(isLocked: newValue) + } .animation(.spring(response: 0.5, dampingFraction: 0.85), value: lockScreenManager.isLocked) .animation(.easeOut(duration: 0.25), value: isExpanded) } diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift index b690ce0..512e475 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift @@ -1,13 +1,13 @@ import SwiftUI final class LockScreenLiveActivityOverlayModel: ObservableObject { - @Published var iconName: String = "lock.fill" @Published var scale: CGFloat = 0.6 @Published var opacity: Double = 0 } struct LockScreenLiveActivityOverlay: View { @ObservedObject var model: LockScreenLiveActivityOverlayModel + @ObservedObject var animator: LockIconAnimator let notchSize: CGSize private var indicatorSize: CGFloat { @@ -26,9 +26,7 @@ struct LockScreenLiveActivityOverlay: View { HStack(spacing: 0) { Color.clear .overlay(alignment: .leading) { - Image(systemName: model.iconName) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.white) + LockIconProgressView(progress: animator.progress) .frame(width: indicatorSize, height: indicatorSize) } .frame(width: indicatorSize, height: notchSize.height) @@ -52,6 +50,5 @@ struct LockScreenLiveActivityOverlay: View { .frame(width: totalWidth, height: notchSize.height) .scaleEffect(x: model.scale, y: 1, anchor: .center) .opacity(model.opacity) - .drawingGroup() } } diff --git a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift index 2b8814e..6f6c631 100644 --- a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift +++ b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift @@ -19,6 +19,7 @@ class LockScreenLiveActivityWindowManager { private var hideTask: Task? private var hostingView: NSHostingView? private let overlayModel = LockScreenLiveActivityOverlayModel() + private let overlayAnimator = LockIconAnimator(initiallyLocked: LockScreenManager.shared.isLocked) private weak var viewModel: DynamicIslandViewModel? private init() {} @@ -103,7 +104,7 @@ class LockScreenLiveActivityWindowManager { let targetFrame = frame(for: windowSize, on: screen) window.setFrame(targetFrame, display: true) - let overlayView = LockScreenLiveActivityOverlay(model: overlayModel, notchSize: notchSize) + let overlayView = LockScreenLiveActivityOverlay(model: overlayModel, animator: overlayAnimator, notchSize: notchSize) if let hostingView { hostingView.rootView = overlayView @@ -134,7 +135,7 @@ class LockScreenLiveActivityWindowManager { hideTask?.cancel() guard let context = lockContext() else { return } - overlayModel.iconName = "lock.fill" + overlayAnimator.update(isLocked: true) overlayModel.scale = 0.6 overlayModel.opacity = 0 @@ -156,20 +157,12 @@ class LockScreenLiveActivityWindowManager { hideTask?.cancel() guard let context = lockContext() else { return } - overlayModel.iconName = "lock.open.fill" - overlayModel.scale = 0.7 - overlayModel.opacity = 0 + overlayModel.scale = 1 + overlayModel.opacity = 1 present(notchSize: context.notchSize, on: context.screen) - DispatchQueue.main.async { - withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { - self.overlayModel.scale = 1 - } - withAnimation(.easeOut(duration: 0.16)) { - self.overlayModel.opacity = 1 - } - } + overlayAnimator.update(isLocked: false) hideTask = Task { [weak self] in try? await Task.sleep(for: .seconds(max(Defaults[.waitInterval], 1.0))) diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index 30a7680..3064522 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -27,6 +27,7 @@ class LockScreenManager: ObservableObject { // MARK: - Private Properties private var debounceIdleTask: Task? + private var collapseTask: Task? // MARK: - Helpers @@ -45,6 +46,7 @@ class LockScreenManager: ObservableObject { deinit { DistributedNotificationCenter.default().removeObserver(self) debounceIdleTask?.cancel() + collapseTask?.cancel() } // MARK: - Setup @@ -80,6 +82,7 @@ class LockScreenManager: ObservableObject { // Set locked state immediately without animation wrapper isLocked = true + collapseTask?.cancel() viewModel?.closeForLockScreen() @@ -120,7 +123,14 @@ class LockScreenManager: ObservableObject { // Update state immediately if Defaults[.enableLockScreenLiveActivity] { - self.coordinator.toggleExpandingView(status: false, type: .lockScreen) + collapseTask?.cancel() + collapseTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(380)) + guard let self = self, !Task.isCancelled else { return } + await MainActor.run { + self.coordinator.toggleExpandingView(status: false, type: .lockScreen) + } + } } self.lastUpdated = Date() diff --git a/LottieAnimations/Lock.json b/LottieAnimations/Lock.json new file mode 100644 index 0000000..e36c23f --- /dev/null +++ b/LottieAnimations/Lock.json @@ -0,0 +1 @@ +{"v":"5.1.18","fr":25,"ip":0,"op":18,"w":500,"h":500,"nm":"Unlock 2","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,310,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[11.5,0],[9.75,0],[0,-10.5],[0,-6.25],[-10.25,0],[-6.75,0],[0,11.25],[0,4]],"o":[[-4,0],[-11.25,0],[0,3.75],[0,10.75],[3.25,0],[11,0],[0,-3.5],[0,-10.5]],"v":[[22,-65],[-87.75,-65],[-105,-48.5],[-105,40],[-87.75,57],[22,57],[40,40],[40,-48.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":4,"s":[0.992156862745,0.192156862745,0.21568627451,1],"e":[0.015686275437,0.972549021244,0.443137258291,1]},{"t":16}],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[43,5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[134.286,134.286],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"n":"0p667_1_0p167_0p167","t":0,"s":[{"i":[[0,0],[0,13],[28.5,0],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-28.5,0],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-114.5],[-32.5,-153],[-78.5,-114.5],[-78.5,-64.814]],"c":false}],"e":[{"i":[[0,0],[0,13],[28.5,0],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-28.5,0],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.394],[-32.5,-167.894],[-78.5,-129.394],[-78.5,-79.707]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"n":"0p667_1_0p333_0","t":2,"s":[{"i":[[0,0],[0,13],[28.5,0],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-28.5,0],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.394],[-32.5,-167.894],[-78.5,-129.394],[-78.5,-79.707]],"c":false}],"e":[{"i":[[0,0],[0,13],[28.5,0],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-28.5,0],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.394],[-32.5,-167.894],[-78.5,-129.394],[-78.5,-79.707]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"n":"0p833_0p833_0p333_0","t":4,"s":[{"i":[[0,0],[0,13],[28.5,0],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-28.5,0],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.394],[-32.5,-167.894],[-78.5,-129.394],[-78.5,-79.707]],"c":false}],"e":[{"i":[[0,0],[0,13],[-0.08,0.154],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-0.08,-0.218],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.207],[13.856,-167.707],[13.84,-129.207],[14.166,-79.521]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"n":"0p667_1_0p167_0p167","t":10,"s":[{"i":[[0,0],[0,13],[-0.08,0.154],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-0.08,-0.218],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.207],[13.856,-167.707],[13.84,-129.207],[14.166,-79.521]],"c":false}],"e":[{"i":[[0,0],[0,13],[-29.681,-0.032],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[28.032,0.03],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.394],[60.213,-167.894],[106.181,-129.394],[106.181,-79.707]],"c":false}]},{"t":16}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":4,"s":[0.992156862745,0.192156862745,0.21568627451,1],"e":[0.015686275437,0.972549021244,0.443137258291,1]},{"t":16}],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":19,"ix":5},"lc":2,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[43,5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[134.286,134.286],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 2","np":3,"cix":2,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":625,"st":0,"bm":0}],"markers":[]} \ No newline at end of file