mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
other small improvements
This commit is contained in:
parent
f7fbb81d36
commit
ffd72456d9
7 changed files with 515 additions and 302 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -43408,6 +43408,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Use enhanced liquid border" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Use MacBook icon when on battery" : {
|
"Use MacBook icon when on battery" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue