// // LockScreenMusicPanel.swift // DynamicIsland // // Created for lock screen music panel with liquid glass effect // import SwiftUI import Defaults struct LockScreenMusicPanel: View { 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() @State private var sliderValue: Double = 0 @State private var dragging: Bool = false @State private var isActive = true @State private var isExpanded = false @State private var isVolumeSliderVisible = false @State private var collapseWorkItem: DispatchWorkItem? @Default(.lockScreenGlassStyle) var lockScreenGlassStyle @Default(.lockScreenShowAppIcon) var showAppIcon @Default(.lockScreenPanelShowsBorder) var showPanelBorder @Default(.lockScreenPanelUsesBlur) var enableBlur @Default(.showMediaOutputControl) var showMediaOutputControl @Default(.showShuffleAndRepeat) var showShuffleAndRepeat @Default(.musicAuxLeftControl) private var leftAuxControl @Default(.musicAuxRightControl) private var rightAuxControl @Default(.enableLyrics) private var enableLyrics 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 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 usesLiquidGlass: Bool { if #available(macOS 26.0, *) { return lockScreenGlassStyle == .liquid } return false } var body: some View { if isActive { panelContent } else { Color.clear .frame(width: Self.collapsedSize.width, height: Self.collapsedSize.height) } } private var panelContent: some View { panelCore .frame(width: currentSize.width, height: currentSize.height) .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() } .onDisappear { isActive = false cancelCollapseTimer() isVolumeSliderVisible = 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() } } } @ViewBuilder private var panelCore: some View { Group { if isExpanded { expandedLayout } else { collapsedLayout } } .padding(.horizontal, isExpanded ? 24 : 20) .padding(.vertical, isExpanded ? 22 : 16) .frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading) .background(panelBackground) .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) .overlay { if showPanelBorder { 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)) } 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) .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 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 { 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 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 let progressValue = dragging ? sliderValue : currentSliderValue(timeline.date) if musicManager.isLiveStream { LiveStreamProgressIndicator(tint: sliderColor) .frame(maxWidth: .infinity) } else { HStack(spacing: 8) { // 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 GeometryReader { geometry in ZStack(alignment: .leading) { // Background track RoundedRectangle(cornerRadius: 3) .fill(Color.white.opacity(0.2)) .frame(height: 6) // Filled portion RoundedRectangle(cornerRadius: 3) .fill(sliderColor) .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 } ) } .frame(height: 6) // Time remaining Text("-\(formatTime(max(musicManager.songDuration - progressValue, 0)))") .font(.system(size: 11, weight: .medium, design: .monospaced)) .foregroundColor(.white.opacity(0.7)) .frame(width: 48, alignment: .trailing) } } } .onAppear { sliderValue = musicManager.elapsedTime } .onChange(of: musicManager.isLiveStream) { _, isLive in if isLive { dragging = false sliderValue = 0 } } } private func currentSliderValue(_ date: Date) -> Double { if dragging { return sliderValue } if musicManager.isLiveStream { return 0 } if musicManager.isPlaying { let timeSinceLastUpdate = date.timeIntervalSince(musicManager.timestampDate) let estimatedElapsed = musicManager.elapsedTime + (timeSinceLastUpdate * musicManager.playbackRate) return min(estimatedElapsed, musicManager.songDuration) } return musicManager.elapsedTime } private var sliderColor: Color { switch Defaults[.sliderColor] { case .white: return .white case .albumArt: return Color(nsColor: musicManager.avgColor) case .accent: return .accentColor } } private func formatTime(_ time: TimeInterval) -> String { let minutes = Int(time) / 60 let seconds = Int(time) % 60 return String(format: "%d:%02d", minutes, seconds) } // 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 controls = resolvedAuxControls return HStack(spacing: spacing) { if let leftControl = controls.left { auxButton(for: leftControl) } controlButton(icon: "backward.fill", size: 18) { musicManager.previousTrack() } playPauseButton controlButton(icon: "forward.fill", size: 18) { musicManager.nextTrack() } if let rightControl = controls.right { auxButton(for: rightControl) } } .frame(maxWidth: .infinity, alignment: alignment) } private var playPauseButton: some View { let frameSize: CGFloat = isExpanded ? 72 : 48 let symbolSize: CGFloat = isExpanded ? 30 : 24 return Button(action: { registerInteraction() musicManager.togglePlay() }) { Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: symbolSize, weight: .semibold)) .foregroundColor(.white) .frame(width: frameSize, height: frameSize) .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) } 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 iconSize: CGFloat = isExpanded ? max(size, 24) : size return Button(action: { registerInteraction() action() }) { Image(systemName: icon) .font(.system(size: iconSize, weight: .medium)) .foregroundColor(isActive ? activeColor : .white.opacity(0.8)) .frame(width: frameSize, height: frameSize) .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) } private var mediaOutputControlButton: some View { let frameSize: CGFloat = isExpanded ? 56 : 32 let iconSize: CGFloat = isExpanded ? 26 : 18 return Button(action: toggleVolumeSlider) { Image(systemName: mediaOutputIcon) .font(.system(size: iconSize, weight: .medium)) .foregroundColor(shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8)) .frame(width: frameSize, height: frameSize) .contentShape(Rectangle()) } .accessibilityLabel("Media output") .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 { 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(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 panelBackground: some View { if enableBlur { if usesLiquidGlass { liquidPanelBackground } else { frostedPanelBackground } } else { RoundedRectangle(cornerRadius: panelCornerRadius) .fill(Color.black.opacity(0.45)) } } @ViewBuilder private var liquidPanelBackground: some View { if #available(macOS 26.0, *) { RoundedRectangle(cornerRadius: panelCornerRadius) .glassEffect( .regular .tint(Color.white.opacity(0.12)) .interactive(), in: .rect(cornerRadius: panelCornerRadius) ) } } private var frostedPanelBackground: some View { RoundedRectangle(cornerRadius: panelCornerRadius) .fill(.ultraThinMaterial) } 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 enableBlur { if usesLiquidGlass { if #available(macOS 26.0, *) { RoundedRectangle(cornerRadius: cornerRadius) .glassEffect( .regular .tint(Color.white.opacity(0.16)) .interactive(), in: .rect(cornerRadius: cornerRadius) ) } else { RoundedRectangle(cornerRadius: cornerRadius) .fill(.ultraThinMaterial) } } else { 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 } 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)") } }