/*
* 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 .
*/
import SwiftUI
import Defaults
struct LockScreenMusicPanel: View {
private struct GlassLogSnapshot: Equatable {
let style: LockScreenGlassStyle
let customizationMode: LockScreenGlassCustomizationMode
let variantRawValue: Int
let usesLiquidGlass: Bool
}
static let collapsedHeight: CGFloat = 180
static let defaultCollapsedWidth: CGFloat = 420
static var collapsedSize: CGSize {
CGSize(width: CGFloat(Defaults[.lockScreenMusicPanelWidth]), height: collapsedHeight)
}
static let expandedSize = CGSize(width: 720, height: 340)
@ObservedObject var musicManager = MusicManager.shared
@ObservedObject private var routeManager = AudioRouteManager.shared
@ObservedObject private var fullscreenArtworkManager = FullScreenArtworkWindowManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
@ObservedObject private var animator: LockScreenPanelAnimator
@State private var sliderValue: Double = 0
@State private var dragging: Bool = false
@State private var lastDragged: Date = .distantPast
@State private var isActive = true
@State private var isExpanded = false
@State private var isVolumeSliderVisible = false
@State private var isAirPlayPopoverPresented = false
@State private var isArtworkFullscreen = false
@State private var collapseWorkItem: DispatchWorkItem?
@State private var parallaxResumeWorkItem: DispatchWorkItem?
@State private var isParallaxSuspended = false
@State private var lastLoggedGlassSnapshot: GlassLogSnapshot?
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
@Default(.lockScreenGlassCustomizationMode) private var glassCustomizationMode
@Default(.lockScreenMusicLiquidGlassVariant) private var musicGlassVariant
@Default(.lockScreenShowAppIcon) var showAppIcon
@Default(.lockScreenPanelShowsBorder) var showPanelBorder
@Default(.lockScreenMusicUsesEnhancedLiquidBorder) private var useEnhancedLiquidBorder
@Default(.lockScreenPanelUsesBlur) var enableBlur
@Default(.showMediaOutputControl) private var showMediaOutputControl
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
@Default(.musicControlSlots) private var slotConfig
@Default(.musicSkipBehavior) private var musicSkipBehavior
@Default(.lockScreenMusicMergedAirPlayOutput) private var mergedAirPlayOutput
@Default(.enableLyrics) private var enableLyrics
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled
@Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth
@Default(.lockScreenMusicFullscreenArtworkEnabled) private var fullscreenArtworkEnabled
@Default(.lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) private var keepAlbumArtVisibleDuringFullscreenArtwork
@Default(.lockScreenMusicFullscreenVideoArtwork) private var fullscreenVideoArtwork
init(animator: LockScreenPanelAnimator) {
_animator = ObservedObject(wrappedValue: animator)
}
private let collapsedPanelCornerRadius: CGFloat = 28
private let expandedPanelCornerRadius: CGFloat = 52
private let collapsedAlbumArtCornerRadius: CGFloat = 16
private let expandedAlbumArtCornerRadius: CGFloat = 60
private let expandedContentSpacing: CGFloat = 40
private let collapseTimeout: TimeInterval = 5
private let collapsedSliderExtraHeight: CGFloat = 72
private let expandedSliderExtraHeight: CGFloat = 88
private let collapsedLyricsExtraHeight: CGFloat = 64
private let expandedLyricsExtraHeight: CGFloat = 96
private var shouldUseFrostedBlur: Bool {
enableBlur && !usesLiquidGlass
}
private var currentSize: CGSize {
let base = isExpanded ? Self.expandedSize : collapsedPanelSize
return CGSize(width: base.width, height: base.height + totalExtraHeight)
}
private var collapsedPanelSize: CGSize {
CGSize(width: CGFloat(collapsedPanelWidth), height: Self.collapsedHeight)
}
private var panelCornerRadius: CGFloat {
isExpanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
}
private var usesCustomLiquidGlass: Bool {
glassCustomizationMode == .customLiquid
}
private var usesStandardLiquidGlass: Bool {
guard glassCustomizationMode == .standard else { return false }
if #available(macOS 26.0, *) {
return lockScreenGlassStyle == .liquid
}
return false
}
private var usesLiquidGlass: Bool {
usesCustomLiquidGlass || usesStandardLiquidGlass
}
private var usesEnhancedCustomLiquidBorder: Bool {
usesCustomLiquidGlass && useEnhancedLiquidBorder
}
private var hidesInlineArtworkForSpotifyCanvasFallback: Bool {
fullscreenArtworkManager.isShowingSpotifyCanvasFallback
}
private var usesSpotifyCanvasFallbackContentPresentation: Bool {
fullscreenArtworkManager.isShowingSpotifyCanvasFallback
}
private var showsDetachedFullscreenLyrics: Bool {
usesSpotifyCanvasFallbackContentPresentation && enableLyrics
}
private var shouldShowInlineLyrics: Bool {
enableLyrics && !showsDetachedFullscreenLyrics
}
var body: some View {
if isActive && musicManager.hasActiveSession {
panelContent
} else {
Color.clear
.frame(width: collapsedPanelSize.width, height: collapsedPanelSize.height)
}
}
private var panelContent: some View {
ZStack(alignment: .topLeading) {
panelBackgroundLayer
panelForeground
}
.frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading)
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
.overlay {
if showPanelBorder && !usesSpotifyCanvasFallbackContentPresentation {
panelBorderOverlay
}
}
.shadow(
color: usesSpotifyCanvasFallbackContentPresentation ? .clear : Color.black.opacity(0.3),
radius: usesSpotifyCanvasFallbackContentPresentation ? 0 : 20,
x: 0,
y: usesSpotifyCanvasFallbackContentPresentation ? 0 : 10
)
.contentShape(Rectangle())
.animation(.easeInOut(duration: 0.28), value: isExpanded)
.animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
.onAppear {
sliderValue = musicManager.elapsedTime
isActive = true
logPanelAppearance()
updatePanelSize(animated: false)
routeManager.refreshDevices()
if isAppleMusicActive {
Task { await airPlayManager.refreshDevices() }
}
logGlassState(reason: "Panel appeared")
}
.onDisappear {
isActive = false
cancelCollapseTimer()
isVolumeSliderVisible = false
parallaxResumeWorkItem?.cancel()
parallaxResumeWorkItem = nil
isParallaxSuspended = false
}
.onReceive(NotificationCenter.default.publisher(for: .atollArtworkWallpaperDismissed)) { _ in
withAnimation(.easeInOut(duration: 0.28)) {
isArtworkFullscreen = false
}
}
.onChange(of: isExpanded) { _, expanded in
updatePanelSize()
}
.onChange(of: showMediaOutputControl) { _, enabled in
if !enabled {
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
isVolumeSliderVisible = false
}
}
updatePanelSize()
}
.onChange(of: isVolumeSliderVisible) { _, visible in
if useMergedAirPlayOutput {
if visible && isAppleMusicActive {
Task { await airPlayManager.refreshDevices() }
}
}
updatePanelSize()
}
.onChange(of: airPlayManager.devices) { _, _ in
if useMergedAirPlayOutput {
updatePanelSize()
}
}
.onChange(of: routeManager.devices) { _, _ in
if !useMergedAirPlayOutput {
updatePanelSize()
}
}
.onChange(of: enableLyrics) { _, _ in
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
updatePanelSize()
}
}
.onChange(of: lockScreenGlassStyle) { _, _ in
logGlassState(reason: "Glass style updated")
}
.onChange(of: glassCustomizationMode) { _, _ in
logGlassState(reason: "Glass mode updated")
}
.onChange(of: musicGlassVariant) { _, _ in
if usesCustomLiquidGlass {
logGlassState(reason: "Liquid variant updated")
}
}
.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
private var panelForeground: some View {
Group {
if isExpanded {
expandedLayout
} else {
collapsedLayout
}
}
.padding(.horizontal, usesSpotifyCanvasFallbackContentPresentation ? 8 : (isExpanded ? 24 : 20))
.padding(.vertical, usesSpotifyCanvasFallbackContentPresentation ? 0 : (isExpanded ? 22 : 16))
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: usesSpotifyCanvasFallbackContentPresentation ? .center : .topLeading
)
}
private var collapsedLayout: some View {
VStack(spacing: 12) {
collapsedHeader
progressBar
.padding(.top, 4)
.frame(maxWidth: .infinity)
playbackControls(alignment: .center)
.padding(.top, 4)
}
}
private var expandedLayout: some View {
Group {
if hidesInlineArtworkForSpotifyCanvasFallback {
VStack(alignment: .leading, spacing: 20) {
expandedHeader
progressBar
.padding(.top, 10)
.frame(maxWidth: .infinity)
playbackControls(alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
HStack(alignment: .center, spacing: expandedContentSpacing) {
albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius)
.frame(width: 230, height: 230)
VStack(alignment: .leading, spacing: 20) {
expandedHeader
progressBar
.padding(.top, 10)
.frame(maxWidth: .infinity)
playbackControls(alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxHeight: .infinity, alignment: .center)
}
private var collapsedHeader: some View {
HStack(alignment: .center, spacing: hidesInlineArtworkForSpotifyCanvasFallback ? 10 : 16) {
if !hidesInlineArtworkForSpotifyCanvasFallback {
albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius)
}
GeometryReader { geo in
VStack(alignment: .leading, spacing: 1) {
MusicTitleMarqueeView(
text: musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle,
isExplicit: !musicManager.songTitle.isEmpty && musicManager.isCurrentTrackExplicit,
font: .system(size: 12, weight: .semibold),
nsFont: .subheadline,
textColor: .white,
minDuration: 0.45,
frameWidth: geo.size.width,
badgeSpacing: 5,
badgeLabel: "E",
badgeHeight: 13,
badgeForegroundColor: Color.black.opacity(0.72),
badgeBackgroundColor: Color.white.opacity(0.46),
badgeHorizontalPadding: 4,
badgeMinWidth: 14,
badgeCornerRadius: 4
)
Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName)
.font(.system(size: 10, weight: .regular))
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
.lineLimit(1)
}
.frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
}
.frame(maxWidth: .infinity, minHeight: 30, maxHeight: 30, alignment: .leading)
visualizer(width: 20, height: 16)
}
.frame(height: 60)
}
private var expandedHeader: some View {
HStack(alignment: .top, spacing: 16) {
GeometryReader { geo in
VStack(alignment: .leading, spacing: 6) {
MusicTitleMarqueeView(
text: musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle,
isExplicit: !musicManager.songTitle.isEmpty && musicManager.isCurrentTrackExplicit,
font: .system(size: 18, weight: .semibold),
nsFont: .title3,
textColor: .white,
minDuration: 0.55,
frameWidth: geo.size.width,
badgeSpacing: 6,
badgeLabel: "E",
badgeHeight: 16,
badgeForegroundColor: Color.black.opacity(0.74),
badgeBackgroundColor: Color.white.opacity(0.5),
badgeHorizontalPadding: 5,
badgeMinWidth: 17,
badgeCornerRadius: 5
)
Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName)
.font(.system(size: 13, weight: .medium))
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.7) : .gray)
.lineLimit(2)
}
.frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
}
.frame(maxWidth: .infinity, minHeight: 42, alignment: .leading)
visualizer(width: 24, height: 20)
}
}
private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View {
let artworkCornerRadius = resolvedArtworkCornerRadius(from: cornerRadius)
return ZStack(alignment: .bottomTrailing) {
if isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork {
RoundedRectangle(cornerRadius: artworkCornerRadius, style: .continuous)
.fill(Color.white.opacity(0.06))
.overlay(
Image(systemName: "photo.fill")
.font(.system(size: size * 0.3, weight: .light))
.foregroundColor(.white.opacity(0.2))
)
} else {
albumArtImage(size: size, cornerRadius: cornerRadius)
if showAppIcon, let icon = lockScreenAppIcon {
icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: appIconSize, height: appIconSize)
.shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4)
.offset(x: appIconOffset, y: appIconOffset)
.transition(.scale.combined(with: .opacity))
}
}
}
.albumArtFlip(angle: isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork ? 0 : musicManager.flipAngle)
.parallax3D(
enableOverride: lockScreenParallaxEnabled && (!isArtworkFullscreen || keepAlbumArtVisibleDuringFullscreenArtwork),
suspended: isParallaxSuspended
)
.frame(width: size)
.background(albumArtBackground(cornerRadius: artworkCornerRadius))
.clipShape(RoundedRectangle(cornerRadius: artworkCornerRadius, style: .continuous))
.opacity(musicManager.isPlaying ? 1 : 0.4)
.scaleEffect(musicManager.isPlaying ? 1 : 0.85)
.animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying)
.animation(.easeInOut(duration: 0.28), value: isArtworkFullscreen)
.onTapGesture {
toggleExpanded()
}
.onRightClick {
expandArtworkToFullscreen()
}
}
@ViewBuilder
private func visualizer(width: CGFloat, height: CGFloat) -> some View {
if Defaults[.useMusicVisualizer] {
Rectangle()
.fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient)
.mask {
AudioVisualizerView(isPlaying: .constant(musicManager.isPlaying))
.frame(width: width, height: height)
}
.frame(width: width, height: height)
}
}
private func toggleExpanded() {
let newState = !isExpanded
suspendParallaxInteraction()
withAnimation(.easeInOut(duration: 0.28)) {
isExpanded = newState
}
if newState {
registerInteraction()
logPanelAppearance(event: "🔍 Expanded")
} else {
logPanelAppearance(event: "⬇️ Collapsed")
cancelCollapseTimer()
}
}
private func expandArtworkToFullscreen() {
guard fullscreenArtworkEnabled else { return }
if FullScreenArtworkWindowManager.shared.isShowing {
FullScreenArtworkWindowManager.shared.hide()
withAnimation(.easeInOut(duration: 0.28)) {
isArtworkFullscreen = false
}
return
}
guard musicManager.hasActiveSession else { return }
let artwork = musicManager.albumArt
let videoURL = musicManager.videoArtworkURL
withAnimation(.easeInOut(duration: 0.28)) {
isArtworkFullscreen = true
}
FullScreenArtworkWindowManager.shared.onDismiss = {
Task { @MainActor in
withAnimation(.easeInOut(duration: 0.28)) {
NotificationCenter.default.post(name: .atollArtworkWallpaperDismissed, object: nil)
}
}
}
FullScreenArtworkWindowManager.shared.show(
artwork: artwork,
videoURL: fullscreenVideoArtwork ? videoURL : nil,
allowLiveWallpaper: fullscreenVideoArtwork
)
}
private func registerInteraction() {
cancelCollapseTimer()
guard isExpanded else { return }
let workItem = DispatchWorkItem {
suspendParallaxInteraction()
withAnimation(.easeInOut(duration: 0.28)) {
isExpanded = false
}
logPanelAppearance(event: "⏱️ Auto-collapsed")
}
collapseWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + collapseTimeout, execute: workItem)
}
private func suspendParallaxInteraction(for duration: TimeInterval = 0.65) {
parallaxResumeWorkItem?.cancel()
isParallaxSuspended = true
let workItem = DispatchWorkItem {
isParallaxSuspended = false
}
parallaxResumeWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: workItem)
}
private func cancelCollapseTimer() {
collapseWorkItem?.cancel()
collapseWorkItem = nil
}
private var isProgressTimelinePaused: Bool {
!musicManager.isPlaying || musicManager.isLiveStream || musicManager.playbackRate <= 0
}
// MARK: - Progress Bar
private var progressBar: some View {
TimelineView(
.animation(
minimumInterval: 1.0,
paused: isProgressTimelinePaused
)
) { timeline in
MusicSliderView(
sliderValue: $sliderValue,
duration: Binding(
get: { musicManager.songDuration },
set: { musicManager.songDuration = $0 }
),
lastDragged: $lastDragged,
color: musicManager.avgColor,
dragging: $dragging,
currentDate: timeline.date,
timestampDate: musicManager.timestampDate,
elapsedTime: musicManager.elapsedTime,
playbackRate: musicManager.playbackRate,
isPlaying: musicManager.isPlaying,
isLiveStream: musicManager.isLiveStream,
onValueChange: { newValue in
registerInteraction()
musicManager.seek(to: newValue)
},
labelLayout: .inline,
trailingLabel: .remaining,
restingTrackHeight: 7,
draggingTrackHeight: 11
)
}
.onAppear {
sliderValue = musicManager.elapsedTime
}
.onChange(of: musicManager.isLiveStream) { _, isLive in
if isLive {
dragging = false
sliderValue = 0
}
}
}
private var sliderColor: Color {
switch Defaults[.sliderColor] {
case .white:
return .white
case .albumArt:
return Color(nsColor: musicManager.avgColor)
case .accent:
return .accentColor
}
}
private var brandAccentColor: Color {
musicManager.brandAccentColor
}
// MARK: - Playback Controls
private func playbackControls(alignment: Alignment) -> some View {
let spacing: CGFloat = isExpanded ? 24 : 20
let verticalSpacing: CGFloat = (shouldShowVolumeSlider || shouldShowInlineLyrics) ? 14 : 10
return VStack(spacing: verticalSpacing) {
controlsRow(alignment: alignment, spacing: spacing)
if shouldShowVolumeSlider {
volumeSlider
.frame(maxWidth: .infinity, alignment: alignment)
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
if shouldShowAirPlay {
airPlaySection
.frame(maxWidth: .infinity, alignment: alignment)
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
} else if shouldShowRouteSelector {
mediaOutputDevicesSection
.frame(maxWidth: .infinity, alignment: alignment)
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
}
}
if shouldShowInlineLyrics {
lyricsSection(alignment: alignment)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, alignment: alignment)
.padding(.top, isExpanded ? 6 : 2)
.animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
.animation(.easeInOut(duration: 0.24), value: shouldShowAirPlay)
.animation(.easeInOut(duration: 0.24), value: shouldShowRouteSelector)
.animation(.easeInOut(duration: 0.24), value: enableLyrics)
}
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
let skipNudge: CGFloat = isExpanded ? 14 : 9
return HStack(spacing: spacing) {
ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in
slotView(for: slot, skipNudge: skipNudge)
}
}
.frame(maxWidth: .infinity, alignment: alignment)
}
private var displayedSlots: [MusicControlButton] {
guard showShuffleAndRepeat else {
return fallbackSlots
}
let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive)
return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout
}
private var fallbackSlots: [MusicControlButton] {
switch musicSkipBehavior {
case .track:
return MusicControlButton.minimalLayout
case .tenSecond:
return [.none, .seekBackward, .playPause, .seekForward, .none]
}
}
@ViewBuilder
private func slotView(for control: MusicControlButton, skipNudge: CGFloat) -> some View {
let seekInterval: TimeInterval = 10
switch control {
case .none:
Spacer(minLength: 0)
case .playPause:
playPauseButton
case .trackBackward:
controlButton(
icon: "backward.fill",
size: 18,
interaction: .nudge(-skipNudge),
symbolEffect: .replace
) {
musicManager.previousTrack()
}
case .trackForward:
controlButton(
icon: "forward.fill",
size: 18,
interaction: .nudge(skipNudge),
symbolEffect: .replace
) {
musicManager.nextTrack()
}
case .seekBackward:
controlButton(
icon: "gobackward.10",
size: 18,
interaction: .wiggle(.counterClockwise),
symbolEffect: .wiggle
) {
musicManager.seek(by: -seekInterval)
}
case .seekForward:
controlButton(
icon: "goforward.10",
size: 18,
interaction: .wiggle(.clockwise),
symbolEffect: .wiggle
) {
musicManager.seek(by: seekInterval)
}
case .shuffle:
controlButton(
icon: "shuffle",
size: 18,
isActive: musicManager.isShuffled,
activeColor: brandAccentColor
) {
musicManager.toggleShuffle()
}
case .repeatMode:
controlButton(
icon: repeatIcon,
size: 18,
isActive: musicManager.repeatMode != .off,
activeColor: brandAccentColor,
symbolEffect: .replace
) {
musicManager.toggleRepeat()
}
case .mediaOutput:
mediaOutputControlButton
case .airPlay:
if useMergedAirPlayOutput {
mediaOutputControlButton
} else {
standaloneAirPlayButton
}
case .lyrics:
controlButton(
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
size: 18,
isActive: enableLyrics,
activeColor: brandAccentColor,
symbolEffect: .replace
) {
withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) {
enableLyrics.toggle()
}
}
}
}
private var playPauseButton: some View {
let frameSize: CGFloat = isExpanded ? 80 : 54
let iconName = musicManager.isPlaying ? "pause.fill" : "play.fill"
return HoverButton(
icon: iconName,
iconColor: .white,
scale: .large,
pressEffect: nil
) {
registerInteraction()
musicManager.togglePlay()
}
.frame(width: frameSize, height: frameSize)
}
private func controlButton(
icon: String,
size: CGFloat = 18,
isActive: Bool = false,
activeColor: Color? = nil,
interaction: PanelControlButton.Interaction = .none,
symbolEffect: PanelControlButton.SymbolEffectStyle = .replace,
action: @escaping () -> Void
) -> some View {
let resolvedActiveColor = activeColor ?? brandAccentColor
let frameSize: CGFloat = isExpanded ? 56 : 32
let iconSize: CGFloat = isExpanded ? max(size, 24) : size
let iconColor = isActive ? resolvedActiveColor : .white.opacity(0.8)
let backgroundOpacity: Double = isActive ? 0.22 : 0.0
return PanelControlButton(
icon: icon,
frameSize: frameSize,
iconSize: iconSize,
iconColor: iconColor,
backgroundOpacity: backgroundOpacity,
interaction: interaction,
symbolEffect: symbolEffect
) {
registerInteraction()
action()
}
}
private var mediaOutputControlButton: some View {
let frameSize: CGFloat = isExpanded ? 56 : 32
let iconSize: CGFloat = isExpanded ? 26 : 18
return PanelControlButton(
icon: mediaOutputIcon,
frameSize: frameSize,
iconSize: iconSize,
iconColor: shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8),
backgroundOpacity: shouldShowVolumeSlider ? 0.22 : 0.0,
interaction: .none,
symbolEffect: .replace,
action: toggleVolumeSlider
)
.accessibilityLabel("Media output")
}
private var standaloneAirPlayButton: some View {
let frameSize: CGFloat = isExpanded ? 56 : 32
let iconSize: CGFloat = isExpanded ? 26 : 18
return PanelControlButton(
icon: "airplayaudio",
frameSize: frameSize,
iconSize: iconSize,
iconColor: isAirPlayPopoverPresented ? .accentColor : .white.opacity(0.8),
backgroundOpacity: isAirPlayPopoverPresented ? 0.22 : 0.0,
interaction: .none,
symbolEffect: .replace
) {
registerInteraction()
isAirPlayPopoverPresented.toggle()
if isAirPlayPopoverPresented {
Task { await airPlayManager.refreshDevices() }
}
}
.accessibilityLabel("AirPlay")
.popover(isPresented: $isAirPlayPopoverPresented, arrowEdge: .bottom) {
AirPlaySelectorPopover(
airPlayManager: airPlayManager,
onHoverChanged: { _ in },
dismiss: { isAirPlayPopoverPresented = false }
)
}
}
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 repeatIcon: String {
switch musicManager.repeatMode {
case .off: return "repeat"
case .all: return "repeat"
case .one: return "repeat.1"
}
}
private var mediaOutputIcon: String {
routeManager.activeDevice?.iconName ?? "speaker.wave.2"
}
private var volumeSlider: some View {
HStack(spacing: 14) {
Image(systemName: volumeIconName)
.font(.system(size: isExpanded ? 16 : 14, weight: .semibold))
.foregroundColor(.white.opacity(0.8))
Slider(
value: Binding(
get: { Double(volumeModel.level) },
set: { newValue in
registerInteraction()
volumeModel.setVolume(Float(newValue))
}
),
in: 0 ... 1
)
.tint(sliderColor)
Text(volumePercentage)
.font(.system(size: isExpanded ? 12 : 11, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.frame(width: 48, alignment: .trailing)
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
.fill(sliderBackgroundFill)
)
}
private var airPlaySection: some View {
accessorySectionContainer {
if airPlayManager.devices.isEmpty {
Text("No AirPlay outputs available")
.font(.system(size: isExpanded ? 13 : 11, weight: .medium))
.foregroundColor(.white.opacity(0.68))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 8)
.padding(.horizontal, 12)
} else {
ScrollView(.vertical, showsIndicators: true) {
VStack(spacing: 6) {
ForEach(airPlayManager.devices) { device in
VStack(spacing: 4) {
Button {
registerInteraction()
Task { await airPlayManager.toggleDevice(device) }
} label: {
HStack(spacing: 8) {
Image(systemName: device.iconName)
.font(.system(size: isExpanded ? 14 : 12, weight: .medium))
.foregroundColor(.white.opacity(0.8))
Text(device.name)
.font(.system(size: isExpanded ? 13 : 11, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Spacer()
if device.isSelected {
Image(systemName: "checkmark")
.font(.system(size: isExpanded ? 12 : 10, weight: .bold))
.foregroundColor(.accentColor)
}
}
.padding(.vertical, 4)
.padding(.horizontal, 12)
}
.buttonStyle(.plain)
if device.isSelected {
AirPlayVolumeSlider(
airPlayManager: airPlayManager,
deviceID: device.id
)
.padding(.horizontal, 12)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: accessorySectionScrollMaxHeight)
}
}
.padding(.vertical, 8)
}
private var mediaOutputDevicesSection: some View {
accessorySectionContainer {
if routeManager.devices.isEmpty {
Text("No audio outputs available")
.font(.system(size: isExpanded ? 13 : 11, weight: .medium))
.foregroundColor(.white.opacity(0.68))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 8)
.padding(.horizontal, 12)
} else {
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .leading, spacing: 6) {
ForEach(routeManager.devices) { device in
Button {
registerInteraction()
routeManager.select(device: device)
} label: {
HStack(spacing: 8) {
Image(systemName: device.iconName)
.font(.system(size: isExpanded ? 14 : 12, weight: .medium))
.foregroundColor(.white.opacity(0.82))
Text(device.name)
.font(.system(size: isExpanded ? 13 : 11, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Spacer()
if device.id == routeManager.activeDeviceID {
Image(systemName: "checkmark")
.font(.system(size: isExpanded ? 12 : 10, weight: .bold))
.foregroundColor(.accentColor)
}
}
.padding(.vertical, 5)
.padding(.horizontal, 12)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: accessorySectionScrollMaxHeight)
}
}
.padding(.vertical, 8)
}
private func accessorySectionContainer(@ViewBuilder content: () -> Content) -> some View {
content()
.background(
RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
.fill(sliderBackgroundFill)
)
}
private var sliderBackgroundFill: Color {
if usesLiquidGlass {
return Color.white.opacity(0.05)
}
return Color.white.opacity(0.08)
}
private var isAppleMusicActive: Bool {
musicManager.bundleIdentifier == "com.apple.Music"
}
private var useMergedAirPlayOutput: Bool {
mergedAirPlayOutput && isAppleMusicActive && slotConfig.contains(where: { $0 == .mediaOutput || $0 == .airPlay })
}
private var shouldShowAirPlay: Bool {
useMergedAirPlayOutput && shouldShowVolumeSlider && !airPlayManager.devices.isEmpty
}
private var shouldShowRouteSelector: Bool {
!useMergedAirPlayOutput && shouldShowVolumeSlider
}
private var shouldShowAccessorySection: Bool {
shouldShowAirPlay || shouldShowRouteSelector
}
private var shouldShowVolumeSlider: Bool {
showMediaOutputControl && isVolumeSliderVisible
}
private var sliderExtraHeight: CGFloat {
sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
}
private var accessorySectionScrollMaxHeight: CGFloat {
isExpanded ? 170 : 130
}
private var lyricsExtraHeight: CGFloat {
lyricsHeight(forExpanded: isExpanded, enabled: shouldShowInlineLyrics)
}
private var accessorySectionExtraHeight: CGFloat {
guard shouldShowAccessorySection else { return 0 }
if shouldShowAirPlay {
let selectedCount = airPlayManager.devices.filter(\.isSelected).count
let totalCount = airPlayManager.devices.count
let deviceRows: CGFloat = CGFloat(totalCount) * 30 + CGFloat(max(totalCount - 1, 0)) * 6
let sliders: CGFloat = CGFloat(selectedCount) * 34
return min(deviceRows + sliders + 24, accessorySectionScrollMaxHeight + 16)
}
let totalCount = max(routeManager.devices.count, 1)
let deviceRows: CGFloat = CGFloat(totalCount) * 30 + CGFloat(max(totalCount - 1, 0)) * 6
return min(deviceRows + 24, accessorySectionScrollMaxHeight + 16)
}
private var totalExtraHeight: CGFloat {
sliderExtraHeight + accessorySectionExtraHeight + 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) +
accessorySectionExtraHeight +
lyricsHeight(forExpanded: expanded, enabled: shouldShowInlineLyrics)
}
private func updatePanelSize(animated: Bool = true) {
LockScreenPanelManager.shared.updatePanelSize(
expanded: isExpanded,
additionalHeight: panelAdditionalHeight(forExpanded: isExpanded),
animated: animated
)
}
private var volumeIconName: String {
if volumeModel.isMuted || volumeModel.level <= 0.001 {
return "speaker.slash.fill"
} else if volumeModel.level < 0.33 {
return "speaker.wave.1.fill"
} else if volumeModel.level < 0.66 {
return "speaker.wave.2.fill"
}
return "speaker.wave.3.fill"
}
private var volumePercentage: String {
"\(Int(round(volumeModel.level * 100)))%"
}
private func toggleVolumeSlider() {
guard showMediaOutputControl else {
isVolumeSliderVisible = false
return
}
registerInteraction()
let newState = !isVolumeSliderVisible
if newState {
routeManager.refreshDevices()
if useMergedAirPlayOutput {
Task { await airPlayManager.refreshDevices() }
}
}
withAnimation(.easeInOut(duration: 0.24)) {
isVolumeSliderVisible = newState
}
updatePanelSize()
}
private func sliderHeight(forExpanded expanded: Bool, visible: Bool) -> CGFloat {
guard visible else { return 0 }
return expanded ? expandedSliderExtraHeight : collapsedSliderExtraHeight
}
@ViewBuilder
private var panelBackgroundLayer: some View {
if usesSpotifyCanvasFallbackContentPresentation {
Color.clear
.allowsHitTesting(false)
.accessibilityHidden(true)
} else if usesCustomLiquidGlass {
customLiquidPanelBackdrop
} else if usesStandardLiquidGlass {
standardLiquidPanelBackdrop
} else if shouldUseFrostedBlur {
frostedPanelBackground
} else {
opaquePanelBackground
}
}
@ViewBuilder
private var standardLiquidPanelBackdrop: some View {
if #available(macOS 26.0, *) {
GlassTextBackdrop(cornerRadius: panelCornerRadius)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
}
@ViewBuilder
private var customLiquidPanelBackdrop: some View {
LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: panelCornerRadius) {
Color.white.opacity(0.04)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
private var frostedPanelBackground: some View {
RoundedRectangle(cornerRadius: panelCornerRadius)
.fill(.ultraThinMaterial)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
private var opaquePanelBackground: some View {
RoundedRectangle(cornerRadius: panelCornerRadius)
.fill(Color.black.opacity(0.45))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
@ViewBuilder
private var panelBorderOverlay: some View {
if usesEnhancedCustomLiquidBorder {
ZStack {
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.stroke(
Color.white.opacity(0.22),
lineWidth: 1.05
)
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.inset(by: 0.85)
.stroke(
LinearGradient(
colors: [
Color.white.opacity(0.28),
Color.white.opacity(0.1),
Color.black.opacity(0.18)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 0.9
)
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.inset(by: 1.55)
.stroke(
Color.black.opacity(0.16),
lineWidth: 0.55
)
}
.allowsHitTesting(false)
} else if usesLiquidGlass {
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.stroke(
Color.white.opacity(usesCustomLiquidGlass ? 0.15 : 0.14),
lineWidth: usesCustomLiquidGlass ? 0.95 : 0.9
)
.allowsHitTesting(false)
} else {
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.stroke(Color.white.opacity(0.35), lineWidth: 1.4)
.allowsHitTesting(false)
}
}
private func albumArtImage(size: CGFloat, cornerRadius: CGFloat) -> some View {
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fit)
.compositingGroup()
.clipShape(RoundedRectangle(cornerRadius: resolvedArtworkCornerRadius(from: cornerRadius), style: .continuous))
}
@ViewBuilder
private func albumArtBackground(cornerRadius: CGFloat) -> some View {
if usesCustomLiquidGlass {
customLiquidAlbumArtBackground(cornerRadius: cornerRadius)
} else if usesStandardLiquidGlass {
if #available(macOS 26.0, *) {
clearLiquidGlassSurface(cornerRadius: cornerRadius)
}
} else if shouldUseFrostedBlur {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(.ultraThinMaterial)
} else {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Color.black.opacity(0.35))
}
}
private var lockScreenAppIcon: Image? {
guard showAppIcon, !musicManager.usingAppIconForArtwork else { return nil }
let bundleIdentifier = musicManager.bundleIdentifier ?? "com.apple.Music"
return AppIcon(for: bundleIdentifier)
}
private var appIconSize: CGFloat {
isExpanded ? 58 : 34
}
private var appIconCornerRadius: CGFloat {
isExpanded ? 18 : 12
}
private var appIconOffset: CGFloat {
isExpanded ? 18 : 12
}
private func resolvedArtworkCornerRadius(from baseCornerRadius: CGFloat) -> CGFloat {
let aspectRatio = musicManager.albumArt.size.height > 0
? musicManager.albumArt.size.width / musicManager.albumArt.size.height
: 1
return aspectRatio > 1.0 ? baseCornerRadius / 3 : baseCornerRadius
}
@available(macOS 26.0, *)
private func clearLiquidGlassSurface(cornerRadius: CGFloat) -> some View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(.clear)
.glassEffect(
.clear.interactive(),
in: .rect(cornerRadius: cornerRadius)
)
}
@ViewBuilder
private func customLiquidAlbumArtBackground(cornerRadius: CGFloat) -> some View {
LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: cornerRadius) {
Color.clear
}
}
private func logGlassState(reason: String) {
let snapshot = GlassLogSnapshot(
style: lockScreenGlassStyle,
customizationMode: glassCustomizationMode,
variantRawValue: musicGlassVariant.rawValue,
usesLiquidGlass: usesLiquidGlass
)
guard snapshot != lastLoggedGlassSnapshot else { return }
lastLoggedGlassSnapshot = snapshot
struct ComponentState {
let name: String
let isLiquid: Bool
}
let states = [
ComponentState(name: "Panel Shell", isLiquid: usesLiquidGlass),
ComponentState(name: "Control Capsules", isLiquid: usesLiquidGlass),
ComponentState(name: "Volume Slider", isLiquid: usesLiquidGlass),
ComponentState(name: "Album Art Plate", isLiquid: usesLiquidGlass)
]
let componentSummary = states.map { entry -> String in
let mode = entry.isLiquid ? "Liquid" : "Frosted"
return "\(entry.name)=\(mode)"
}.joined(separator: ", ")
let modeDescription: String
if usesCustomLiquidGlass {
modeDescription = "Custom Liquid (variant \(musicGlassVariant.rawValue))"
} else if usesStandardLiquidGlass {
modeDescription = "Standard Liquid"
} else {
modeDescription = lockScreenGlassStyle.rawValue
}
print("[LockScreenMusicPanel] \(reason) – customization=\(glassCustomizationMode.rawValue), mode=\(modeDescription), components[\(componentSummary)], macOS \(currentOSVersionDescription())")
if glassCustomizationMode == .standard && lockScreenGlassStyle == .liquid && !usesStandardLiquidGlass {
print("[LockScreenMusicPanel] Liquid Glass requested but unavailable on this macOS build. Falling back to frosted visuals.")
}
}
private func currentOSVersionDescription() -> String {
let version = ProcessInfo.processInfo.operatingSystemVersion
return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
}
private func logPanelAppearance(event: String = "✅ View appeared") {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss.SSS"
let styleDescriptor = usesLiquidGlass ? "Liquid Glass" : "Frosted"
print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: \(event) – \(styleDescriptor)")
}
}
private struct PanelControlButton: View {
let icon: String
let frameSize: CGFloat
let iconSize: CGFloat
let iconColor: Color
let backgroundOpacity: Double
let interaction: Interaction
let symbolEffect: SymbolEffectStyle
let action: () -> Void
@State private var isHovering = false
@State private var pressOffset: CGFloat = 0
@State private var rotationAngle: Double = 0
@State private var wiggleToken: Int = 0
var body: some View {
Button(action: {
triggerPressEffect()
action()
}) {
RoundedRectangle(cornerRadius: frameSize / 2, style: .continuous)
.fill(backgroundColor)
.overlay(
iconView
)
}
.frame(width: frameSize, height: frameSize)
.buttonStyle(PlainButtonStyle())
.offset(x: pressOffset)
.rotationEffect(.degrees(rotationAngle))
.onHover { hovering in
withAnimation(.smooth(duration: 0.24)) {
isHovering = hovering
}
}
}
private var backgroundColor: Color {
let hoveredOpacity = max(backgroundOpacity + 0.08, 0.18)
let appliedOpacity = isHovering ? hoveredOpacity : backgroundOpacity
return Color.white.opacity(min(appliedOpacity, 0.32))
}
private func triggerPressEffect() {
switch interaction {
case .none:
return
case .nudge(let amount):
withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) {
pressOffset = amount
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
withAnimation(.spring(response: 0.28, dampingFraction: 0.72)) {
pressOffset = 0
}
}
case .wiggle(let direction):
guard #available(macOS 14.0, *) else { return }
wiggleToken += 1
let angle: Double = direction == .clockwise ? 10 : -10
withAnimation(.spring(response: 0.18, dampingFraction: 0.5)) {
rotationAngle = angle
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.75)) {
rotationAngle = 0
}
}
}
}
@ViewBuilder
private var iconView: some View {
let base = Image(systemName: icon)
.font(.system(size: iconSize, weight: .medium))
.foregroundStyle(iconColor)
switch symbolEffect {
case .replace:
base.contentTransition(.symbolEffect(.replace))
case .replaceAndBounce:
if #available(macOS 14.0, *) {
base
.contentTransition(.symbolEffect(.replace))
.symbolEffect(.bounce, value: icon)
} else {
base.contentTransition(.symbolEffect(.replace))
}
case .wiggle:
if #available(macOS 15.0, *) {
base
.contentTransition(.symbolEffect(.replace))
.symbolEffect(.wiggle.byLayer, options: .nonRepeating, value: wiggleToken)
} else {
base.contentTransition(.symbolEffect(.replace))
}
}
}
enum Interaction {
case none
case nudge(CGFloat)
case wiggle(WiggleDirection)
}
enum SymbolEffectStyle {
case replace
case replaceAndBounce
case wiggle
}
enum WiggleDirection {
case clockwise
case counterClockwise
}
}
@available(macOS 26.0, *)
private struct GlassTextBackdrop: View {
let cornerRadius: CGFloat
var body: some View {
GeometryReader { proxy in
let dynamicFontSize = max(min(proxy.size.width, proxy.size.height) / 8, 42)
Text("Lock Screen Liquid Glass")
.font(.system(size: dynamicFontSize, weight: .semibold, design: .rounded))
.foregroundStyle(Color.clear)
.frame(width: proxy.size.width, height: proxy.size.height)
.glassEffect(
.clear.interactive(),
in: .rect(cornerRadius: cornerRadius)
)
}
.allowsHitTesting(false)
.accessibilityHidden(true)
}
}
// MARK: - Right Click Gesture
private struct RightClickOverlay: ViewModifier {
let action: () -> Void
func body(content: Content) -> some View {
content.overlay(
RightClickReceiverView(action: action)
.frame(maxWidth: .infinity, maxHeight: .infinity)
)
}
}
private struct RightClickReceiverView: NSViewRepresentable {
let action: () -> Void
func makeNSView(context: Context) -> RightClickNSView {
let view = RightClickNSView()
view.onRightClick = action
return view
}
func updateNSView(_ nsView: RightClickNSView, context: Context) {
nsView.onRightClick = action
}
}
private extension View {
func onRightClick(perform action: @escaping () -> Void) -> some View {
modifier(RightClickOverlay(action: action))
}
}
final class RightClickNSView: NSView {
var onRightClick: (() -> Void)?
private var eventMonitor: Any?
deinit {
removeEventMonitor()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
removeEventMonitor()
} else {
installEventMonitorIfNeeded()
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
private func installEventMonitorIfNeeded() {
guard eventMonitor == nil else { return }
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] event in
guard let self, let window = self.window, event.window === window else {
return event
}
let pointInView = self.convert(event.locationInWindow, from: nil)
guard self.bounds.contains(pointInView) else {
return event
}
self.onRightClick?()
return nil
}
}
private func removeEventMonitor() {
guard let eventMonitor else { return }
NSEvent.removeMonitor(eventMonitor)
self.eventMonitor = nil
}
}
extension Notification.Name {
static let atollArtworkWallpaperDismissed = Notification.Name("atollArtworkWallpaperDismissed")
}