other small improvements

This commit is contained in:
delli 2026-04-22 17:25:14 +02:00
commit ffd72456d9
7 changed files with 515 additions and 302 deletions

View file

@ -234,6 +234,64 @@ struct ContentView: View {
private var closedMusicContentEnabled: Bool { private var closedMusicContentEnabled: Bool {
enableMinimalisticUI || showStandardMediaControls 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 // Use minimalistic corner radius ONLY when opened, keep normal when closed
private var activeCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) { private var activeCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) {
@ -334,212 +392,200 @@ struct ContentView: View {
} }
var body: some View { var body: some View {
let interactionsEnabled = !lockScreenManager.isLocked installRootLifecycleHandlers(on: rootBodyView)
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
ZStack(alignment: .top) { private var mainLayoutBase: some View {
let mainLayout = NotchLayout() NotchLayout()
.frame(alignment: .top) .frame(alignment: .top)
.padding(.horizontal, notchHorizontalPadding) .padding(.horizontal, notchHorizontalPadding)
.padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0)
.background(.black) .background(.black)
.clipShape(resolvedClipShape) .clipShape(resolvedClipShape)
.compositingGroup() .compositingGroup()
.shadow( .shadow(
color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow])
? .black.opacity(0.6) ? .black.opacity(0.6)
: .clear, : .clear,
radius: Defaults[.cornerRadiusScaling] ? 10 : 5 radius: Defaults[.cornerRadiusScaling] ? 10 : 5
) )
// Extra horizontal inset for Dynamic Island mode so the shadow // Extra horizontal inset for Dynamic Island mode so the shadow
// is not clipped by the outer frame constraint // is not clipped by the outer frame constraint
.padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0) .padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0)
.padding(.top, pillTopOffset) .padding(.top, pillTopOffset)
}
mainLayout private var configuredMainLayout: some View {
.conditionalModifier(!useModernCloseAnimation) { view in mainLayoutBase
let hoverAnimation = Animation.bouncy.speed(1.2) .conditionalModifier(!useModernCloseAnimation) { view in
let notchStateAnimation = Animation.spring.speed(1.2) let hoverAnimation = Animation.bouncy.speed(1.2)
return view let notchStateAnimation = Animation.spring.speed(1.2)
.animation(hoverAnimation, value: isHovering) return view
.animation(notchStateAnimation, value: vm.notchState) .animation(hoverAnimation, value: isHovering)
.animation(.smooth, value: gestureProgress) .animation(notchStateAnimation, value: vm.notchState)
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) .animation(.smooth, value: gestureProgress)
} .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
.conditionalModifier(useModernCloseAnimation) { view in }
let hoverAnimation = Animation.bouncy.speed(1.2) .conditionalModifier(useModernCloseAnimation) { view in
let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0) let hoverAnimation = Animation.bouncy.speed(1.2)
let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0) let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0)
let notchAnimation = vm.notchState == .open ? openAnimation : closeAnimation let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0)
return view let notchAnimation = vm.notchState == .open ? openAnimation : closeAnimation
.animation(hoverAnimation, value: isHovering) return view
.animation(notchAnimation, value: vm.notchState) .animation(hoverAnimation, value: isHovering)
.animation(.smooth, value: gestureProgress) .animation(notchAnimation, value: vm.notchState)
} .animation(.smooth, value: gestureProgress)
.conditionalModifier(interactionsEnabled) { view in }
view .conditionalModifier(interactionsEnabled) { view in
.contentShape(resolvedClipShape) view
.onHover { hovering in .contentShape(resolvedClipShape)
handleHover(hovering) .onHover { hovering in
handleHover(hovering)
}
.onTapGesture {
if handleClosedMusicWaveformTapIfNeeded() {
return
} }
.onTapGesture { if vm.notchState == .closed && Defaults[.enableHaptics] {
if handleClosedMusicWaveformTapIfNeeded() { triggerHapticIfAllowed()
return }
openNotch()
}
.conditionalModifier(Defaults[.enableGestures]) { view in
view
.panGesture(direction: .down) { translation, phase in
handleDownGesture(translation: translation, phase: phase)
} }
if vm.notchState == .closed && Defaults[.enableHaptics] { .panGesture(direction: .left) { translation, phase in
triggerHapticIfAllowed() 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() 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) { .onChange(of: vm.notchState) { _, newState in
if !newPopoverState && !isHovering && vm.notchState == .open && !shouldPreventAutoClose() { // Update smart monitoring based on notch state
vm.close() if enableStatsFeature {
} let currentViewString = coordinator.currentView == .stats ? "stats" : "other"
} statsManager.updateMonitoringState(
} notchIsOpen: newState == .open,
.onChange(of: vm.shouldRecheckHover) { _, _ in currentView: currentViewString
// 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)
} }
// 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( .frame(
maxWidth: dynamicNotchSize.width + (isDynamicIslandMode ? dynamicIslandShadowInset * 2 : 0), maxWidth: dynamicNotchSize.width + (isDynamicIslandMode ? dynamicIslandShadowInset * 2 : 0),
@ -551,118 +597,143 @@ struct ContentView: View {
.background(dragDetector) .background(dragDetector)
.environmentObject(vm) .environmentObject(vm)
.environmentObject(webcamManager) .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)
}
} private func installRootLifecycleHandlers<Content: View>(on view: Content) -> some View {
.onChange(of: musicControlWindowEnabled) { _, enabled in installSecondaryRootLifecycleHandlers(
if enabled { on: installPrimaryRootLifecycleHandlers(on: view)
)
}
private func installPrimaryRootLifecycleHandlers<Content: View>(on view: Content) -> some View {
view
.onAppear {
isMusicControlWindowSuppressed = vm.notchState != .closed
|| lockScreenManager.isLocked
|| isMusicHUDDeferredAfterUnlock
if musicManager.isPlaying || !musicManager.isPlayerIdle { if musicManager.isPlaying || !musicManager.isPlayerIdle {
clearMusicControlVisibilityDeadline() clearMusicControlVisibilityDeadline()
} }
if let deadline = musicControlVisibilityDeadline, Date() > deadline {
clearMusicControlVisibilityDeadline()
}
enqueueMusicControlWindowSync(forceRefresh: true) 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<Content: View>(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() cancelMusicControlWindowSync()
hideMusicControlWindow() hideMusicControlWindow()
cancelMusicControlVisibilityTimer()
clearMusicControlVisibilityDeadline() clearMusicControlVisibilityDeadline()
hasPendingMusicControlSync = false musicControlSuppressionTask?.cancel()
pendingMusicControlForceRefresh = false 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 @ViewBuilder
@ -683,12 +754,7 @@ struct ContentView: View {
if musicManager.isPlaying { return true } if musicManager.isPlaying { return true }
return !musicManager.isPlayerIdle && hasMusicMetadata return !musicManager.isPlayerIdle && hasMusicMetadata
}() }()
let musicPairingEligible = vm.notchState == .closed let musicPairingEligible = closedMusicPairingEligible(hasActiveMusicSnapshot: hasActiveMusicSnapshot)
&& hasActiveMusicSnapshot
&& coordinator.musicLiveActivityEnabled
&& closedMusicContentEnabled
&& !vm.hideOnClosed
&& !lockScreenManager.isLocked
let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible) let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible)
let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary) let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary)
let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID) let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID)
@ -750,6 +816,8 @@ struct ContentView: View {
.transition(AnyTransition.move(edge: .trailing).combined(with: .opacity)) .transition(AnyTransition.move(edge: .trailing).combined(with: .opacity))
} else if canShowMusicDuringExpansion && musicPairingEligible { } else if canShowMusicDuringExpansion && musicPairingEligible {
MusicLiveActivity(secondary: musicSecondary) 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 { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed {
TimerLiveActivity() TimerLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed { } 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() DoNotDisturbLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed {
LockScreenLiveActivity() 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 { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed {
PrivacyLiveActivity() PrivacyLiveActivity()
} else if let extensionPayload = extensionStandalonePayload { } else if let extensionPayload = extensionStandalonePayload {
@ -1436,6 +1506,15 @@ struct ContentView: View {
return nil 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 { guard coordinator.musicLiveActivityEnabled else {
ExtensionRoutingDiagnostics.shared.logSuppression( ExtensionRoutingDiagnostics.shared.logSuppression(
.music, .music,
@ -1647,6 +1726,7 @@ struct ContentView: View {
&& closedMusicContentEnabled && closedMusicContentEnabled
&& !vm.hideOnClosed && !vm.hideOnClosed
&& !lockScreenManager.isLocked && !lockScreenManager.isLocked
&& !isMusicHUDDeferredAfterUnlock
&& !coordinator.expandingView.show && !coordinator.expandingView.show
&& (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil) && (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil)
&& !coordinator.firstLaunch && !coordinator.firstLaunch
@ -2185,7 +2265,7 @@ struct ContentView: View {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
if vm.notchState == .closed && !lockScreenManager.isLocked { if vm.notchState == .closed && !lockScreenManager.isLocked && !isMusicHUDDeferredAfterUnlock {
isMusicControlWindowSuppressed = false isMusicControlWindowSuppressed = false
triggerPendingMusicControlSyncIfNeeded() triggerPendingMusicControlSyncIfNeeded()
} else { } else {
@ -2208,7 +2288,10 @@ struct ContentView: View {
} }
private func shouldDeferMusicControlSync() -> Bool { 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) { private func enqueueMusicControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0) {
@ -2232,6 +2315,7 @@ struct ContentView: View {
vm.notchState == .closed, vm.notchState == .closed,
!vm.hideOnClosed, !vm.hideOnClosed,
!lockScreenManager.isLocked, !lockScreenManager.isLocked,
!isMusicHUDDeferredAfterUnlock,
!isMusicControlWindowSuppressed else { !isMusicControlWindowSuppressed else {
return false return false
} }
@ -2250,7 +2334,7 @@ struct ContentView: View {
return return
} }
if !bypassSuppression && (isMusicControlWindowSuppressed || lockScreenManager.isLocked) { if !bypassSuppression && (isMusicControlWindowSuppressed || lockScreenManager.isLocked || isMusicHUDDeferredAfterUnlock) {
hasPendingMusicControlSync = true hasPendingMusicControlSync = true
if forceRefresh { if forceRefresh {
pendingMusicControlForceRefresh = true pendingMusicControlForceRefresh = true

View file

@ -43408,6 +43408,9 @@
} }
} }
} }
},
"Use enhanced liquid border" : {
}, },
"Use MacBook icon when on battery" : { "Use MacBook icon when on battery" : {
"localizations" : { "localizations" : {

View file

@ -42,18 +42,42 @@ struct LockScreenLiveActivity: View {
private var indicatorDimension: CGFloat { private var indicatorDimension: CGFloat {
max(0, vm.effectiveClosedNotchHeight - 12) 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 { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
// Left - Lock icon with subtle glow // Left - Lock icon with subtle glow
Color.clear Color.clear
.overlay(alignment: .leading) { .overlay(alignment: .leading) {
if isExpanded { LockIconProgressView(progress: iconAnimator.progress, iconColor: iconColor)
LockIconProgressView(progress: iconAnimator.progress, iconColor: iconColor) .frame(width: indicatorDimension, height: indicatorDimension)
.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 // Center - Black fill
Rectangle() Rectangle()
@ -62,7 +86,7 @@ struct LockScreenLiveActivity: View {
// Right - Empty for symmetry with animation // Right - Empty for symmetry with animation
Color.clear 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)) .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0))
.onAppear { .onAppear {
@ -100,6 +124,5 @@ struct LockScreenLiveActivity: View {
} }
} }
.animation(.spring(response: 0.5, dampingFraction: 0.85), value: lockScreenManager.isLocked) .animation(.spring(response: 0.5, dampingFraction: 0.85), value: lockScreenManager.isLocked)
.animation(.easeOut(duration: 0.25), value: isExpanded)
} }
} }

View file

@ -56,6 +56,7 @@ struct LockScreenMusicPanel: View {
@Default(.lockScreenMusicLiquidGlassVariant) private var musicGlassVariant @Default(.lockScreenMusicLiquidGlassVariant) private var musicGlassVariant
@Default(.lockScreenShowAppIcon) var showAppIcon @Default(.lockScreenShowAppIcon) var showAppIcon
@Default(.lockScreenPanelShowsBorder) var showPanelBorder @Default(.lockScreenPanelShowsBorder) var showPanelBorder
@Default(.lockScreenMusicUsesEnhancedLiquidBorder) private var useEnhancedLiquidBorder
@Default(.lockScreenPanelUsesBlur) var enableBlur @Default(.lockScreenPanelUsesBlur) var enableBlur
@Default(.showMediaOutputControl) private var showMediaOutputControl @Default(.showMediaOutputControl) private var showMediaOutputControl
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
@ -113,6 +114,10 @@ struct LockScreenMusicPanel: View {
private var usesLiquidGlass: Bool { private var usesLiquidGlass: Bool {
usesCustomLiquidGlass || usesStandardLiquidGlass usesCustomLiquidGlass || usesStandardLiquidGlass
} }
private var usesEnhancedCustomLiquidBorder: Bool {
usesCustomLiquidGlass && useEnhancedLiquidBorder
}
var body: some View { var body: some View {
if isActive && musicManager.hasActiveSession { if isActive && musicManager.hasActiveSession {
@ -131,9 +136,8 @@ struct LockScreenMusicPanel: View {
.frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading) .frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading)
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
.overlay { .overlay {
if showPanelBorder && !usesLiquidGlass { if showPanelBorder {
RoundedRectangle(cornerRadius: panelCornerRadius) panelBorderOverlay
.stroke(Color.white.opacity(0.35), lineWidth: 1.4)
} }
} }
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) .shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10)
@ -1077,6 +1081,53 @@ struct LockScreenMusicPanel: View {
.accessibilityHidden(true) .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 { private func albumArtImage(size: CGFloat, cornerRadius: CGFloat) -> some View {
Image(nsImage: musicManager.albumArt) Image(nsImage: musicManager.albumArt)
.resizable() .resizable()

View file

@ -2670,6 +2670,12 @@ struct Media: View {
Text("Show panel border") Text("Show panel border")
} }
.disabled(!enableLockScreenMediaWidget) .disabled(!enableLockScreenMediaWidget)
if lockScreenGlassCustomizationMode == .customLiquid {
Defaults.Toggle(key: .lockScreenMusicUsesEnhancedLiquidBorder) {
Text("Use enhanced liquid border")
}
.disabled(!enableLockScreenMediaWidget)
}
if lockScreenGlassCustomizationMode == .customLiquid { if lockScreenGlassCustomizationMode == .customLiquid {
customLiquidBlurRow customLiquidBlurRow
.opacity(enableLockScreenMediaWidget ? 1 : 0.5) .opacity(enableLockScreenMediaWidget ? 1 : 0.5)
@ -4772,6 +4778,13 @@ struct LockScreenSettings: View {
} }
.disabled(!enableLockScreenMediaWidget) .disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Show panel border")) .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 { if lockScreenGlassCustomizationMode == .customLiquid {
variantSlider( variantSlider(
title: "Music panel variant", title: "Music panel variant",

View file

@ -26,6 +26,8 @@ import AVFoundation
enum LockScreenAnimationTimings { enum LockScreenAnimationTimings {
static let lockExpand: TimeInterval = 0.45 static let lockExpand: TimeInterval = 0.45
static let unlockCollapse: TimeInterval = 0.82 static let unlockCollapse: TimeInterval = 0.82
static let postUnlockMusicHUDPause: TimeInterval = 1.0
static let postUnlockMusicHUDReveal: TimeInterval = 0.34
} }
@MainActor @MainActor
@ -39,11 +41,13 @@ class LockScreenManager: ObservableObject {
// MARK: - Published Properties // MARK: - Published Properties
@Published var isLocked: Bool = false @Published var isLocked: Bool = false
@Published var isLockIdle: Bool = true @Published var isLockIdle: Bool = true
@Published var shouldDelayPostUnlockMusicHUD: Bool = false
@Published var lastUpdated: Date = .distantPast @Published var lastUpdated: Date = .distantPast
// MARK: - Private Properties // MARK: - Private Properties
private var debounceIdleTask: Task<Void, Never>? private var debounceIdleTask: Task<Void, Never>?
private var collapseTask: Task<Void, Never>? private var collapseTask: Task<Void, Never>?
private var postUnlockMusicHUDTask: Task<Void, Never>?
// MARK: - Helpers // MARK: - Helpers
@ -63,6 +67,7 @@ class LockScreenManager: ObservableObject {
DistributedNotificationCenter.default().removeObserver(self) DistributedNotificationCenter.default().removeObserver(self)
debounceIdleTask?.cancel() debounceIdleTask?.cancel()
collapseTask?.cancel() collapseTask?.cancel()
postUnlockMusicHUDTask?.cancel()
} }
// MARK: - Setup // MARK: - Setup
@ -102,6 +107,8 @@ class LockScreenManager: ObservableObject {
// Update state SYNCHRONOUSLY without Task/await to avoid any delay // Update state SYNCHRONOUSLY without Task/await to avoid any delay
lastUpdated = Date() lastUpdated = Date()
updateIdleState(locked: true) updateIdleState(locked: true)
postUnlockMusicHUDTask?.cancel()
shouldDelayPostUnlockMusicHUD = false
// Set locked state immediately without animation wrapper // Set locked state immediately without animation wrapper
isLocked = true isLocked = true
@ -149,6 +156,33 @@ class LockScreenManager: ObservableObject {
lastUpdated = Date() lastUpdated = Date()
updateIdleState(locked: false) updateIdleState(locked: false)
isLocked = 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 // Hide panel window immediately and synchronously
print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window")
@ -182,8 +216,9 @@ class LockScreenManager: ObservableObject {
} else { } else {
debounceIdleTask?.cancel() debounceIdleTask?.cancel()
debounceIdleTask = Task { [weak self] in debounceIdleTask = Task { [weak self] in
let configuredInterval = max(Defaults[.waitInterval], 0) // Keep the lock live activity mounted until the collapse animation finishes,
let idleDelay = min(max(configuredInterval, 0.2), LockScreenAnimationTimings.unlockCollapse) // otherwise the content disappears before the island fully closes.
let idleDelay = LockScreenAnimationTimings.unlockCollapse
try? await Task.sleep(for: .seconds(idleDelay)) try? await Task.sleep(for: .seconds(idleDelay))
guard let self = self, !Task.isCancelled else { return } guard let self = self, !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {

View file

@ -904,6 +904,10 @@ extension Defaults.Keys {
) )
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false) static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: false) static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: false)
static let lockScreenMusicUsesEnhancedLiquidBorder = Key<Bool>(
"lockScreenMusicUsesEnhancedLiquidBorder",
default: true
)
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true) static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
static let lockScreenMusicMergedAirPlayOutput = Key<Bool>("lockScreenMusicMergedAirPlayOutput", default: true) static let lockScreenMusicMergedAirPlayOutput = Key<Bool>("lockScreenMusicMergedAirPlayOutput", default: true)
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false) static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false)