mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
323 lines
11 KiB
Swift
323 lines
11 KiB
Swift
import SwiftUI
|
|
import Defaults
|
|
|
|
struct MusicControlOverlay: View {
|
|
let notchHeight: CGFloat
|
|
let cornerRadius: CGFloat
|
|
|
|
@ObservedObject private var musicManager = MusicManager.shared
|
|
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
|
|
|
private let seekInterval: TimeInterval = MusicManager.skipGestureSeekInterval
|
|
private let skipPressMagnitude: CGFloat = 8
|
|
|
|
private var trackBackwardPressEffect: FloatingMediaButton.PressEffect { .nudge(-skipPressMagnitude) }
|
|
private var trackForwardPressEffect: FloatingMediaButton.PressEffect { .nudge(skipPressMagnitude) }
|
|
private var tenSecondBackwardPressEffect: FloatingMediaButton.PressEffect { .wiggle(.counterClockwise) }
|
|
private var tenSecondForwardPressEffect: FloatingMediaButton.PressEffect { .wiggle(.clockwise) }
|
|
|
|
private var controlsEnabled: Bool {
|
|
!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil
|
|
}
|
|
|
|
private var buttonSide: CGFloat {
|
|
let preferred = notchHeight * 0.94
|
|
let lowerBound = max(30, notchHeight * 0.8)
|
|
let upperBound = max(notchHeight + 2, 32)
|
|
return min(max(preferred, lowerBound), upperBound)
|
|
}
|
|
|
|
private var playPauseSide: CGFloat {
|
|
min(buttonSide + 8, notchHeight + 4)
|
|
}
|
|
|
|
private var buttonCornerRadius: CGFloat {
|
|
max(cornerRadius - 8, buttonSide * 0.28)
|
|
}
|
|
|
|
private var windowCornerRadius: CGFloat {
|
|
max(cornerRadius - 6, 12)
|
|
}
|
|
|
|
private var backwardConfig: ButtonConfig {
|
|
switch musicSkipBehavior {
|
|
case .track:
|
|
return ButtonConfig(
|
|
icon: "backward.fill",
|
|
pressEffect: trackBackwardPressEffect,
|
|
symbolEffect: .replace,
|
|
action: { MusicManager.shared.previousTrack() }
|
|
)
|
|
case .tenSecond:
|
|
return ButtonConfig(
|
|
icon: "gobackward.10",
|
|
pressEffect: tenSecondBackwardPressEffect,
|
|
symbolEffect: .wiggle,
|
|
action: { MusicManager.shared.seek(by: -seekInterval) }
|
|
)
|
|
}
|
|
}
|
|
|
|
private var forwardConfig: ButtonConfig {
|
|
switch musicSkipBehavior {
|
|
case .track:
|
|
return ButtonConfig(
|
|
icon: "forward.fill",
|
|
pressEffect: trackForwardPressEffect,
|
|
symbolEffect: .replace,
|
|
action: { MusicManager.shared.nextTrack() }
|
|
)
|
|
case .tenSecond:
|
|
return ButtonConfig(
|
|
icon: "goforward.10",
|
|
pressEffect: tenSecondForwardPressEffect,
|
|
symbolEffect: .wiggle,
|
|
action: { MusicManager.shared.seek(by: seekInterval) }
|
|
)
|
|
}
|
|
}
|
|
|
|
private var playPauseConfig: ButtonConfig {
|
|
ButtonConfig(
|
|
icon: musicManager.isPlaying ? "pause.fill" : "play.fill",
|
|
pressEffect: .none,
|
|
symbolEffect: .replace,
|
|
action: { MusicManager.shared.togglePlay() }
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
let verticalPadding = max(8, notchHeight * 0.12)
|
|
|
|
let backwardGestureTrigger = skipGestureTrigger(for: .backward)
|
|
let forwardGestureTrigger = skipGestureTrigger(for: .forward)
|
|
|
|
HStack(spacing: 18) {
|
|
FloatingMediaButton(
|
|
icon: backwardConfig.icon,
|
|
frameSize: CGSize(width: buttonSide, height: buttonSide),
|
|
cornerRadius: buttonCornerRadius,
|
|
foregroundColor: .white.opacity(controlsEnabled ? 0.9 : 0.35),
|
|
pressEffect: backwardConfig.pressEffect,
|
|
symbolEffectStyle: backwardConfig.symbolEffect,
|
|
externalTriggerToken: backwardGestureTrigger?.token,
|
|
externalTriggerEffect: backwardGestureTrigger?.pressEffect,
|
|
isEnabled: controlsEnabled,
|
|
action: backwardConfig.action
|
|
)
|
|
|
|
FloatingMediaButton(
|
|
icon: playPauseConfig.icon,
|
|
frameSize: CGSize(width: playPauseSide, height: playPauseSide),
|
|
cornerRadius: max(buttonCornerRadius + 2, playPauseSide * 0.32),
|
|
foregroundColor: .white,
|
|
pressEffect: playPauseConfig.pressEffect,
|
|
symbolEffectStyle: playPauseConfig.symbolEffect,
|
|
externalTriggerToken: nil,
|
|
externalTriggerEffect: nil,
|
|
isEnabled: controlsEnabled,
|
|
action: playPauseConfig.action
|
|
)
|
|
|
|
FloatingMediaButton(
|
|
icon: forwardConfig.icon,
|
|
frameSize: CGSize(width: buttonSide, height: buttonSide),
|
|
cornerRadius: buttonCornerRadius,
|
|
foregroundColor: .white.opacity(controlsEnabled ? 0.9 : 0.35),
|
|
pressEffect: forwardConfig.pressEffect,
|
|
symbolEffectStyle: forwardConfig.symbolEffect,
|
|
externalTriggerToken: forwardGestureTrigger?.token,
|
|
externalTriggerEffect: forwardGestureTrigger?.pressEffect,
|
|
isEnabled: controlsEnabled,
|
|
action: forwardConfig.action
|
|
)
|
|
}
|
|
.padding(.vertical, verticalPadding)
|
|
.padding(.horizontal, 18)
|
|
.frame(height: notchHeight)
|
|
.frame(minWidth: buttonSide * 3.2)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: windowCornerRadius, style: .continuous)
|
|
.fill(Color.black)
|
|
}
|
|
.compositingGroup()
|
|
.animation(.smooth(duration: 0.2), value: musicManager.isPlaying)
|
|
.animation(.smooth(duration: 0.2), value: musicSkipBehavior)
|
|
.animation(.smooth(duration: 0.2), value: controlsEnabled)
|
|
}
|
|
|
|
private struct ButtonConfig {
|
|
let icon: String
|
|
let pressEffect: FloatingMediaButton.PressEffect
|
|
let symbolEffect: FloatingMediaButton.SymbolEffectStyle
|
|
let action: () -> Void
|
|
}
|
|
}
|
|
|
|
private struct FloatingMediaButton: View {
|
|
let icon: String
|
|
let frameSize: CGSize
|
|
let cornerRadius: CGFloat
|
|
let foregroundColor: Color
|
|
let pressEffect: PressEffect
|
|
let symbolEffectStyle: SymbolEffectStyle
|
|
let externalTriggerToken: Int?
|
|
let externalTriggerEffect: PressEffect?
|
|
let isEnabled: Bool
|
|
let action: () -> Void
|
|
|
|
@State private var isHovering = false
|
|
@State private var pressOffset: CGFloat = 0
|
|
@State private var rotationAngle: Double = 0
|
|
@State private var wiggleToken: Int = 0
|
|
@State private var lastExternalTriggerToken: Int?
|
|
|
|
var body: some View {
|
|
Button {
|
|
guard isEnabled else { return }
|
|
triggerPressEffect()
|
|
action()
|
|
} label: {
|
|
iconView
|
|
.frame(width: frameSize.width, height: frameSize.height)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
.fill(isHovering && isEnabled ? Color.white.opacity(0.18) : .clear)
|
|
)
|
|
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
|
}
|
|
.buttonStyle(PlainButtonStyle())
|
|
.offset(x: pressOffset)
|
|
.rotationEffect(.degrees(rotationAngle))
|
|
.disabled(!isEnabled)
|
|
.onHover { hovering in
|
|
withAnimation(.easeOut(duration: 0.18)) {
|
|
isHovering = hovering
|
|
}
|
|
}
|
|
.onChange(of: externalTriggerToken) { _, newToken in
|
|
guard isEnabled, let newToken else { return }
|
|
guard newToken != lastExternalTriggerToken else { return }
|
|
lastExternalTriggerToken = newToken
|
|
triggerPressEffect(override: externalTriggerEffect)
|
|
}
|
|
}
|
|
|
|
private var iconView: some View {
|
|
let image = Image(systemName: icon)
|
|
.font(.system(size: min(frameSize.width, frameSize.height) * 0.48, weight: .semibold))
|
|
.foregroundColor(foregroundColor)
|
|
|
|
switch symbolEffectStyle {
|
|
case .none:
|
|
return AnyView(image)
|
|
case .replace:
|
|
if #available(macOS 14.0, *) {
|
|
return AnyView(image.contentTransition(.symbolEffect(.replace)))
|
|
} else {
|
|
return AnyView(image)
|
|
}
|
|
case .replaceAndBounce:
|
|
if #available(macOS 14.0, *) {
|
|
return AnyView(
|
|
image
|
|
.contentTransition(.symbolEffect(.replace))
|
|
.symbolEffect(.bounce, value: icon)
|
|
)
|
|
} else {
|
|
return AnyView(image)
|
|
}
|
|
case .wiggle:
|
|
if #available(macOS 15.0, *) {
|
|
return AnyView(
|
|
image.symbolEffect(
|
|
.wiggle.byLayer,
|
|
options: .nonRepeating,
|
|
value: wiggleToken
|
|
)
|
|
)
|
|
} else {
|
|
return AnyView(image)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func triggerPressEffect(override: PressEffect? = nil) {
|
|
let activeEffect = override ?? pressEffect
|
|
|
|
switch activeEffect {
|
|
case .none:
|
|
return
|
|
case .nudge(let amount):
|
|
withAnimation(.spring(response: 0.16, dampingFraction: 0.72)) {
|
|
pressOffset = amount
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
|
|
withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) {
|
|
pressOffset = 0
|
|
}
|
|
}
|
|
case .wiggle(let direction):
|
|
guard #available(macOS 14.0, *) else { return }
|
|
wiggleToken += 1
|
|
let angle: Double = direction == .clockwise ? 11 : -11
|
|
|
|
withAnimation(.spring(response: 0.18, dampingFraction: 0.52)) {
|
|
rotationAngle = angle
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.76)) {
|
|
rotationAngle = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum PressEffect {
|
|
case none
|
|
case nudge(CGFloat)
|
|
case wiggle(WiggleDirection)
|
|
}
|
|
|
|
enum SymbolEffectStyle {
|
|
case none
|
|
case replace
|
|
case replaceAndBounce
|
|
case wiggle
|
|
}
|
|
|
|
enum WiggleDirection {
|
|
case clockwise
|
|
case counterClockwise
|
|
}
|
|
}
|
|
|
|
private extension MusicControlOverlay {
|
|
func skipGestureTrigger(for direction: MusicManager.SkipDirection) -> (token: Int, pressEffect: FloatingMediaButton.PressEffect)? {
|
|
guard let pulse = musicManager.skipGesturePulse, pulse.direction == direction else {
|
|
return nil
|
|
}
|
|
|
|
let effect: FloatingMediaButton.PressEffect
|
|
switch (pulse.behavior, direction) {
|
|
case (.track, .backward):
|
|
effect = trackBackwardPressEffect
|
|
case (.track, .forward):
|
|
effect = trackForwardPressEffect
|
|
case (.tenSecond, .backward):
|
|
effect = tenSecondBackwardPressEffect
|
|
case (.tenSecond, .forward):
|
|
effect = tenSecondForwardPressEffect
|
|
}
|
|
|
|
return (token: pulse.token, pressEffect: effect)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
MusicControlOverlay(notchHeight: 34, cornerRadius: 14)
|
|
.padding()
|
|
.background(Color.gray.opacity(0.2))
|
|
}
|