Merge pull request #111 from Ebullioscopic/feature/lock-screen

LIVE Indicator and Animations for Lock Screen music Player
This commit is contained in:
Hariharan Mudaliar 2025-11-20 00:35:21 +05:30 • committed by GitHub
commit e3a6b6167b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 267 additions and 81 deletions

View file

@ -412,6 +412,7 @@
INFOPLIST_KEY_CFBundleDisplayName = Atoll; INFOPLIST_KEY_CFBundleDisplayName = Atoll;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_MetalCaptureEnabled = YES;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music"; INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
@ -479,6 +480,7 @@
INFOPLIST_KEY_CFBundleDisplayName = Atoll; INFOPLIST_KEY_CFBundleDisplayName = Atoll;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_MetalCaptureEnabled = YES;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music"; INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";

View file

@ -15,6 +15,7 @@ struct LockScreenMusicPanel: View {
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@ObservedObject private var routeManager = AudioRouteManager.shared @ObservedObject private var routeManager = AudioRouteManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel() @StateObject private var volumeModel = MediaOutputVolumeViewModel()
@ObservedObject private var animator: LockScreenPanelAnimator
@State private var sliderValue: Double = 0 @State private var sliderValue: Double = 0
@State private var dragging: Bool = false @State private var dragging: Bool = false
@State private var isActive = true @State private var isActive = true
@ -26,6 +27,14 @@ struct LockScreenMusicPanel: View {
@Default(.lockScreenPanelShowsBorder) var showPanelBorder @Default(.lockScreenPanelShowsBorder) var showPanelBorder
@Default(.lockScreenPanelUsesBlur) var enableBlur @Default(.lockScreenPanelUsesBlur) var enableBlur
@Default(.showMediaOutputControl) var showMediaOutputControl @Default(.showMediaOutputControl) var showMediaOutputControl
@Default(.showShuffleAndRepeat) var showShuffleAndRepeat
@Default(.musicAuxLeftControl) private var leftAuxControl
@Default(.musicAuxRightControl) private var rightAuxControl
@Default(.enableLyrics) private var enableLyrics
init(animator: LockScreenPanelAnimator) {
_animator = ObservedObject(wrappedValue: animator)
}
private let collapsedPanelCornerRadius: CGFloat = 28 private let collapsedPanelCornerRadius: CGFloat = 28
private let expandedPanelCornerRadius: CGFloat = 52 private let expandedPanelCornerRadius: CGFloat = 52
@ -35,10 +44,12 @@ struct LockScreenMusicPanel: View {
private let collapseTimeout: TimeInterval = 5 private let collapseTimeout: TimeInterval = 5
private let collapsedSliderExtraHeight: CGFloat = 72 private let collapsedSliderExtraHeight: CGFloat = 72
private let expandedSliderExtraHeight: CGFloat = 88 private let expandedSliderExtraHeight: CGFloat = 88
private let collapsedLyricsExtraHeight: CGFloat = 64
private let expandedLyricsExtraHeight: CGFloat = 96
private var currentSize: CGSize { private var currentSize: CGSize {
let base = isExpanded ? Self.expandedSize : Self.collapsedSize let base = isExpanded ? Self.expandedSize : Self.collapsedSize
return CGSize(width: base.width, height: base.height + sliderExtraHeight) return CGSize(width: base.width, height: base.height + totalExtraHeight)
} }
private var panelCornerRadius: CGFloat { private var panelCornerRadius: CGFloat {
@ -70,11 +81,7 @@ struct LockScreenMusicPanel: View {
sliderValue = musicManager.elapsedTime sliderValue = musicManager.elapsedTime
isActive = true isActive = true
logPanelAppearance() logPanelAppearance()
LockScreenPanelManager.shared.updatePanelSize( updatePanelSize(animated: false)
expanded: isExpanded,
additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider),
animated: false
)
routeManager.refreshDevices() routeManager.refreshDevices()
} }
.onDisappear { .onDisappear {
@ -83,10 +90,7 @@ struct LockScreenMusicPanel: View {
isVolumeSliderVisible = false isVolumeSliderVisible = false
} }
.onChange(of: isExpanded) { _, expanded in .onChange(of: isExpanded) { _, expanded in
LockScreenPanelManager.shared.updatePanelSize( updatePanelSize()
expanded: expanded,
additionalHeight: sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider)
)
} }
.onChange(of: showMediaOutputControl) { _, enabled in .onChange(of: showMediaOutputControl) { _, enabled in
if !enabled { if !enabled {
@ -94,11 +98,16 @@ struct LockScreenMusicPanel: View {
isVolumeSliderVisible = false isVolumeSliderVisible = false
} }
} }
LockScreenPanelManager.shared.updatePanelSize( updatePanelSize()
expanded: isExpanded,
additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
)
} }
.onChange(of: enableLyrics) { _, _ in
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
updatePanelSize()
}
}
.scaleEffect(animator.isPresented ? 1 : 0.9, anchor: .center)
.opacity(animator.isPresented ? 1 : 0)
.animation(.spring(response: 0.52, dampingFraction: 0.8), value: animator.isPresented)
} }
@ViewBuilder @ViewBuilder
@ -272,56 +281,79 @@ struct LockScreenMusicPanel: View {
// MARK: - Progress Bar // MARK: - Progress Bar
private var progressBar: some View { private var progressBar: some View {
TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in TimelineView(
let currentElapsed = currentSliderValue(timeline.date) .animation(
minimumInterval: (!musicManager.isLiveStream && musicManager.playbackRate > 0) ? 0.1 : nil
)
) { timeline in
let progressValue = dragging ? sliderValue : currentSliderValue(timeline.date)
HStack(spacing: 8) { if musicManager.isLiveStream {
// Elapsed time LiveStreamProgressIndicator(tint: sliderColor)
Text(formatTime(dragging ? sliderValue : currentElapsed)) .frame(maxWidth: .infinity)
.font(.system(size: 11, weight: .medium, design: .monospaced)) } else {
.foregroundColor(.white.opacity(0.7)) HStack(spacing: 8) {
.frame(width: 42, alignment: .leading) // Elapsed time
Text(formatTime(progressValue))
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.frame(width: 42, alignment: .leading)
// Progress bar // Progress bar
GeometryReader { geometry in GeometryReader { geometry in
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
// Background track // Background track
RoundedRectangle(cornerRadius: 3) RoundedRectangle(cornerRadius: 3)
.fill(Color.white.opacity(0.2)) .fill(Color.white.opacity(0.2))
.frame(height: 6) .frame(height: 6)
// Filled portion // Filled portion
RoundedRectangle(cornerRadius: 3) RoundedRectangle(cornerRadius: 3)
.fill(sliderColor) .fill(sliderColor)
.frame(width: max(0, geometry.size.width * (currentSliderValue(timeline.date) / max(musicManager.songDuration, 1))), height: 6) .frame(
width: max(
0,
geometry.size.width * (progressValue / max(musicManager.songDuration, 1))
),
height: 6
)
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
registerInteraction()
dragging = true
let duration = max(musicManager.songDuration, 0.0001)
let clampedX = min(max(value.location.x, 0), geometry.size.width)
let newValue = Double(clampedX / geometry.size.width) * duration
sliderValue = min(max(0, newValue), musicManager.songDuration)
}
.onEnded { _ in
registerInteraction()
musicManager.seek(to: sliderValue)
dragging = false
}
)
} }
.gesture( .frame(height: 6)
DragGesture(minimumDistance: 0)
.onChanged { value in
registerInteraction()
dragging = true
let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration)
sliderValue = newValue
}
.onEnded { _ in
registerInteraction()
musicManager.seek(to: sliderValue)
dragging = false
}
)
}
.frame(height: 6)
// Time remaining // Time remaining
Text("-\(formatTime(musicManager.songDuration - (dragging ? sliderValue : currentElapsed)))") Text("-\(formatTime(max(musicManager.songDuration - progressValue, 0)))")
.font(.system(size: 11, weight: .medium, design: .monospaced)) .font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.7)) .foregroundColor(.white.opacity(0.7))
.frame(width: 48, alignment: .trailing) .frame(width: 48, alignment: .trailing)
}
} }
} }
.onAppear { .onAppear {
sliderValue = musicManager.elapsedTime sliderValue = musicManager.elapsedTime
} }
.onChange(of: musicManager.isLiveStream) { _, isLive in
if isLive {
dragging = false
sliderValue = 0
}
}
} }
private func currentSliderValue(_ date: Date) -> Double { private func currentSliderValue(_ date: Date) -> Double {
@ -329,6 +361,10 @@ struct LockScreenMusicPanel: View {
return sliderValue return sliderValue
} }
if musicManager.isLiveStream {
return 0
}
if musicManager.isPlaying { if musicManager.isPlaying {
let timeSinceLastUpdate = date.timeIntervalSince(musicManager.timestampDate) let timeSinceLastUpdate = date.timeIntervalSince(musicManager.timestampDate)
let estimatedElapsed = musicManager.elapsedTime + (timeSinceLastUpdate * musicManager.playbackRate) let estimatedElapsed = musicManager.elapsedTime + (timeSinceLastUpdate * musicManager.playbackRate)
@ -359,8 +395,9 @@ struct LockScreenMusicPanel: View {
private func playbackControls(alignment: Alignment) -> some View { private func playbackControls(alignment: Alignment) -> some View {
let spacing: CGFloat = isExpanded ? 24 : 20 let spacing: CGFloat = isExpanded ? 24 : 20
let verticalSpacing: CGFloat = (shouldShowVolumeSlider || enableLyrics) ? 14 : 10
return VStack(spacing: shouldShowVolumeSlider ? 14 : 10) { return VStack(spacing: verticalSpacing) {
controlsRow(alignment: alignment, spacing: spacing) controlsRow(alignment: alignment, spacing: spacing)
if shouldShowVolumeSlider { if shouldShowVolumeSlider {
@ -368,16 +405,24 @@ struct LockScreenMusicPanel: View {
.frame(maxWidth: .infinity, alignment: alignment) .frame(maxWidth: .infinity, alignment: alignment)
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity)) .transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
} }
if enableLyrics {
lyricsSection(alignment: alignment)
.transition(.move(edge: .top).combined(with: .opacity))
}
} }
.frame(maxWidth: .infinity, alignment: alignment) .frame(maxWidth: .infinity, alignment: alignment)
.padding(.top, isExpanded ? 6 : 2) .padding(.top, isExpanded ? 6 : 2)
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider) .animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider)
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: enableLyrics)
} }
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View { private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
HStack(spacing: spacing) { let controls = resolvedAuxControls
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
musicManager.toggleShuffle() return HStack(spacing: spacing) {
if let leftControl = controls.left {
auxButton(for: leftControl)
} }
controlButton(icon: "backward.fill", size: 18) { controlButton(icon: "backward.fill", size: 18) {
@ -390,12 +435,8 @@ struct LockScreenMusicPanel: View {
musicManager.nextTrack() musicManager.nextTrack()
} }
if showMediaOutputControl { if let rightControl = controls.right {
mediaOutputControlButton auxButton(for: rightControl)
} else {
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
musicManager.toggleRepeat()
}
} }
} }
.frame(maxWidth: .infinity, alignment: alignment) .frame(maxWidth: .infinity, alignment: alignment)
@ -418,7 +459,13 @@ struct LockScreenMusicPanel: View {
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
} }
private func controlButton(icon: String, size: CGFloat = 18, isActive: Bool = false, action: @escaping () -> Void) -> some View { private func controlButton(
icon: String,
size: CGFloat = 18,
isActive: Bool = false,
activeColor: Color = .red,
action: @escaping () -> Void
) -> some View {
let frameSize: CGFloat = isExpanded ? 56 : 32 let frameSize: CGFloat = isExpanded ? 56 : 32
let iconSize: CGFloat = isExpanded ? max(size, 24) : size let iconSize: CGFloat = isExpanded ? max(size, 24) : size
@ -428,7 +475,7 @@ struct LockScreenMusicPanel: View {
}) { }) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: iconSize, weight: .medium)) .font(.system(size: iconSize, weight: .medium))
.foregroundColor(isActive ? .red : .white.opacity(0.8)) .foregroundColor(isActive ? activeColor : .white.opacity(0.8))
.frame(width: frameSize, height: frameSize) .frame(width: frameSize, height: frameSize)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@ -450,6 +497,93 @@ struct LockScreenMusicPanel: View {
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
} }
private func lyricsSection(alignment: Alignment) -> some View {
let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines)
let transition: AnyTransition = .asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .move(edge: .top).combined(with: .opacity)
)
return HStack(spacing: 8) {
if !line.isEmpty {
Image(systemName: "music.note")
.font(.system(size: isExpanded ? 14 : 12, weight: .semibold))
.foregroundColor(.white.opacity(0.7))
.symbolRenderingMode(.monochrome)
Text(line)
.font(.system(size: isExpanded ? 14 : 12, weight: .semibold))
.foregroundColor(.white.opacity(0.88))
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 6)
.id(line)
.transition(transition)
}
}
.padding(.horizontal, isExpanded ? 10 : 8)
.padding(.top, isExpanded ? 12 : 8)
.frame(maxWidth: .infinity, alignment: alignment)
.animation(.smooth(duration: 0.32), value: line)
}
private var availableAuxControls: [MusicAuxiliaryControl] {
MusicAuxiliaryControl.allCases.filter { control in
control != .mediaOutput || showMediaOutputControl
}
}
private var resolvedAuxControls: (left: MusicAuxiliaryControl?, right: MusicAuxiliaryControl?) {
guard showShuffleAndRepeat else { return (nil, nil) }
let options = availableAuxControls
guard !options.isEmpty else { return (nil, nil) }
let left = options.contains(leftAuxControl) ? leftAuxControl : options.first!
var rightCandidate = options.contains(rightAuxControl) ? rightAuxControl : options.first!
if options.count == 1 {
return (left, nil)
}
if rightCandidate == left, let alternative = options.first(where: { $0 != left }) {
rightCandidate = alternative
}
if rightCandidate == left {
return (left, nil)
}
return (left, rightCandidate)
}
@ViewBuilder
private func auxButton(for control: MusicAuxiliaryControl) -> some View {
switch control {
case .shuffle:
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
musicManager.toggleShuffle()
}
case .repeatMode:
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
musicManager.toggleRepeat()
}
case .mediaOutput:
mediaOutputControlButton
case .lyrics:
controlButton(
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
isActive: enableLyrics,
activeColor: .accentColor
) {
withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) {
enableLyrics.toggle()
}
}
}
}
private var repeatIcon: String { private var repeatIcon: String {
switch musicManager.repeatMode { switch musicManager.repeatMode {
case .off: return "repeat" case .off: return "repeat"
@ -501,6 +635,32 @@ struct LockScreenMusicPanel: View {
sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider) sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
} }
private var lyricsExtraHeight: CGFloat {
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics)
}
private var totalExtraHeight: CGFloat {
sliderExtraHeight + lyricsExtraHeight
}
private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat {
guard enabled else { return 0 }
return expanded ? expandedLyricsExtraHeight : collapsedLyricsExtraHeight
}
private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat {
sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) +
lyricsHeight(forExpanded: expanded, enabled: enableLyrics)
}
private func updatePanelSize(animated: Bool = true) {
LockScreenPanelManager.shared.updatePanelSize(
expanded: isExpanded,
additionalHeight: panelAdditionalHeight(forExpanded: isExpanded),
animated: animated
)
}
private var volumeIconName: String { private var volumeIconName: String {
if volumeModel.isMuted || volumeModel.level <= 0.001 { if volumeModel.isMuted || volumeModel.level <= 0.001 {
return "speaker.slash.fill" return "speaker.slash.fill"
@ -517,6 +677,11 @@ struct LockScreenMusicPanel: View {
} }
private func toggleVolumeSlider() { private func toggleVolumeSlider() {
guard showMediaOutputControl else {
isVolumeSliderVisible = false
return
}
registerInteraction() registerInteraction()
let newState = !isVolumeSliderVisible let newState = !isVolumeSliderVisible
if newState { if newState {
@ -527,11 +692,7 @@ struct LockScreenMusicPanel: View {
isVolumeSliderVisible = newState isVolumeSliderVisible = newState
} }
let targetHeight = sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider) updatePanelSize()
LockScreenPanelManager.shared.updatePanelSize(
expanded: isExpanded,
additionalHeight: targetHeight
)
} }
private func sliderHeight(forExpanded expanded: Bool, visible: Bool) -> CGFloat { private func sliderHeight(forExpanded expanded: Bool, visible: Bool) -> CGFloat {

View file

@ -11,6 +11,11 @@ import SkyLightWindow
import Defaults import Defaults
import QuartzCore import QuartzCore
@MainActor
final class LockScreenPanelAnimator: ObservableObject {
@Published var isPresented: Bool = false
}
@MainActor @MainActor
class LockScreenPanelManager { class LockScreenPanelManager {
static let shared = LockScreenPanelManager() static let shared = LockScreenPanelManager()
@ -21,6 +26,8 @@ class LockScreenPanelManager {
private let collapsedPanelCornerRadius: CGFloat = 28 private let collapsedPanelCornerRadius: CGFloat = 28
private let expandedPanelCornerRadius: CGFloat = 52 private let expandedPanelCornerRadius: CGFloat = 52
private(set) var latestFrame: NSRect? private(set) var latestFrame: NSRect?
private let panelAnimator = LockScreenPanelAnimator()
private var hideTask: Task<Void, Never>?
private init() { private init() {
print("[\(timestamp())] LockScreenPanelManager: initialized") print("[\(timestamp())] LockScreenPanelManager: initialized")
@ -81,7 +88,10 @@ class LockScreenPanelManager {
window.setFrame(targetFrame, display: true) window.setFrame(targetFrame, display: true)
latestFrame = targetFrame latestFrame = targetFrame
let hosting = NSHostingView(rootView: LockScreenMusicPanel()) hideTask?.cancel()
panelAnimator.isPresented = false
let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator))
hosting.frame = NSRect(origin: .zero, size: targetFrame.size) hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
window.contentView = hosting window.contentView = hosting
@ -100,6 +110,11 @@ class LockScreenPanelManager {
// Keep the window alive and simply order it out on unlock to avoid SkyLight crashes. // Keep the window alive and simply order it out on unlock to avoid SkyLight crashes.
window.orderFrontRegardless() window.orderFrontRegardless()
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.panelAnimator.isPresented = true
}
print("[\(timestamp())] LockScreenPanelManager: panel visible") print("[\(timestamp())] LockScreenPanelManager: panel visible")
} }
@ -142,16 +157,24 @@ class LockScreenPanelManager {
func hidePanel() { func hidePanel() {
print("[\(timestamp())] LockScreenPanelManager: hidePanel") print("[\(timestamp())] LockScreenPanelManager: hidePanel")
panelAnimator.isPresented = false
hideTask?.cancel()
guard let window = panelWindow else { guard let window = panelWindow else {
print("LockScreenPanelManager: no panel to hide") print("LockScreenPanelManager: no panel to hide")
latestFrame = nil
return return
} }
window.orderOut(nil) hideTask = Task { [weak self, weak window] in
window.contentView = nil try? await Task.sleep(for: .milliseconds(360))
guard let self else { return }
latestFrame = nil await MainActor.run {
window?.orderOut(nil)
print("[\(timestamp())] LockScreenPanelManager: panel hidden") window?.contentView = nil
self.latestFrame = nil
print("[\(self.timestamp())] LockScreenPanelManager: panel hidden")
}
}
} }
} }