Reposition and animations

This commit is contained in:
Hariharan Mudaliar 2025-11-20 10:54:27 +05:30
commit ea2706ad96
2 changed files with 90 additions and 22 deletions

View file

@ -420,18 +420,20 @@ struct LockScreenMusicPanel: View {
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View { private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
let controls = resolvedAuxControls let controls = resolvedAuxControls
let skipNudge: CGFloat = isExpanded ? 14 : 9
return HStack(spacing: spacing) { return HStack(spacing: spacing) {
if let leftControl = controls.left { if let leftControl = controls.left {
auxButton(for: leftControl) auxButton(for: leftControl)
} }
controlButton(icon: "backward.fill", size: 18) { controlButton(icon: "backward.fill", size: 18, pressNudge: -skipNudge) {
musicManager.previousTrack() musicManager.previousTrack()
} }
playPauseButton playPauseButton
controlButton(icon: "forward.fill", size: 18) { controlButton(icon: "forward.fill", size: 18, pressNudge: skipNudge) {
musicManager.nextTrack() musicManager.nextTrack()
} }
@ -453,6 +455,7 @@ struct LockScreenMusicPanel: View {
Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill") Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: symbolSize, weight: .semibold)) .font(.system(size: symbolSize, weight: .semibold))
.foregroundColor(.white) .foregroundColor(.white)
.contentTransition(.symbolEffect(.replace))
.frame(width: frameSize, height: frameSize) .frame(width: frameSize, height: frameSize)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@ -464,37 +467,41 @@ struct LockScreenMusicPanel: View {
size: CGFloat = 18, size: CGFloat = 18,
isActive: Bool = false, isActive: Bool = false,
activeColor: Color = .red, activeColor: Color = .red,
pressNudge: CGFloat? = nil,
action: @escaping () -> Void action: @escaping () -> Void
) -> some View { ) -> some View {
let frameSize: CGFloat = isExpanded ? 56 : 32 let frameSize: CGFloat = isExpanded ? 56 : 32
let iconSize: CGFloat = isExpanded ? max(size, 24) : size 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 Button(action: { return PanelControlButton(
icon: icon,
frameSize: frameSize,
iconSize: iconSize,
iconColor: iconColor,
backgroundOpacity: backgroundOpacity,
pressNudge: pressNudge
) {
registerInteraction() registerInteraction()
action() 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 { private var mediaOutputControlButton: some View {
let frameSize: CGFloat = isExpanded ? 56 : 32 let frameSize: CGFloat = isExpanded ? 56 : 32
let iconSize: CGFloat = isExpanded ? 26 : 18 let iconSize: CGFloat = isExpanded ? 26 : 18
return Button(action: toggleVolumeSlider) { return PanelControlButton(
Image(systemName: mediaOutputIcon) icon: mediaOutputIcon,
.font(.system(size: iconSize, weight: .medium)) frameSize: frameSize,
.foregroundColor(shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8)) iconSize: iconSize,
.frame(width: frameSize, height: frameSize) iconColor: shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8),
.contentShape(Rectangle()) backgroundOpacity: shouldShowVolumeSlider ? 0.22 : 0.0,
} pressNudge: nil,
action: toggleVolumeSlider
)
.accessibilityLabel("Media output") .accessibilityLabel("Media output")
.buttonStyle(PlainButtonStyle())
} }
private func lyricsSection(alignment: Alignment) -> some View { private func lyricsSection(alignment: Alignment) -> some View {
@ -791,3 +798,60 @@ struct LockScreenMusicPanel: View {
print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: \(event) – \(styleDescriptor)") 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 pressNudge: CGFloat?
let action: () -> Void
@State private var isHovering = false
@State private var pressOffset: CGFloat = 0
var body: some View {
Button(action: {
triggerPressEffect()
action()
}) {
RoundedRectangle(cornerRadius: frameSize / 2, style: .continuous)
.fill(backgroundColor)
.overlay(
Image(systemName: icon)
.font(.system(size: iconSize, weight: .medium))
.foregroundStyle(iconColor)
.contentTransition(.symbolEffect(.replace))
)
}
.frame(width: frameSize, height: frameSize)
.buttonStyle(PlainButtonStyle())
.offset(x: pressOffset)
.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() {
guard let pressNudge else { return }
withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) {
pressOffset = pressNudge
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
withAnimation(.spring(response: 0.28, dampingFraction: 0.72)) {
pressOffset = 0
}
}
}
}

View file

@ -38,7 +38,7 @@ final class LockScreenWeatherPanelManager {
let fittingSize = hostingView.fittingSize let fittingSize = hostingView.fittingSize
hostingView.frame = NSRect(origin: .zero, size: fittingSize) hostingView.frame = NSRect(origin: .zero, size: fittingSize)
let targetFrame = frame(for: fittingSize, on: screen) let targetFrame = frame(for: fittingSize, snapshot: snapshot, on: screen)
let window = ensureWindow() let window = ensureWindow()
window.setFrame(targetFrame, display: true) window.setFrame(targetFrame, display: true)
latestFrame = targetFrame latestFrame = targetFrame
@ -78,14 +78,18 @@ final class LockScreenWeatherPanelManager {
return newWindow return newWindow
} }
private func frame(for size: CGSize, on screen: NSScreen) -> NSRect { private func frame(for size: CGSize, snapshot: LockScreenWeatherSnapshot, on screen: NSScreen) -> NSRect {
let screenFrame = screen.frame let screenFrame = screen.frame
let originX = screenFrame.midX - (size.width / 2) let originX = screenFrame.midX - (size.width / 2)
let verticalOffset = screenFrame.height * 0.15 let verticalOffset = screenFrame.height * 0.15
let maxY = screenFrame.maxY - size.height - 48 let maxY = screenFrame.maxY - size.height - 48
let baseY = min(maxY, screenFrame.midY + verticalOffset) let baseY = min(maxY, screenFrame.midY + verticalOffset)
let loweredY = baseY - 36 let loweredY = baseY - 36
let clampedY = max(screenFrame.minY + 80, loweredY)
let inlineLift: CGFloat = snapshot.widgetStyle == .inline ? 28 : 0
let adjustedY = loweredY + inlineLift
let upperClampedY = min(maxY, adjustedY)
let clampedY = max(screenFrame.minY + 80, upperClampedY)
return NSRect(x: originX, y: clampedY, width: size.width, height: size.height) return NSRect(x: originX, y: clampedY, width: size.width, height: size.height)
} }
} }