// // LockScreenMusicPanel.swift // DynamicIsland // // Created for lock screen music panel with liquid glass effect // 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 collapsedSize = CGSize(width: 420, height: 180) static let expandedSize = CGSize(width: 720, height: 340) @ObservedObject var musicManager = MusicManager.shared @ObservedObject private var routeManager = AudioRouteManager.shared @StateObject private var volumeModel = MediaOutputVolumeViewModel() @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 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(.lockScreenPanelUsesBlur) var enableBlur @Default(.showMediaOutputControl) private var showMediaOutputControl @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat @Default(.musicControlSlots) private var slotConfig @Default(.musicSkipBehavior) private var musicSkipBehavior @Default(.enableLyrics) private var enableLyrics @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled 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 : Self.collapsedSize return CGSize(width: base.width, height: base.height + totalExtraHeight) } 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 } var body: some View { if isActive { panelContent } else { Color.clear .frame(width: Self.collapsedSize.width, height: Self.collapsedSize.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 && !usesLiquidGlass { RoundedRectangle(cornerRadius: panelCornerRadius) .stroke(Color.white.opacity(0.35), lineWidth: 1.4) } } .shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) .contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) .animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded) .animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.18), value: shouldShowVolumeSlider) .onAppear { sliderValue = musicManager.elapsedTime isActive = true logPanelAppearance() updatePanelSize(animated: false) routeManager.refreshDevices() logGlassState(reason: "Panel appeared") } .onDisappear { isActive = false cancelCollapseTimer() isVolumeSliderVisible = false parallaxResumeWorkItem?.cancel() parallaxResumeWorkItem = nil isParallaxSuspended = 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: 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, isExpanded ? 24 : 20) .padding(.vertical, isExpanded ? 22 : 16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .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 { 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: 16) { albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius) VStack(alignment: .leading, spacing: 1) { Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle) .font(.system(size: 12, weight: .semibold)) .foregroundColor(.white) .lineLimit(1) 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) } Spacer() visualizer(width: 20, height: 16) } .frame(height: 60) } private var expandedHeader: some View { HStack(alignment: .top, spacing: 16) { VStack(alignment: .leading, spacing: 6) { Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle) .font(.system(size: 18, weight: .semibold)) .foregroundColor(.white) .lineLimit(2) 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) } Spacer() visualizer(width: 24, height: 20) } } private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View { Button(action: toggleExpanded) { ZStack(alignment: .bottomTrailing) { albumArtImage(size: size, cornerRadius: cornerRadius) if showAppIcon, let icon = lockScreenAppIcon { icon .resizable() .aspectRatio(contentMode: .fill) .frame(width: appIconSize, height: appIconSize) .clipShape(RoundedRectangle(cornerRadius: appIconCornerRadius, style: .continuous)) .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: musicManager.flipAngle) .parallax3D(magnitude: 12, enableOverride: lockScreenParallaxEnabled, suspended: isParallaxSuspended) .frame(width: size, height: size) .background(albumArtBackground(cornerRadius: cornerRadius)) } .buttonStyle(PlainButtonStyle()) .opacity(musicManager.isPlaying ? 1 : 0.4) .scaleEffect(musicManager.isPlaying ? 1 : 0.85) .animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying) } @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 { AudioSpectrumView(isPlaying: .constant(musicManager.isPlaying)) .frame(width: width, height: height) } .frame(width: width, height: height) } } private func toggleExpanded() { let newState = !isExpanded suspendParallaxInteraction() withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) { isExpanded = newState } if newState { registerInteraction() logPanelAppearance(event: "🔍 Expanded") } else { logPanelAppearance(event: "⬇️ Collapsed") cancelCollapseTimer() } } private func registerInteraction() { cancelCollapseTimer() guard isExpanded else { return } let workItem = DispatchWorkItem { suspendParallaxInteraction() withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) { 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 } // MARK: - Progress Bar private var progressBar: some View { TimelineView( .animation( minimumInterval: (!musicManager.isLiveStream && musicManager.playbackRate > 0) ? 0.1 : nil ) ) { 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 } } // MARK: - Playback Controls private func playbackControls(alignment: Alignment) -> some View { let spacing: CGFloat = isExpanded ? 24 : 20 let verticalSpacing: CGFloat = (shouldShowVolumeSlider || enableLyrics) ? 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 enableLyrics { lyricsSection(alignment: alignment) .transition(.move(edge: .top).combined(with: .opacity)) } } .frame(maxWidth: .infinity, alignment: alignment) .padding(.top, isExpanded ? 6 : 2) .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 { 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) 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: .red ) { musicManager.toggleShuffle() } case .repeatMode: controlButton( icon: repeatIcon, size: 18, isActive: musicManager.repeatMode != .off, activeColor: .red, symbolEffect: .replace ) { musicManager.toggleRepeat() } case .mediaOutput: mediaOutputControlButton case .lyrics: controlButton( icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", size: 18, isActive: enableLyrics, activeColor: .accentColor, 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 = .red, interaction: PanelControlButton.Interaction = .none, symbolEffect: PanelControlButton.SymbolEffectStyle = .replace, action: @escaping () -> Void ) -> some View { let frameSize: CGFloat = isExpanded ? 56 : 32 let iconSize: CGFloat = isExpanded ? max(size, 24) : size let iconColor = isActive ? activeColor : .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 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 sliderBackgroundFill: Color { if usesLiquidGlass { return Color.white.opacity(0.05) } return Color.white.opacity(0.08) } private var shouldShowVolumeSlider: Bool { showMediaOutputControl && isVolumeSliderVisible } private var sliderExtraHeight: CGFloat { 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 { 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() } withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { 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 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) } private func albumArtImage(size: CGFloat, cornerRadius: CGFloat) -> some View { Image(nsImage: musicManager.albumArt) .resizable() .aspectRatio(contentMode: .fill) .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: 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 : 10 } private var appIconOffset: CGFloat { isExpanded ? 18 : 12 } @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) } }