Update LockScreenMusicPanel for smooth vertical expansion

This commit is contained in:
Hariharan Mudaliar 2026-03-28 12:31:15 +05:30
commit 061564eb8b
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
3 changed files with 156 additions and 104 deletions

View file

@ -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<Content: View>(@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
}