From ffd72456d9d378ba49dea2bf6298dbe61ffcf988 Mon Sep 17 00:00:00 2001 From: delli Date: Wed, 22 Apr 2026 17:25:14 +0200 Subject: [PATCH] other small improvements --- DynamicIsland/ContentView.swift | 698 ++++++++++-------- DynamicIsland/Localizable.xcstrings | 3 + .../LockScreen/LockScreenLiveActivity.swift | 37 +- .../LockScreen/LockScreenMusicPanel.swift | 57 +- .../components/Settings/SettingsView.swift | 13 + .../managers/LockScreenManager.swift | 39 +- DynamicIsland/models/Constants.swift | 4 + 7 files changed, 532 insertions(+), 319 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 1cf9985..34e4e78 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -234,6 +234,64 @@ struct ContentView: View { private var closedMusicContentEnabled: Bool { enableMinimalisticUI || showStandardMediaControls } + + private var isMusicHUDDeferredAfterUnlock: Bool { + lockScreenManager.shouldDelayPostUnlockMusicHUD + } + + private var interactionsEnabled: Bool { + !lockScreenManager.isLocked + } + + private var isIslandMode: Bool { + isDynamicIslandMode + } + + private var notchHorizontalPadding: CGFloat { + guard vm.notchState == .open else { + return activeCornerRadiusInsets.closed.bottom + } + if Defaults[.cornerRadiusScaling] { + return activeCornerRadiusInsets.opened.top - 5 + } + return activeCornerRadiusInsets.opened.bottom - 5 + } + + private var bodyHoverAreaPadding: CGFloat { + if vm.notchState == .open && Defaults[.extendHoverArea] { + return 0 + } + return vm.effectiveClosedNotchHeight == 0 ? zeroHeightHoverPadding : 0 + } + + private var notchBottomPadding: CGFloat { + currentShadowPadding + bodyHoverAreaPadding + } + + private var pillTopOffset: CGFloat { + isIslandMode ? dynamicIslandTopOffset : 0 + } + + private func closedMusicPairingEligible(hasActiveMusicSnapshot: Bool) -> Bool { + vm.notchState == .closed + && hasActiveMusicSnapshot + && coordinator.musicLiveActivityEnabled + && closedMusicContentEnabled + && !vm.hideOnClosed + && !lockScreenManager.isLocked + && !isMusicHUDDeferredAfterUnlock + } + + private var closedLiveActivitySwapTransition: AnyTransition { + .asymmetric( + insertion: .opacity + .combined(with: .scale(scale: 0.965, anchor: .center)) + .animation(.spring(response: 0.34, dampingFraction: 0.88)), + removal: .opacity + .combined(with: .scale(scale: 0.92, anchor: .center)) + .animation(.smooth(duration: 0.22)) + ) + } // Use minimalistic corner radius ONLY when opened, keep normal when closed private var activeCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) { @@ -334,212 +392,200 @@ struct ContentView: View { } var body: some View { - let interactionsEnabled = !lockScreenManager.isLocked - let isIslandMode = isDynamicIslandMode - let notchHorizontalPadding: CGFloat = { - guard vm.notchState == .open else { - return activeCornerRadiusInsets.closed.bottom - } - if Defaults[.cornerRadiusScaling] { - return activeCornerRadiusInsets.opened.top - 5 - } - return activeCornerRadiusInsets.opened.bottom - 5 - }() - let hoverAreaPadding: CGFloat = { - if vm.notchState == .open && Defaults[.extendHoverArea] { - return 0 - } - return vm.effectiveClosedNotchHeight == 0 ? zeroHeightHoverPadding : 0 - }() - let notchBottomPadding = currentShadowPadding + hoverAreaPadding - // Extra top padding to detach pill from screen edge in Dynamic Island mode - let pillTopOffset: CGFloat = isIslandMode ? dynamicIslandTopOffset : 0 + installRootLifecycleHandlers(on: rootBodyView) + } - ZStack(alignment: .top) { - let mainLayout = NotchLayout() - .frame(alignment: .top) - .padding(.horizontal, notchHorizontalPadding) - .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) - .background(.black) - .clipShape(resolvedClipShape) - .compositingGroup() - .shadow( - color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) - ? .black.opacity(0.6) - : .clear, - radius: Defaults[.cornerRadiusScaling] ? 10 : 5 - ) - // Extra horizontal inset for Dynamic Island mode so the shadow - // is not clipped by the outer frame constraint - .padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0) - .padding(.top, pillTopOffset) + private var mainLayoutBase: some View { + NotchLayout() + .frame(alignment: .top) + .padding(.horizontal, notchHorizontalPadding) + .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) + .background(.black) + .clipShape(resolvedClipShape) + .compositingGroup() + .shadow( + color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) + ? .black.opacity(0.6) + : .clear, + radius: Defaults[.cornerRadiusScaling] ? 10 : 5 + ) + // Extra horizontal inset for Dynamic Island mode so the shadow + // is not clipped by the outer frame constraint + .padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0) + .padding(.top, pillTopOffset) + } - mainLayout - .conditionalModifier(!useModernCloseAnimation) { view in - let hoverAnimation = Animation.bouncy.speed(1.2) - let notchStateAnimation = Animation.spring.speed(1.2) - return view - .animation(hoverAnimation, value: isHovering) - .animation(notchStateAnimation, value: vm.notchState) - .animation(.smooth, value: gestureProgress) - .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) - } - .conditionalModifier(useModernCloseAnimation) { view in - let hoverAnimation = Animation.bouncy.speed(1.2) - let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0) - let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0) - let notchAnimation = vm.notchState == .open ? openAnimation : closeAnimation - return view - .animation(hoverAnimation, value: isHovering) - .animation(notchAnimation, value: vm.notchState) - .animation(.smooth, value: gestureProgress) - } - .conditionalModifier(interactionsEnabled) { view in - view - .contentShape(resolvedClipShape) - .onHover { hovering in - handleHover(hovering) + private var configuredMainLayout: some View { + mainLayoutBase + .conditionalModifier(!useModernCloseAnimation) { view in + let hoverAnimation = Animation.bouncy.speed(1.2) + let notchStateAnimation = Animation.spring.speed(1.2) + return view + .animation(hoverAnimation, value: isHovering) + .animation(notchStateAnimation, value: vm.notchState) + .animation(.smooth, value: gestureProgress) + .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) + } + .conditionalModifier(useModernCloseAnimation) { view in + let hoverAnimation = Animation.bouncy.speed(1.2) + let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0) + let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0) + let notchAnimation = vm.notchState == .open ? openAnimation : closeAnimation + return view + .animation(hoverAnimation, value: isHovering) + .animation(notchAnimation, value: vm.notchState) + .animation(.smooth, value: gestureProgress) + } + .conditionalModifier(interactionsEnabled) { view in + view + .contentShape(resolvedClipShape) + .onHover { hovering in + handleHover(hovering) + } + .onTapGesture { + if handleClosedMusicWaveformTapIfNeeded() { + return } - .onTapGesture { - if handleClosedMusicWaveformTapIfNeeded() { - return + if vm.notchState == .closed && Defaults[.enableHaptics] { + triggerHapticIfAllowed() + } + openNotch() + } + .conditionalModifier(Defaults[.enableGestures]) { view in + view + .panGesture(direction: .down) { translation, phase in + handleDownGesture(translation: translation, phase: phase) } - if vm.notchState == .closed && Defaults[.enableHaptics] { - triggerHapticIfAllowed() + .panGesture(direction: .left) { translation, phase in + handleSkipGesture(direction: .forward, translation: translation, phase: phase) } + .panGesture(direction: .right) { translation, phase in + handleSkipGesture(direction: .backward, translation: translation, phase: phase) + } + } + } + .conditionalModifier((Defaults[.closeGestureEnabled] || Defaults[.reverseScrollGestures]) && Defaults[.enableGestures] && interactionsEnabled) { view in + view + .panGesture(direction: .up) { translation, phase in + handleUpGesture(translation: translation, phase: phase) + } + } + // Shadow bottom padding and hide-until-hover offset applied AFTER + // interaction modifiers so .contentShape / .onHover only covers + // the actual notch content, not the shadow clearance below it. + .padding(.bottom, notchBottomPadding) + .offset(y: shouldHideUntilHover && !isHovering + ? -(vm.closedNotchSize.height + pillTopOffset + currentShadowPadding + 10) + : 0 + ) + .onAppear(perform: { + if coordinator.firstLaunch { + // Single open during first launch; closeHello() handles the timed close. + runAfter(1) { + withAnimation(vm.animation) { openNotch() } - .conditionalModifier(Defaults[.enableGestures]) { view in - view - .panGesture(direction: .down) { translation, phase in - handleDownGesture(translation: translation, phase: phase) - } - .panGesture(direction: .left) { translation, phase in - handleSkipGesture(direction: .forward, translation: translation, phase: phase) - } - .panGesture(direction: .right) { translation, phase in - handleSkipGesture(direction: .backward, translation: translation, phase: phase) - } - } - } - .conditionalModifier((Defaults[.closeGestureEnabled] || Defaults[.reverseScrollGestures]) && Defaults[.enableGestures] && interactionsEnabled) { view in - view - .panGesture(direction: .up) { translation, phase in - handleUpGesture(translation: translation, phase: phase) - } - } - // Shadow bottom padding and hide-until-hover offset applied AFTER - // interaction modifiers so .contentShape / .onHover only covers - // the actual notch content, not the shadow clearance below it. - .padding(.bottom, notchBottomPadding) - .offset(y: shouldHideUntilHover && !isHovering - ? -(vm.closedNotchSize.height + pillTopOffset + currentShadowPadding + 10) - : 0 - ) - .onAppear(perform: { - if coordinator.firstLaunch { - // Single open during first launch; closeHello() handles the timed close. - runAfter(1) { - withAnimation(vm.animation) { - openNotch() - } - } - } - }) - .onChange(of: vm.notchState) { _, newState in - // Update smart monitoring based on notch state - if enableStatsFeature { - let currentViewString = coordinator.currentView == .stats ? "stats" : "other" - statsManager.updateMonitoringState( - notchIsOpen: newState == .open, - currentView: currentViewString - ) - } - - // Reset hover state when notch state changes - if newState == .closed && isHovering { - withAnimation { - isHovering = false - } - } - if newState != .closed { - isHoveringClosedMusicWaveformControl = false - } - if newState == .closed { - removeStickyTerminalClickMonitor() - } - #if os(macOS) - if newState == .open { - TimerControlWindowManager.shared.hide() - } - #endif - } - .onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in - runAfter(0.1) { - if !newPopoverState && !isHovering && vm.notchState == .open && !shouldPreventAutoClose() { - vm.close() - } } } - .onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in - runAfter(0.1) { - if !newPopoverState && !isHovering && vm.notchState == .open && !shouldPreventAutoClose() { - vm.close() - } - } - } - .onChange(of: vm.shouldRecheckHover) { _, _ in - // Recheck hover state when popovers are closed - runAfter(0.1) { - if vm.notchState == .open && !shouldPreventAutoClose() && !isHovering { - vm.close() - } - } - } - .onReceive(NotificationCenter.default.publisher(for: .sharingDidFinish)) { _ in - runAfter(0.1) { - if vm.notchState == .open && !isHovering && !shouldPreventAutoClose() { - vm.close() - } - } - } - .onChange(of: coordinator.sneakPeek.show) { _, sneakPeekShowing in - // When sneak peek finishes, check if user is still hovering and open notch if needed - if !sneakPeekShowing { - runAfter(0.2) { - if isHovering && vm.notchState == .closed { - openNotch() - } - } - } - } - .onChange(of: coordinator.currentView) { _, newValue in - if enableStatsFeature { - let currentViewString = newValue == .stats ? "stats" : "other" - statsManager.updateMonitoringState( - notchIsOpen: vm.notchState == .open, - currentView: currentViewString - ) - } - syncStickyTerminalOutsideClickMonitor() - } - .sensoryFeedback(.alignment, trigger: haptics) - .contextMenu { - Button("Settings") { - SettingsWindowController.shared.showWindow() - } -// Button("Edit") { // Doesnt work.... -// let dn = DynamicNotch(content: EditPanelView()) -// dn.toggle() -// } -// #if DEBUG -// .disabled(false) -// #else -// .disabled(true) -// #endif -// .keyboardShortcut("E", modifiers: .command) + }) + .onChange(of: vm.notchState) { _, newState in + // Update smart monitoring based on notch state + if enableStatsFeature { + let currentViewString = coordinator.currentView == .stats ? "stats" : "other" + statsManager.updateMonitoringState( + notchIsOpen: newState == .open, + currentView: currentViewString + ) } + // Reset hover state when notch state changes + if newState == .closed && isHovering { + withAnimation { + isHovering = false + } + } + if newState != .closed { + isHoveringClosedMusicWaveformControl = false + } + if newState == .closed { + removeStickyTerminalClickMonitor() + } + #if os(macOS) + if newState == .open { + TimerControlWindowManager.shared.hide() + } + #endif + } + .onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in + runAfter(0.1) { + if !newPopoverState && !isHovering && vm.notchState == .open && !shouldPreventAutoClose() { + vm.close() + } + } + } + .onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in + runAfter(0.1) { + if !newPopoverState && !isHovering && vm.notchState == .open && !shouldPreventAutoClose() { + vm.close() + } + } + } + .onChange(of: vm.shouldRecheckHover) { _, _ in + // Recheck hover state when popovers are closed + runAfter(0.1) { + if vm.notchState == .open && !shouldPreventAutoClose() && !isHovering { + vm.close() + } + } + } + .onReceive(NotificationCenter.default.publisher(for: .sharingDidFinish)) { _ in + runAfter(0.1) { + if vm.notchState == .open && !isHovering && !shouldPreventAutoClose() { + vm.close() + } + } + } + .onChange(of: coordinator.sneakPeek.show) { _, sneakPeekShowing in + // When sneak peek finishes, check if user is still hovering and open notch if needed + if !sneakPeekShowing { + runAfter(0.2) { + if isHovering && vm.notchState == .closed { + openNotch() + } + } + } + } + .onChange(of: coordinator.currentView) { _, newValue in + if enableStatsFeature { + let currentViewString = newValue == .stats ? "stats" : "other" + statsManager.updateMonitoringState( + notchIsOpen: vm.notchState == .open, + currentView: currentViewString + ) + } + syncStickyTerminalOutsideClickMonitor() + } + .sensoryFeedback(.alignment, trigger: haptics) + .contextMenu { + Button("Settings") { + SettingsWindowController.shared.showWindow() + } +// Button("Edit") { // Doesnt work.... +// let dn = DynamicNotch(content: EditPanelView()) +// dn.toggle() +// } +// #if DEBUG +// .disabled(false) +// #else +// .disabled(true) +// #endif +// .keyboardShortcut("E", modifiers: .command) + } + } + + private var rootBodyView: some View { + ZStack(alignment: .top) { + configuredMainLayout } .frame( maxWidth: dynamicNotchSize.width + (isDynamicIslandMode ? dynamicIslandShadowInset * 2 : 0), @@ -551,118 +597,143 @@ struct ContentView: View { .background(dragDetector) .environmentObject(vm) .environmentObject(webcamManager) - .onAppear { - isMusicControlWindowSuppressed = vm.notchState != .closed || lockScreenManager.isLocked - if musicManager.isPlaying || !musicManager.isPlayerIdle { - clearMusicControlVisibilityDeadline() - } - if let deadline = musicControlVisibilityDeadline, Date() > deadline { - clearMusicControlVisibilityDeadline() - } - enqueueMusicControlWindowSync(forceRefresh: true) - startHiddenEdgeHoverPolling() - } - .onChange(of: terminalStickyMode) { _, _ in - syncStickyTerminalOutsideClickMonitor() - } - .onChange(of: vm.notchState) { _, state in - if state == .open { - suppressMusicControlWindowUpdates() - cancelMusicControlWindowSync() - hideMusicControlWindow() - } else { - releaseMusicControlWindowUpdates(after: musicControlResumeDelay) - enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) - } + } - } - .onChange(of: musicControlWindowEnabled) { _, enabled in - if enabled { + private func installRootLifecycleHandlers(on view: Content) -> some View { + installSecondaryRootLifecycleHandlers( + on: installPrimaryRootLifecycleHandlers(on: view) + ) + } + + private func installPrimaryRootLifecycleHandlers(on view: Content) -> some View { + view + .onAppear { + isMusicControlWindowSuppressed = vm.notchState != .closed + || lockScreenManager.isLocked + || isMusicHUDDeferredAfterUnlock if musicManager.isPlaying || !musicManager.isPlayerIdle { clearMusicControlVisibilityDeadline() } + if let deadline = musicControlVisibilityDeadline, Date() > deadline { + clearMusicControlVisibilityDeadline() + } enqueueMusicControlWindowSync(forceRefresh: true) - } else { + startHiddenEdgeHoverPolling() + } + .onChange(of: terminalStickyMode) { _, _ in + syncStickyTerminalOutsideClickMonitor() + } + .onChange(of: vm.notchState) { _, state in + if state == .open { + suppressMusicControlWindowUpdates() + cancelMusicControlWindowSync() + hideMusicControlWindow() + } else { + releaseMusicControlWindowUpdates(after: musicControlResumeDelay) + enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + .onChange(of: musicControlWindowEnabled) { _, enabled in + if enabled { + if musicManager.isPlaying || !musicManager.isPlayerIdle { + clearMusicControlVisibilityDeadline() + } + enqueueMusicControlWindowSync(forceRefresh: true) + } else { + cancelMusicControlWindowSync() + hideMusicControlWindow() + clearMusicControlVisibilityDeadline() + hasPendingMusicControlSync = false + pendingMusicControlForceRefresh = false + } + } + .onChange(of: coordinator.musicLiveActivityEnabled) { _, enabled in + if enabled { + enqueueMusicControlWindowSync(forceRefresh: true) + } else { + cancelMusicControlWindowSync() + hideMusicControlWindow() + clearMusicControlVisibilityDeadline() + hasPendingMusicControlSync = false + pendingMusicControlForceRefresh = false + } + } + .onChange(of: vm.hideOnClosed) { _, hidden in + if hidden { + cancelMusicControlWindowSync() + hideMusicControlWindow() + } else { + enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + .onChange(of: lockScreenManager.isLocked) { _, locked in + if locked { + suppressMusicControlWindowUpdates() + cancelMusicControlWindowSync() + hideMusicControlWindow() + } else { + releaseMusicControlWindowUpdates(after: musicControlResumeDelay) + enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + .onChange(of: lockScreenManager.shouldDelayPostUnlockMusicHUD) { _, deferred in + if deferred { + suppressMusicControlWindowUpdates() + cancelMusicControlWindowSync() + hideMusicControlWindow() + } else { + releaseMusicControlWindowUpdates(after: 0) + enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + } + + private func installSecondaryRootLifecycleHandlers(on view: Content) -> some View { + view + .onChange(of: showStandardMediaControls) { _, _ in + handleStandardMediaControlsAvailabilityChange() + } + .onChange(of: enableMinimalisticUI) { _, _ in + handleStandardMediaControlsAvailabilityChange() + } + .onChange(of: gestureProgress) { _, _ in + if shouldShowMusicControlWindow() { + enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + .onChange(of: isHovering) { _, hovering in + if shouldShowMusicControlWindow() { + enqueueMusicControlWindowSync(forceRefresh: true, delay: hovering ? 0.05 : 0.12) + } + } + .onChange(of: musicManager.isPlaying) { _, isPlaying in + handleMusicControlPlaybackChange(isPlaying: isPlaying) + } + .onChange(of: musicManager.isPlayerIdle) { _, isIdle in + handleMusicControlIdleChange(isIdle: isIdle) + } + .onChange(of: vm.closedNotchSize) { _, _ in + if shouldShowMusicControlWindow() { + enqueueMusicControlWindowSync(forceRefresh: true) + } + } + .onChange(of: vm.effectiveClosedNotchHeight) { _, _ in + if shouldShowMusicControlWindow() { + enqueueMusicControlWindowSync(forceRefresh: true) + } + } + .onDisappear { + hoverTask?.cancel() + stopHoverClickMonitor() + removeStickyTerminalClickMonitor() + stopHiddenEdgeHoverPolling() cancelMusicControlWindowSync() hideMusicControlWindow() + cancelMusicControlVisibilityTimer() clearMusicControlVisibilityDeadline() - hasPendingMusicControlSync = false - pendingMusicControlForceRefresh = false + musicControlSuppressionTask?.cancel() + isHoveringClosedMusicWaveformControl = false } - } - .onChange(of: coordinator.musicLiveActivityEnabled) { _, enabled in - if enabled { - enqueueMusicControlWindowSync(forceRefresh: true) - } else { - cancelMusicControlWindowSync() - hideMusicControlWindow() - clearMusicControlVisibilityDeadline() - hasPendingMusicControlSync = false - pendingMusicControlForceRefresh = false - } - } - .onChange(of: vm.hideOnClosed) { _, hidden in - if hidden { - cancelMusicControlWindowSync() - hideMusicControlWindow() - } else { - enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) - } - } - .onChange(of: lockScreenManager.isLocked) { _, locked in - if locked { - suppressMusicControlWindowUpdates() - cancelMusicControlWindowSync() - hideMusicControlWindow() - } else { - releaseMusicControlWindowUpdates(after: musicControlResumeDelay) - enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) - } - } - .onChange(of: showStandardMediaControls) { _, _ in - handleStandardMediaControlsAvailabilityChange() - } - .onChange(of: enableMinimalisticUI) { _, _ in - handleStandardMediaControlsAvailabilityChange() - } - .onChange(of: gestureProgress) { _, _ in - if shouldShowMusicControlWindow() { - enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) - } - } - .onChange(of: isHovering) { _, hovering in - if shouldShowMusicControlWindow() { - enqueueMusicControlWindowSync(forceRefresh: true, delay: hovering ? 0.05 : 0.12) - } - } - .onChange(of: musicManager.isPlaying) { _, isPlaying in - handleMusicControlPlaybackChange(isPlaying: isPlaying) - } - .onChange(of: musicManager.isPlayerIdle) { _, isIdle in - handleMusicControlIdleChange(isIdle: isIdle) - } - .onChange(of: vm.closedNotchSize) { _, _ in - if shouldShowMusicControlWindow() { - enqueueMusicControlWindowSync(forceRefresh: true) - } - } - .onChange(of: vm.effectiveClosedNotchHeight) { _, _ in - if shouldShowMusicControlWindow() { - enqueueMusicControlWindowSync(forceRefresh: true) - } - } - .onDisappear { - hoverTask?.cancel() - stopHoverClickMonitor() - removeStickyTerminalClickMonitor() - stopHiddenEdgeHoverPolling() - cancelMusicControlWindowSync() - hideMusicControlWindow() - cancelMusicControlVisibilityTimer() - clearMusicControlVisibilityDeadline() - musicControlSuppressionTask?.cancel() - isHoveringClosedMusicWaveformControl = false - } } @ViewBuilder @@ -683,12 +754,7 @@ struct ContentView: View { if musicManager.isPlaying { return true } return !musicManager.isPlayerIdle && hasMusicMetadata }() - let musicPairingEligible = vm.notchState == .closed - && hasActiveMusicSnapshot - && coordinator.musicLiveActivityEnabled - && closedMusicContentEnabled - && !vm.hideOnClosed - && !lockScreenManager.isLocked + let musicPairingEligible = closedMusicPairingEligible(hasActiveMusicSnapshot: hasActiveMusicSnapshot) let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible) let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary) let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID) @@ -750,6 +816,8 @@ struct ContentView: View { .transition(AnyTransition.move(edge: .trailing).combined(with: .opacity)) } else if canShowMusicDuringExpansion && musicPairingEligible { MusicLiveActivity(secondary: musicSecondary) + .id("closed-music-live-activity") + .transition(closedLiveActivitySwapTransition) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { TimerLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed { @@ -766,6 +834,8 @@ struct ContentView: View { DoNotDisturbLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { LockScreenLiveActivity() + .id("lock-screen-live-activity") + .transition(closedLiveActivitySwapTransition) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { PrivacyLiveActivity() } else if let extensionPayload = extensionStandalonePayload { @@ -1436,6 +1506,15 @@ struct ContentView: View { return nil } + guard !isMusicHUDDeferredAfterUnlock else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "waiting for lock screen unlock animation to finish", + pendingCount: candidates.count + ) + return nil + } + guard coordinator.musicLiveActivityEnabled else { ExtensionRoutingDiagnostics.shared.logSuppression( .music, @@ -1647,6 +1726,7 @@ struct ContentView: View { && closedMusicContentEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked + && !isMusicHUDDeferredAfterUnlock && !coordinator.expandingView.show && (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil) && !coordinator.firstLaunch @@ -2185,7 +2265,7 @@ struct ContentView: View { guard !Task.isCancelled else { return } await MainActor.run { - if vm.notchState == .closed && !lockScreenManager.isLocked { + if vm.notchState == .closed && !lockScreenManager.isLocked && !isMusicHUDDeferredAfterUnlock { isMusicControlWindowSuppressed = false triggerPendingMusicControlSyncIfNeeded() } else { @@ -2208,7 +2288,10 @@ struct ContentView: View { } private func shouldDeferMusicControlSync() -> Bool { - vm.notchState != .closed || lockScreenManager.isLocked || isMusicControlWindowSuppressed + vm.notchState != .closed + || lockScreenManager.isLocked + || isMusicHUDDeferredAfterUnlock + || isMusicControlWindowSuppressed } private func enqueueMusicControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0) { @@ -2232,6 +2315,7 @@ struct ContentView: View { vm.notchState == .closed, !vm.hideOnClosed, !lockScreenManager.isLocked, + !isMusicHUDDeferredAfterUnlock, !isMusicControlWindowSuppressed else { return false } @@ -2250,7 +2334,7 @@ struct ContentView: View { return } - if !bypassSuppression && (isMusicControlWindowSuppressed || lockScreenManager.isLocked) { + if !bypassSuppression && (isMusicControlWindowSuppressed || lockScreenManager.isLocked || isMusicHUDDeferredAfterUnlock) { hasPendingMusicControlSync = true if forceRefresh { pendingMusicControlForceRefresh = true diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 66ebabf..11cb81f 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -43408,6 +43408,9 @@ } } } + }, + "Use enhanced liquid border" : { + }, "Use MacBook icon when on battery" : { "localizations" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift index f103790..da41805 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift @@ -42,18 +42,42 @@ struct LockScreenLiveActivity: View { private var indicatorDimension: CGFloat { max(0, vm.effectiveClosedNotchHeight - 12) } + + private var indicatorSideHeight: CGFloat { + max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + } + + private var expandedIndicatorSideWidth: CGFloat { + max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) + } + + private var indicatorSideWidth: CGFloat { + isExpanded ? expandedIndicatorSideWidth : 0 + } + + private var indicatorVisibilityProgress: CGFloat { + guard expandedIndicatorSideWidth > 0 else { return 0 } + return min(max(indicatorSideWidth / expandedIndicatorSideWidth, 0), 1) + } + + private var indicatorOpacity: Double { + let fadeThreshold: CGFloat = 0.16 + guard indicatorVisibilityProgress < fadeThreshold else { return 1 } + return Double(max(0, indicatorVisibilityProgress / fadeThreshold)) + } var body: some View { HStack(spacing: 0) { // Left - Lock icon with subtle glow Color.clear .overlay(alignment: .leading) { - if isExpanded { - LockIconProgressView(progress: iconAnimator.progress, iconColor: iconColor) - .frame(width: indicatorDimension, height: indicatorDimension) - } + LockIconProgressView(progress: iconAnimator.progress, iconColor: iconColor) + .frame(width: indicatorDimension, height: indicatorDimension) + .opacity(indicatorOpacity) + .scaleEffect(0.96 + (indicatorVisibilityProgress * 0.04)) } - .frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + .frame(width: indicatorSideWidth, height: indicatorSideHeight, alignment: .leading) + .clipped() // Center - Black fill Rectangle() @@ -62,7 +86,7 @@ struct LockScreenLiveActivity: View { // Right - Empty for symmetry with animation Color.clear - .frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + .frame(width: indicatorSideWidth, height: indicatorSideHeight) } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) .onAppear { @@ -100,6 +124,5 @@ struct LockScreenLiveActivity: View { } } .animation(.spring(response: 0.5, dampingFraction: 0.85), value: lockScreenManager.isLocked) - .animation(.easeOut(duration: 0.25), value: isExpanded) } } diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 14afb14..17716a4 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -56,6 +56,7 @@ struct LockScreenMusicPanel: View { @Default(.lockScreenMusicLiquidGlassVariant) private var musicGlassVariant @Default(.lockScreenShowAppIcon) var showAppIcon @Default(.lockScreenPanelShowsBorder) var showPanelBorder + @Default(.lockScreenMusicUsesEnhancedLiquidBorder) private var useEnhancedLiquidBorder @Default(.lockScreenPanelUsesBlur) var enableBlur @Default(.showMediaOutputControl) private var showMediaOutputControl @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat @@ -113,6 +114,10 @@ struct LockScreenMusicPanel: View { private var usesLiquidGlass: Bool { usesCustomLiquidGlass || usesStandardLiquidGlass } + + private var usesEnhancedCustomLiquidBorder: Bool { + usesCustomLiquidGlass && useEnhancedLiquidBorder + } var body: some View { if isActive && musicManager.hasActiveSession { @@ -131,9 +136,8 @@ struct LockScreenMusicPanel: View { .frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading) .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) .overlay { - if showPanelBorder && !usesLiquidGlass { - RoundedRectangle(cornerRadius: panelCornerRadius) - .stroke(Color.white.opacity(0.35), lineWidth: 1.4) + if showPanelBorder { + panelBorderOverlay } } .shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) @@ -1077,6 +1081,53 @@ struct LockScreenMusicPanel: View { .accessibilityHidden(true) } + @ViewBuilder + private var panelBorderOverlay: some View { + if usesEnhancedCustomLiquidBorder { + ZStack { + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .stroke( + Color.white.opacity(0.22), + lineWidth: 1.05 + ) + + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .inset(by: 0.85) + .stroke( + LinearGradient( + colors: [ + Color.white.opacity(0.28), + Color.white.opacity(0.1), + Color.black.opacity(0.18) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 0.9 + ) + + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .inset(by: 1.55) + .stroke( + Color.black.opacity(0.16), + lineWidth: 0.55 + ) + } + .allowsHitTesting(false) + } else if usesLiquidGlass { + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .stroke( + Color.white.opacity(usesCustomLiquidGlass ? 0.15 : 0.14), + lineWidth: usesCustomLiquidGlass ? 0.95 : 0.9 + ) + .allowsHitTesting(false) + } else { + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.35), lineWidth: 1.4) + .allowsHitTesting(false) + } + } + private func albumArtImage(size: CGFloat, cornerRadius: CGFloat) -> some View { Image(nsImage: musicManager.albumArt) .resizable() diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 73c2559..38302ff 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -2670,6 +2670,12 @@ struct Media: View { Text("Show panel border") } .disabled(!enableLockScreenMediaWidget) + if lockScreenGlassCustomizationMode == .customLiquid { + Defaults.Toggle(key: .lockScreenMusicUsesEnhancedLiquidBorder) { + Text("Use enhanced liquid border") + } + .disabled(!enableLockScreenMediaWidget) + } if lockScreenGlassCustomizationMode == .customLiquid { customLiquidBlurRow .opacity(enableLockScreenMediaWidget ? 1 : 0.5) @@ -4772,6 +4778,13 @@ struct LockScreenSettings: View { } .disabled(!enableLockScreenMediaWidget) .settingsHighlight(id: highlightID("Show panel border")) + if lockScreenGlassCustomizationMode == .customLiquid { + Defaults.Toggle(key: .lockScreenMusicUsesEnhancedLiquidBorder) { + Text("Use enhanced liquid border") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Use enhanced liquid border")) + } if lockScreenGlassCustomizationMode == .customLiquid { variantSlider( title: "Music panel variant", diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index e1c7a6e..6e98688 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -26,6 +26,8 @@ import AVFoundation enum LockScreenAnimationTimings { static let lockExpand: TimeInterval = 0.45 static let unlockCollapse: TimeInterval = 0.82 + static let postUnlockMusicHUDPause: TimeInterval = 1.0 + static let postUnlockMusicHUDReveal: TimeInterval = 0.34 } @MainActor @@ -39,11 +41,13 @@ class LockScreenManager: ObservableObject { // MARK: - Published Properties @Published var isLocked: Bool = false @Published var isLockIdle: Bool = true + @Published var shouldDelayPostUnlockMusicHUD: Bool = false @Published var lastUpdated: Date = .distantPast // MARK: - Private Properties private var debounceIdleTask: Task? private var collapseTask: Task? + private var postUnlockMusicHUDTask: Task? // MARK: - Helpers @@ -63,6 +67,7 @@ class LockScreenManager: ObservableObject { DistributedNotificationCenter.default().removeObserver(self) debounceIdleTask?.cancel() collapseTask?.cancel() + postUnlockMusicHUDTask?.cancel() } // MARK: - Setup @@ -102,6 +107,8 @@ class LockScreenManager: ObservableObject { // Update state SYNCHRONOUSLY without Task/await to avoid any delay lastUpdated = Date() updateIdleState(locked: true) + postUnlockMusicHUDTask?.cancel() + shouldDelayPostUnlockMusicHUD = false // Set locked state immediately without animation wrapper isLocked = true @@ -149,6 +156,33 @@ class LockScreenManager: ObservableObject { lastUpdated = Date() updateIdleState(locked: false) isLocked = false + postUnlockMusicHUDTask?.cancel() + shouldDelayPostUnlockMusicHUD = Defaults[.enableLockScreenLiveActivity] + + if shouldDelayPostUnlockMusicHUD { + postUnlockMusicHUDTask = Task { [weak self] in + try? await Task.sleep( + for: .seconds( + LockScreenAnimationTimings.unlockCollapse + + LockScreenAnimationTimings.postUnlockMusicHUDPause + ) + ) + guard let self = self, !Task.isCancelled else { return } + await MainActor.run { + if !self.isLocked { + withAnimation( + .spring( + response: LockScreenAnimationTimings.postUnlockMusicHUDReveal, + dampingFraction: 0.88, + blendDuration: 0.08 + ) + ) { + self.shouldDelayPostUnlockMusicHUD = false + } + } + } + } + } // Hide panel window immediately and synchronously print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") @@ -182,8 +216,9 @@ class LockScreenManager: ObservableObject { } else { debounceIdleTask?.cancel() debounceIdleTask = Task { [weak self] in - let configuredInterval = max(Defaults[.waitInterval], 0) - let idleDelay = min(max(configuredInterval, 0.2), LockScreenAnimationTimings.unlockCollapse) + // Keep the lock live activity mounted until the collapse animation finishes, + // otherwise the content disappears before the island fully closes. + let idleDelay = LockScreenAnimationTimings.unlockCollapse try? await Task.sleep(for: .seconds(idleDelay)) guard let self = self, !Task.isCancelled else { return } await MainActor.run { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 083b913..198cd85 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -904,6 +904,10 @@ extension Defaults.Keys { ) static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: false) + static let lockScreenMusicUsesEnhancedLiquidBorder = Key( + "lockScreenMusicUsesEnhancedLiquidBorder", + default: true + ) static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true) static let lockScreenMusicMergedAirPlayOutput = Key("lockScreenMusicMergedAirPlayOutput", default: true) static let lockScreenTimerWidgetUsesBlur = Key("lockScreenTimerWidgetUsesBlur", default: false)