From 061564eb8be66660df5d16f5b4ab6bfd6b760fd7 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 28 Mar 2026 12:31:15 +0530 Subject: [PATCH] Update LockScreenMusicPanel for smooth vertical expansion --- DynamicIsland/Localizable.xcstrings | 4 + .../LockScreen/LockScreenMusicPanel.swift | 249 +++++++++++------- .../managers/LockScreenPanelManager.swift | 7 +- 3 files changed, 156 insertions(+), 104 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 9389307..a6ab283 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -20363,6 +20363,10 @@ "comment" : "A message displayed when no AirPlay devices are found.", "isCommentAutoGenerated" : true }, + "No AirPlay outputs available" : { + "comment" : "A message displayed when there are no available AirPlay outputs.", + "isCommentAutoGenerated" : true + }, "No audio outputs available" : { "comment" : "Displayed when no audio output devices can be listed in the media output selector.", "isCommentAutoGenerated" : true, diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 198a45f..4dc6eae 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -45,9 +45,8 @@ struct LockScreenMusicPanel: View { @State private var isActive = true @State private var isExpanded = false @State private var isVolumeSliderVisible = false - @State private var isMediaOutputPopoverPresented = false @State private var isAirPlayPopoverPresented = false - @State private var measuredAirPlayHeight: CGFloat? + @State private var collapseWorkItem: DispatchWorkItem? @State private var parallaxResumeWorkItem: DispatchWorkItem? @State private var isParallaxSuspended = false @@ -139,8 +138,8 @@ struct LockScreenMusicPanel: View { } .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) + .animation(.easeInOut(duration: 0.28), value: isExpanded) + .animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider) .onAppear { sliderValue = musicManager.elapsedTime isActive = true @@ -175,8 +174,6 @@ struct LockScreenMusicPanel: View { if useMergedAirPlayOutput { if visible && isAppleMusicActive { Task { await airPlayManager.refreshDevices() } - } else { - measuredAirPlayHeight = nil } } updatePanelSize() @@ -186,6 +183,11 @@ struct LockScreenMusicPanel: View { updatePanelSize() } } + .onChange(of: routeManager.devices) { _, _ in + if !useMergedAirPlayOutput { + updatePanelSize() + } + } .onChange(of: enableLyrics) { _, _ in withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { updatePanelSize() @@ -334,7 +336,7 @@ struct LockScreenMusicPanel: View { private func toggleExpanded() { let newState = !isExpanded suspendParallaxInteraction() - withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) { + withAnimation(.easeInOut(duration: 0.28)) { isExpanded = newState } @@ -353,7 +355,7 @@ struct LockScreenMusicPanel: View { let workItem = DispatchWorkItem { suspendParallaxInteraction() - withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) { + withAnimation(.easeInOut(duration: 0.28)) { isExpanded = false } logPanelAppearance(event: "⏱️ Auto-collapsed") @@ -461,6 +463,10 @@ struct LockScreenMusicPanel: View { 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)) } } @@ -471,9 +477,10 @@ struct LockScreenMusicPanel: View { } .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: shouldShowAirPlay) - .animation(.spring(response: 0.45, dampingFraction: 0.82), value: enableLyrics) + .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 { @@ -570,11 +577,7 @@ struct LockScreenMusicPanel: View { musicManager.toggleRepeat() } case .mediaOutput: - if useMergedAirPlayOutput { - mediaOutputControlButton - } else { - standaloneMediaOutputButton - } + mediaOutputControlButton case .airPlay: if useMergedAirPlayOutput { mediaOutputControlButton @@ -658,36 +661,6 @@ struct LockScreenMusicPanel: View { .accessibilityLabel("Media output") } - private var standaloneMediaOutputButton: some View { - let frameSize: CGFloat = isExpanded ? 56 : 32 - let iconSize: CGFloat = isExpanded ? 26 : 18 - - return PanelControlButton( - icon: mediaOutputIcon, - frameSize: frameSize, - iconSize: iconSize, - iconColor: isMediaOutputPopoverPresented ? .accentColor : .white.opacity(0.8), - backgroundOpacity: isMediaOutputPopoverPresented ? 0.22 : 0.0, - interaction: .none, - symbolEffect: .replace - ) { - registerInteraction() - isMediaOutputPopoverPresented.toggle() - if isMediaOutputPopoverPresented { - routeManager.refreshDevices() - } - } - .accessibilityLabel("Media output") - .popover(isPresented: $isMediaOutputPopoverPresented, arrowEdge: .bottom) { - MediaOutputSelectorPopover( - routeManager: routeManager, - volumeModel: volumeModel, - onHoverChanged: { _ in }, - dismiss: { isMediaOutputPopoverPresented = false } - ) - } - } - private var standaloneAirPlayButton: some View { let frameSize: CGFloat = isExpanded ? 56 : 32 let iconSize: CGFloat = isExpanded ? 26 : 18 @@ -792,58 +765,113 @@ struct LockScreenMusicPanel: View { } private var airPlaySection: some View { - 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) + 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) + } } } - .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) - .background( - RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous) - .fill(sliderBackgroundFill) - ) - .background( - GeometryReader { geo in - Color.clear - .onAppear { measuredAirPlayHeight = geo.size.height } - .onChange(of: geo.size.height) { _, newHeight in - measuredAirPlayHeight = newHeight - updatePanelSize() + } + + 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 { @@ -865,6 +893,14 @@ struct LockScreenMusicPanel: View { useMergedAirPlayOutput && shouldShowVolumeSlider && !airPlayManager.devices.isEmpty } + private var shouldShowRouteSelector: Bool { + !useMergedAirPlayOutput && shouldShowVolumeSlider + } + + private var shouldShowAccessorySection: Bool { + shouldShowAirPlay || shouldShowRouteSelector + } + private var shouldShowVolumeSlider: Bool { showMediaOutputControl && isVolumeSliderVisible } @@ -873,25 +909,31 @@ struct LockScreenMusicPanel: View { sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider) } + private var accessorySectionScrollMaxHeight: CGFloat { + isExpanded ? 170 : 130 + } + private var lyricsExtraHeight: CGFloat { lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics) } - private var airPlayExtraHeight: CGFloat { - guard shouldShowAirPlay else { return 0 } - if let measured = measuredAirPlayHeight { - return measured + 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) } - // Fallback estimate used only until the first GeometryReader measurement arrives - let selectedCount = airPlayManager.devices.filter(\.isSelected).count - let totalCount = airPlayManager.devices.count - let deviceRows: CGFloat = CGFloat(totalCount) * 22 + CGFloat(max(totalCount - 1, 0)) * 6 - let sliders: CGFloat = CGFloat(selectedCount) * 24 - return deviceRows + sliders + 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 + airPlayExtraHeight + lyricsExtraHeight + sliderExtraHeight + accessorySectionExtraHeight + lyricsExtraHeight } private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat { @@ -901,7 +943,7 @@ struct LockScreenMusicPanel: View { private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat { sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) + - airPlayExtraHeight + + accessorySectionExtraHeight + lyricsHeight(forExpanded: expanded, enabled: enableLyrics) } @@ -938,9 +980,12 @@ struct LockScreenMusicPanel: View { let newState = !isVolumeSliderVisible if newState { routeManager.refreshDevices() + if useMergedAirPlayOutput { + Task { await airPlayManager.refreshDevices() } + } } - withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { + withAnimation(.easeInOut(duration: 0.24)) { isVolumeSliderVisible = newState } diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index 5c5dfa7..c259971 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -166,6 +166,8 @@ class LockScreenPanelManager { return } + let resizeDuration: CFTimeInterval = 0.28 + let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize let targetWidth = baseSize.width let targetHeight = baseSize.height + additionalHeight @@ -175,7 +177,7 @@ class LockScreenPanelManager { if animated { NSAnimationContext.runAnimationGroup { context in - context.duration = 0.45 + context.duration = resizeDuration context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) window.animator().setFrame(targetFrame, display: true) } @@ -191,7 +193,8 @@ class LockScreenPanelManager { let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius if animated { CATransaction.begin() - CATransaction.setAnimationDuration(0.28) + CATransaction.setAnimationDuration(resizeDuration) + CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut)) window.contentView?.layer?.cornerRadius = targetRadius CATransaction.commit() } else {