mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Update LockScreenMusicPanel for smooth vertical expansion
This commit is contained in:
parent
699b9402dc
commit
061564eb8b
3 changed files with 156 additions and 104 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue