mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 23:24:32 +00:00
The Spotify Canvas fallback layout previously centered the panel+artwork group on screenFrame.midY, so the enlarged artwork tile could push up into the row of circular widgets (battery, audio, AQI, weather). Clamp maxCenterY against the bottom edge of both LockScreenWeatherPanelManager.latestFrame and LockScreenTimerWidgetPanelManager.latestFrame, with a 20pt clearance, and guard against the clamp inverting via max(maxCenterY, minCenterY). The panel reflows through the existing .atollLockScreenPanelFrameDidChange notification, so the artwork overlay window follows the new panel position automatically. Signed-off-by: Jis G Jacob <studiokeys@blissroms.org>
382 lines
14 KiB
Swift
382 lines
14 KiB
Swift
/*
|
|
* Atoll (DynamicIsland)
|
|
* Copyright (C) 2024-2026 Atoll Contributors
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import SwiftUI
|
|
import AppKit
|
|
import SkyLightWindow
|
|
import Defaults
|
|
import QuartzCore
|
|
import Combine
|
|
|
|
@MainActor
|
|
final class LockScreenPanelAnimator: ObservableObject {
|
|
@Published var isPresented: Bool = false
|
|
}
|
|
|
|
@MainActor
|
|
class LockScreenPanelManager {
|
|
static let shared = LockScreenPanelManager()
|
|
|
|
private var panelWindow: NSWindow?
|
|
private var hasDelegated = false
|
|
private var collapsedFrame: NSRect?
|
|
private var isPanelExpanded = false
|
|
private var currentAdditionalHeight: CGFloat = 0
|
|
private let collapsedPanelCornerRadius: CGFloat = 28
|
|
private let expandedPanelCornerRadius: CGFloat = 52
|
|
private(set) var latestFrame: NSRect?
|
|
private let panelAnimator = LockScreenPanelAnimator()
|
|
private var hideTask: Task<Void, Never>?
|
|
private var screenChangeObserver: NSObjectProtocol?
|
|
private var workspaceObservers: [NSObjectProtocol] = []
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
private init() {
|
|
print("[\(timestamp())] LockScreenPanelManager: initialized")
|
|
registerScreenChangeObservers()
|
|
observeDefaultChanges()
|
|
}
|
|
|
|
private func timestamp() -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "HH:mm:ss.SSS"
|
|
return formatter.string(from: Date())
|
|
}
|
|
|
|
private func publishPanelFrame(_ frame: NSRect?) {
|
|
latestFrame = frame
|
|
|
|
var userInfo: [AnyHashable: Any] = [:]
|
|
if let frame {
|
|
userInfo["frame"] = NSValue(rect: frame)
|
|
}
|
|
|
|
NotificationCenter.default.post(
|
|
name: .atollLockScreenPanelFrameDidChange,
|
|
object: self,
|
|
userInfo: userInfo
|
|
)
|
|
}
|
|
|
|
private func registerScreenChangeObservers() {
|
|
screenChangeObserver = NotificationCenter.default.addObserver(
|
|
forName: NSApplication.didChangeScreenParametersNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.handleScreenGeometryChange(reason: "screen-parameters")
|
|
}
|
|
}
|
|
|
|
let workspaceCenter = NSWorkspace.shared.notificationCenter
|
|
let wakeObserver = workspaceCenter.addObserver(
|
|
forName: NSWorkspace.screensDidWakeNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.handleScreenGeometryChange(reason: "screens-did-wake")
|
|
}
|
|
}
|
|
|
|
workspaceObservers = [wakeObserver]
|
|
}
|
|
|
|
func showPanel() {
|
|
print("[\(timestamp())] LockScreenPanelManager: showPanel")
|
|
|
|
guard Defaults[.enableLockScreenMediaWidget] else {
|
|
print("[\(timestamp())] LockScreenPanelManager: widget disabled")
|
|
hidePanel()
|
|
return
|
|
}
|
|
|
|
guard let screen = currentScreen() else {
|
|
print("[\(timestamp())] LockScreenPanelManager: no main screen available")
|
|
return
|
|
}
|
|
|
|
let screenFrame = screen.frame
|
|
let defaultCollapsedFrame = collapsedFrame(for: screenFrame)
|
|
let targetFrame = targetFrame(for: screenFrame, panelSize: LockScreenMusicPanel.collapsedSize)
|
|
collapsedFrame = defaultCollapsedFrame
|
|
isPanelExpanded = false
|
|
currentAdditionalHeight = 0
|
|
|
|
let window: NSWindow
|
|
|
|
if let existingWindow = panelWindow {
|
|
window = existingWindow
|
|
} else {
|
|
let newWindow = NSWindow(
|
|
contentRect: targetFrame,
|
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
|
|
newWindow.isReleasedWhenClosed = false
|
|
newWindow.isOpaque = false
|
|
newWindow.backgroundColor = .clear
|
|
newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
|
|
newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
|
newWindow.isMovable = false
|
|
newWindow.hasShadow = false
|
|
|
|
ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface)
|
|
|
|
panelWindow = newWindow
|
|
window = newWindow
|
|
hasDelegated = false
|
|
}
|
|
|
|
window.setFrame(targetFrame, display: true)
|
|
publishPanelFrame(targetFrame)
|
|
hideTask?.cancel()
|
|
panelAnimator.isPresented = false
|
|
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false)
|
|
|
|
let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator))
|
|
hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
|
|
hosting.autoresizingMask = [.width, .height]
|
|
window.contentView = hosting
|
|
|
|
// Ensure the underlying window content is clipped to rounded corners
|
|
if let content = window.contentView {
|
|
content.wantsLayer = true
|
|
content.layer?.masksToBounds = true
|
|
content.layer?.cornerRadius = collapsedPanelCornerRadius
|
|
content.layer?.backgroundColor = NSColor.clear.cgColor
|
|
}
|
|
|
|
if !hasDelegated {
|
|
SkyLightOperator.shared.delegateWindow(window)
|
|
hasDelegated = true
|
|
}
|
|
|
|
// Keep the window alive and simply order it out on unlock to avoid SkyLight crashes.
|
|
window.orderFrontRegardless()
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.panelAnimator.isPresented = true
|
|
}
|
|
|
|
print("[\(timestamp())] LockScreenPanelManager: panel visible")
|
|
}
|
|
|
|
func updatePanelSize(expanded: Bool, additionalHeight: CGFloat = 0, animated: Bool = true) {
|
|
guard let window = panelWindow, let screen = currentScreen() else {
|
|
return
|
|
}
|
|
|
|
let resizeDuration: CFTimeInterval = 0.28
|
|
|
|
let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
|
|
let targetFrame = targetFrame(
|
|
for: screen.frame,
|
|
panelSize: CGSize(width: baseSize.width, height: baseSize.height + additionalHeight)
|
|
)
|
|
|
|
if animated {
|
|
NSAnimationContext.runAnimationGroup { context in
|
|
context.duration = resizeDuration
|
|
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
window.animator().setFrame(targetFrame, display: true)
|
|
}
|
|
} else {
|
|
window.setFrame(targetFrame, display: true)
|
|
}
|
|
|
|
publishPanelFrame(targetFrame)
|
|
|
|
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated)
|
|
|
|
// Update corner radius to match the SwiftUI panel's style
|
|
let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
|
|
if animated {
|
|
CATransaction.begin()
|
|
CATransaction.setAnimationDuration(resizeDuration)
|
|
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut))
|
|
window.contentView?.layer?.cornerRadius = targetRadius
|
|
CATransaction.commit()
|
|
} else {
|
|
window.contentView?.layer?.cornerRadius = targetRadius
|
|
}
|
|
|
|
isPanelExpanded = expanded
|
|
currentAdditionalHeight = additionalHeight
|
|
}
|
|
|
|
func notifyTimerWidgetFrameChanged(animated: Bool) {
|
|
guard panelWindow?.isVisible == true || panelAnimator.isPresented else { return }
|
|
applyOffsetAdjustment(animated: animated)
|
|
}
|
|
|
|
func applyOffsetAdjustment(animated: Bool = true) {
|
|
guard let screen = currentScreen() else { return }
|
|
let screenFrame = screen.frame
|
|
let newCollapsed = collapsedFrame(for: screenFrame)
|
|
collapsedFrame = newCollapsed
|
|
|
|
guard panelWindow != nil else { return }
|
|
updatePanelSize(expanded: isPanelExpanded, additionalHeight: currentAdditionalHeight, animated: animated)
|
|
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated)
|
|
}
|
|
|
|
func hidePanel() {
|
|
print("[\(timestamp())] LockScreenPanelManager: hidePanel")
|
|
|
|
panelAnimator.isPresented = false
|
|
hideTask?.cancel()
|
|
|
|
guard let window = panelWindow else {
|
|
print("LockScreenPanelManager: no panel to hide")
|
|
publishPanelFrame(nil)
|
|
return
|
|
}
|
|
|
|
hideTask = Task { [weak self, weak window] in
|
|
try? await Task.sleep(for: .milliseconds(360))
|
|
guard let self else { return }
|
|
await MainActor.run {
|
|
window?.orderOut(nil)
|
|
window?.contentView = nil
|
|
self.publishPanelFrame(nil)
|
|
print("[\(self.timestamp())] LockScreenPanelManager: panel hidden")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleScreenGeometryChange(reason: String) {
|
|
guard let window = panelWindow else { return }
|
|
guard window.isVisible || panelAnimator.isPresented else { return }
|
|
guard let screen = currentScreen() else { return }
|
|
|
|
let screenFrame = screen.frame
|
|
collapsedFrame = collapsedFrame(for: screenFrame)
|
|
updatePanelSize(expanded: isPanelExpanded, additionalHeight: currentAdditionalHeight, animated: false)
|
|
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false)
|
|
|
|
print("[\(timestamp())] LockScreenPanelManager: realigned window due to \(reason)")
|
|
}
|
|
|
|
private func observeDefaultChanges() {
|
|
Defaults.publisher(.lockScreenMusicPanelWidth)
|
|
.receive(on: RunLoop.main)
|
|
.sink { [weak self] _ in
|
|
self?.applyOffsetAdjustment(animated: true)
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
private func targetFrame(for screenFrame: NSRect, panelSize: CGSize) -> NSRect {
|
|
let defaultCollapsedFrame = collapsedFrame(for: screenFrame)
|
|
|
|
if FullScreenArtworkWindowManager.shared.isShowingSpotifyCanvasFallback {
|
|
return spotifyCanvasFallbackPanelFrame(
|
|
screenFrame: screenFrame,
|
|
defaultCollapsedFrame: defaultCollapsedFrame,
|
|
panelSize: panelSize
|
|
)
|
|
}
|
|
|
|
let originX = defaultCollapsedFrame.midX - (panelSize.width / 2)
|
|
return NSRect(
|
|
x: originX,
|
|
y: defaultCollapsedFrame.origin.y,
|
|
width: panelSize.width,
|
|
height: panelSize.height
|
|
)
|
|
}
|
|
|
|
private func spotifyCanvasFallbackPanelFrame(
|
|
screenFrame: NSRect,
|
|
defaultCollapsedFrame: NSRect,
|
|
panelSize: CGSize
|
|
) -> NSRect {
|
|
let fullscreenArtworkManager = FullScreenArtworkWindowManager.shared
|
|
let spacing = fullscreenArtworkManager.spotifyCanvasFallbackInterItemSpacing(screenFrame: screenFrame)
|
|
let artworkSide = fullscreenArtworkManager.spotifyCanvasFallbackArtworkSideLength(
|
|
screenFrame: screenFrame,
|
|
panelSize: panelSize
|
|
)
|
|
let totalWidth = artworkSide + spacing + panelSize.width
|
|
let horizontalMargin: CGFloat = 48
|
|
let minGroupOriginX = screenFrame.minX + horizontalMargin
|
|
let maxGroupOriginX = max(screenFrame.maxX - horizontalMargin - totalWidth, minGroupOriginX)
|
|
let groupOriginX = min(
|
|
max(screenFrame.midX - (totalWidth / 2), minGroupOriginX),
|
|
maxGroupOriginX
|
|
)
|
|
|
|
let baseCenterY = screenFrame.midY
|
|
let largestHalfHeight = max(artworkSide, panelSize.height) / 2
|
|
let minCenterY = screenFrame.minY + largestHalfHeight + 56
|
|
var maxCenterY = screenFrame.maxY - largestHalfHeight - 84
|
|
|
|
let widgetClearance: CGFloat = 20
|
|
if let weatherFrame = LockScreenWeatherPanelManager.shared.latestFrame,
|
|
!weatherFrame.isEmpty {
|
|
let weatherTopConstrainedCenterY = weatherFrame.minY - widgetClearance - largestHalfHeight
|
|
maxCenterY = min(maxCenterY, weatherTopConstrainedCenterY)
|
|
}
|
|
if let timerFrame = LockScreenTimerWidgetPanelManager.shared.latestFrame,
|
|
!timerFrame.isEmpty {
|
|
let timerTopConstrainedCenterY = timerFrame.minY - widgetClearance - largestHalfHeight
|
|
maxCenterY = min(maxCenterY, timerTopConstrainedCenterY)
|
|
}
|
|
|
|
let resolvedMaxCenterY = max(maxCenterY, minCenterY)
|
|
let centerY = min(max(baseCenterY, minCenterY), resolvedMaxCenterY)
|
|
|
|
return NSRect(
|
|
x: groupOriginX + artworkSide + spacing,
|
|
y: centerY - (panelSize.height / 2),
|
|
width: panelSize.width,
|
|
height: panelSize.height
|
|
)
|
|
}
|
|
|
|
private func collapsedFrame(for screenFrame: NSRect) -> NSRect {
|
|
let collapsedSize = LockScreenMusicPanel.collapsedSize
|
|
let originX = screenFrame.midX - (collapsedSize.width / 2)
|
|
let baseOriginY = screenFrame.origin.y + (screenFrame.height / 2) - collapsedSize.height - 32
|
|
let defaultLowering: CGFloat = -28
|
|
let userOffset = CGFloat(Defaults[.lockScreenMusicVerticalOffset])
|
|
let clampedOffset = min(max(userOffset, -160), 160)
|
|
var originY = baseOriginY + defaultLowering + clampedOffset
|
|
|
|
if let timerFrame = LockScreenTimerWidgetPanelManager.shared.latestFrame {
|
|
let maxAllowedTop = timerFrame.minY - 12
|
|
let maxOriginY = maxAllowedTop - collapsedSize.height
|
|
originY = min(originY, maxOriginY)
|
|
}
|
|
|
|
return NSRect(x: originX, y: originY, width: collapsedSize.width, height: collapsedSize.height)
|
|
}
|
|
|
|
private func currentScreen() -> NSScreen? {
|
|
LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main
|
|
}
|
|
}
|
|
|
|
extension Notification.Name {
|
|
static let atollLockScreenPanelFrameDidChange = Notification.Name("atollLockScreenPanelFrameDidChange")
|
|
}
|