mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Add Gesture controls for media skip and forward
This commit is contained in:
parent
48b3266e76
commit
d70569e32f
9 changed files with 267 additions and 21 deletions
|
|
@ -9,16 +9,19 @@ import SwiftUI
|
|||
|
||||
struct HoverButton: View {
|
||||
var icon: String
|
||||
var iconColor: Color = .white;
|
||||
var iconColor: Color = .white
|
||||
var scale: Image.Scale = .medium
|
||||
var pressEffect: PressEffect? = nil
|
||||
var contentTransition: ContentTransition = .symbolEffect
|
||||
var externalTriggerToken: Int? = nil
|
||||
var externalTriggerEffect: PressEffect? = nil
|
||||
var action: () -> Void
|
||||
var contentTransition: ContentTransition = .symbolEffect;
|
||||
|
||||
@State private var isHovering = false
|
||||
@State private var pressOffset: CGFloat = 0
|
||||
@State private var wiggleAngle: Double = 0
|
||||
@State private var wiggleToken: Int = 0
|
||||
@State private var lastExternalTriggerToken: Int?
|
||||
|
||||
var body: some View {
|
||||
let size = CGFloat(scale == .large ? 40 : 30)
|
||||
|
|
@ -66,12 +69,17 @@ struct HoverButton: View {
|
|||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
.onChange(of: externalTriggerToken) { _, newToken in
|
||||
guard let newToken, newToken != lastExternalTriggerToken else { return }
|
||||
lastExternalTriggerToken = newToken
|
||||
triggerPressEffect(override: externalTriggerEffect)
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerPressEffect() {
|
||||
guard let pressEffect else { return }
|
||||
private func triggerPressEffect(override: PressEffect? = nil) {
|
||||
guard let effect = override ?? pressEffect else { return }
|
||||
|
||||
switch pressEffect {
|
||||
switch effect {
|
||||
case .nudge(let amount):
|
||||
withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) {
|
||||
pressOffset = amount
|
||||
|
|
|
|||
|
|
@ -8,9 +8,14 @@ struct MusicControlOverlay: View {
|
|||
@ObservedObject private var musicManager = MusicManager.shared
|
||||
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
||||
|
||||
private let seekInterval: TimeInterval = 10
|
||||
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
|
||||
}
|
||||
|
|
@ -39,14 +44,14 @@ struct MusicControlOverlay: View {
|
|||
case .track:
|
||||
return ButtonConfig(
|
||||
icon: "backward.fill",
|
||||
pressEffect: .nudge(-skipPressMagnitude),
|
||||
pressEffect: trackBackwardPressEffect,
|
||||
symbolEffect: .replace,
|
||||
action: { MusicManager.shared.previousTrack() }
|
||||
)
|
||||
case .tenSecond:
|
||||
return ButtonConfig(
|
||||
icon: "gobackward.10",
|
||||
pressEffect: .wiggle(.counterClockwise),
|
||||
pressEffect: tenSecondBackwardPressEffect,
|
||||
symbolEffect: .wiggle,
|
||||
action: { MusicManager.shared.seek(by: -seekInterval) }
|
||||
)
|
||||
|
|
@ -58,14 +63,14 @@ struct MusicControlOverlay: View {
|
|||
case .track:
|
||||
return ButtonConfig(
|
||||
icon: "forward.fill",
|
||||
pressEffect: .nudge(skipPressMagnitude),
|
||||
pressEffect: trackForwardPressEffect,
|
||||
symbolEffect: .replace,
|
||||
action: { MusicManager.shared.nextTrack() }
|
||||
)
|
||||
case .tenSecond:
|
||||
return ButtonConfig(
|
||||
icon: "goforward.10",
|
||||
pressEffect: .wiggle(.clockwise),
|
||||
pressEffect: tenSecondForwardPressEffect,
|
||||
symbolEffect: .wiggle,
|
||||
action: { MusicManager.shared.seek(by: seekInterval) }
|
||||
)
|
||||
|
|
@ -84,6 +89,9 @@ struct MusicControlOverlay: View {
|
|||
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,
|
||||
|
|
@ -92,6 +100,8 @@ struct MusicControlOverlay: View {
|
|||
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
|
||||
)
|
||||
|
|
@ -103,6 +113,8 @@ struct MusicControlOverlay: View {
|
|||
foregroundColor: .white,
|
||||
pressEffect: playPauseConfig.pressEffect,
|
||||
symbolEffectStyle: playPauseConfig.symbolEffect,
|
||||
externalTriggerToken: nil,
|
||||
externalTriggerEffect: nil,
|
||||
isEnabled: controlsEnabled,
|
||||
action: playPauseConfig.action
|
||||
)
|
||||
|
|
@ -114,6 +126,8 @@ struct MusicControlOverlay: View {
|
|||
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
|
||||
)
|
||||
|
|
@ -147,6 +161,8 @@ private struct FloatingMediaButton: View {
|
|||
let foregroundColor: Color
|
||||
let pressEffect: PressEffect
|
||||
let symbolEffectStyle: SymbolEffectStyle
|
||||
let externalTriggerToken: Int?
|
||||
let externalTriggerEffect: PressEffect?
|
||||
let isEnabled: Bool
|
||||
let action: () -> Void
|
||||
|
||||
|
|
@ -154,6 +170,7 @@ private struct FloatingMediaButton: View {
|
|||
@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 {
|
||||
|
|
@ -178,6 +195,12 @@ private struct FloatingMediaButton: View {
|
|||
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 {
|
||||
|
|
@ -219,8 +242,10 @@ private struct FloatingMediaButton: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func triggerPressEffect() {
|
||||
switch pressEffect {
|
||||
private func triggerPressEffect(override: PressEffect? = nil) {
|
||||
let activeEffect = override ?? pressEffect
|
||||
|
||||
switch activeEffect {
|
||||
case .none:
|
||||
return
|
||||
case .nudge(let amount):
|
||||
|
|
@ -269,6 +294,28 @@ private struct FloatingMediaButton: View {
|
|||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -674,6 +674,11 @@ private struct MinimalisticReminderDetailsView: View {
|
|||
)
|
||||
}
|
||||
|
||||
private struct SkipTrigger {
|
||||
let token: Int
|
||||
let pressEffect: MinimalisticSquircircleButton.PressEffect
|
||||
}
|
||||
|
||||
private func controlButton(
|
||||
icon: String,
|
||||
size: CGFloat = 18,
|
||||
|
|
@ -681,6 +686,7 @@ private struct MinimalisticReminderDetailsView: View {
|
|||
activeColor: Color = .red,
|
||||
pressEffect: MinimalisticSquircircleButton.PressEffect = .none,
|
||||
symbolEffect: MinimalisticSquircircleButton.SymbolEffectStyle = .none,
|
||||
trigger: SkipTrigger? = nil,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
MinimalisticSquircircleButton(
|
||||
|
|
@ -692,6 +698,8 @@ private struct MinimalisticReminderDetailsView: View {
|
|||
foregroundColor: isActive ? activeColor : .white.opacity(0.85),
|
||||
pressEffect: pressEffect,
|
||||
symbolEffectStyle: symbolEffect,
|
||||
externalTriggerToken: trigger?.token,
|
||||
externalTriggerEffect: trigger?.pressEffect,
|
||||
action: action
|
||||
)
|
||||
}
|
||||
|
|
@ -723,6 +731,7 @@ private struct MinimalisticReminderDetailsView: View {
|
|||
size: 18,
|
||||
pressEffect: .nudge(-skipMagnitude),
|
||||
symbolEffect: .replace,
|
||||
trigger: skipGestureTrigger(for: .trackBackward),
|
||||
action: { musicManager.previousTrack() }
|
||||
)
|
||||
case .trackForward:
|
||||
|
|
@ -731,6 +740,7 @@ private struct MinimalisticReminderDetailsView: View {
|
|||
size: 18,
|
||||
pressEffect: .nudge(skipMagnitude),
|
||||
symbolEffect: .replace,
|
||||
trigger: skipGestureTrigger(for: .trackForward),
|
||||
action: { musicManager.nextTrack() }
|
||||
)
|
||||
case .seekBackward:
|
||||
|
|
@ -739,6 +749,7 @@ private struct MinimalisticReminderDetailsView: View {
|
|||
size: 18,
|
||||
pressEffect: .wiggle(.counterClockwise),
|
||||
symbolEffect: .wiggle,
|
||||
trigger: skipGestureTrigger(for: .seekBackward),
|
||||
action: { musicManager.seek(by: -seekInterval) }
|
||||
)
|
||||
case .seekForward:
|
||||
|
|
@ -747,6 +758,7 @@ private struct MinimalisticReminderDetailsView: View {
|
|||
size: 18,
|
||||
pressEffect: .wiggle(.clockwise),
|
||||
symbolEffect: .wiggle,
|
||||
trigger: skipGestureTrigger(for: .seekForward),
|
||||
action: { musicManager.seek(by: seekInterval) }
|
||||
)
|
||||
case .shuffle:
|
||||
|
|
@ -770,6 +782,23 @@ private struct MinimalisticReminderDetailsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func skipGestureTrigger(for control: MusicControlButton) -> SkipTrigger? {
|
||||
guard let pulse = musicManager.skipGesturePulse else { return nil }
|
||||
|
||||
switch control {
|
||||
case .trackBackward where pulse.behavior == .track && pulse.direction == .backward:
|
||||
return SkipTrigger(token: pulse.token, pressEffect: .nudge(-skipMagnitude))
|
||||
case .trackForward where pulse.behavior == .track && pulse.direction == .forward:
|
||||
return SkipTrigger(token: pulse.token, pressEffect: .nudge(skipMagnitude))
|
||||
case .seekBackward where pulse.behavior == .tenSecond && pulse.direction == .backward:
|
||||
return SkipTrigger(token: pulse.token, pressEffect: .wiggle(.counterClockwise))
|
||||
case .seekForward where pulse.behavior == .tenSecond && pulse.direction == .forward:
|
||||
return SkipTrigger(token: pulse.token, pressEffect: .wiggle(.clockwise))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
private struct MinimalisticMediaOutputButton: View {
|
||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||
|
|
@ -901,12 +930,15 @@ private struct MinimalisticSquircircleButton: View {
|
|||
let foregroundColor: Color
|
||||
let pressEffect: PressEffect
|
||||
let symbolEffectStyle: SymbolEffectStyle
|
||||
let externalTriggerToken: Int?
|
||||
let externalTriggerEffect: PressEffect?
|
||||
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?
|
||||
|
||||
init(
|
||||
icon: String,
|
||||
|
|
@ -917,6 +949,8 @@ private struct MinimalisticSquircircleButton: View {
|
|||
foregroundColor: Color,
|
||||
pressEffect: PressEffect = .none,
|
||||
symbolEffectStyle: SymbolEffectStyle = .none,
|
||||
externalTriggerToken: Int? = nil,
|
||||
externalTriggerEffect: PressEffect? = nil,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.icon = icon
|
||||
|
|
@ -927,6 +961,8 @@ private struct MinimalisticSquircircleButton: View {
|
|||
self.foregroundColor = foregroundColor
|
||||
self.pressEffect = pressEffect
|
||||
self.symbolEffectStyle = symbolEffectStyle
|
||||
self.externalTriggerToken = externalTriggerToken
|
||||
self.externalTriggerEffect = externalTriggerEffect
|
||||
self.action = action
|
||||
}
|
||||
|
||||
|
|
@ -951,10 +987,17 @@ private struct MinimalisticSquircircleButton: View {
|
|||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
.onChange(of: externalTriggerToken) { _, newToken in
|
||||
guard let newToken, newToken != lastExternalTriggerToken else { return }
|
||||
lastExternalTriggerToken = newToken
|
||||
triggerPressEffect(override: externalTriggerEffect)
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerPressEffect() {
|
||||
switch pressEffect {
|
||||
private func triggerPressEffect(override: PressEffect? = nil) {
|
||||
let effect = override ?? pressEffect
|
||||
|
||||
switch effect {
|
||||
case .none:
|
||||
return
|
||||
case .nudge(let amount):
|
||||
|
|
|
|||
|
|
@ -271,19 +271,35 @@ struct MusicControlsView: View {
|
|||
MusicManager.shared.togglePlay()
|
||||
}
|
||||
case .trackBackward:
|
||||
playbackButton(icon: "backward.fill", press: .nudge(-skipMagnitude)) {
|
||||
playbackButton(
|
||||
icon: "backward.fill",
|
||||
press: .nudge(-skipMagnitude),
|
||||
trigger: skipGestureTrigger(for: .trackBackward)
|
||||
) {
|
||||
musicManager.previousTrack()
|
||||
}
|
||||
case .trackForward:
|
||||
playbackButton(icon: "forward.fill", press: .nudge(skipMagnitude)) {
|
||||
playbackButton(
|
||||
icon: "forward.fill",
|
||||
press: .nudge(skipMagnitude),
|
||||
trigger: skipGestureTrigger(for: .trackForward)
|
||||
) {
|
||||
musicManager.nextTrack()
|
||||
}
|
||||
case .seekBackward:
|
||||
playbackButton(icon: "gobackward.10", press: .wiggle(.counterClockwise)) {
|
||||
playbackButton(
|
||||
icon: "gobackward.10",
|
||||
press: .wiggle(.counterClockwise),
|
||||
trigger: skipGestureTrigger(for: .seekBackward)
|
||||
) {
|
||||
musicManager.seek(by: -seekInterval)
|
||||
}
|
||||
case .seekForward:
|
||||
playbackButton(icon: "goforward.10", press: .wiggle(.clockwise)) {
|
||||
playbackButton(
|
||||
icon: "goforward.10",
|
||||
press: .wiggle(.clockwise),
|
||||
trigger: skipGestureTrigger(for: .seekForward)
|
||||
) {
|
||||
musicManager.seek(by: seekInterval)
|
||||
}
|
||||
case .shuffle:
|
||||
|
|
@ -315,19 +331,44 @@ struct MusicControlsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct SkipTrigger {
|
||||
let token: Int
|
||||
let pressEffect: HoverButton.PressEffect
|
||||
}
|
||||
|
||||
private func playbackButton(
|
||||
icon: String,
|
||||
press: HoverButton.PressEffect?,
|
||||
trigger: SkipTrigger?,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HoverButton(
|
||||
icon: icon,
|
||||
scale: .medium,
|
||||
pressEffect: press
|
||||
pressEffect: press,
|
||||
externalTriggerToken: trigger?.token,
|
||||
externalTriggerEffect: trigger?.pressEffect
|
||||
) {
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
private func skipGestureTrigger(for control: MusicControlButton) -> SkipTrigger? {
|
||||
guard let pulse = musicManager.skipGesturePulse else { return nil }
|
||||
|
||||
switch control {
|
||||
case .trackBackward where pulse.behavior == .track && pulse.direction == .backward:
|
||||
return SkipTrigger(token: pulse.token, pressEffect: .nudge(-skipMagnitude))
|
||||
case .trackForward where pulse.behavior == .track && pulse.direction == .forward:
|
||||
return SkipTrigger(token: pulse.token, pressEffect: .nudge(skipMagnitude))
|
||||
case .seekBackward where pulse.behavior == .tenSecond && pulse.direction == .backward:
|
||||
return SkipTrigger(token: pulse.token, pressEffect: .wiggle(.counterClockwise))
|
||||
case .seekForward where pulse.behavior == .tenSecond && pulse.direction == .forward:
|
||||
return SkipTrigger(token: pulse.token, pressEffect: .wiggle(.clockwise))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main View
|
||||
|
|
|
|||
|
|
@ -831,6 +831,8 @@ struct GeneralSettings: View {
|
|||
@Default(.enableGestures) var enableGestures
|
||||
@Default(.openNotchOnHover) var openNotchOnHover
|
||||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||||
@Default(.enableHorizontalMusicGestures) var enableHorizontalMusicGestures
|
||||
@Default(.musicGestureBehavior) var musicGestureBehavior
|
||||
|
||||
private func highlightID(_ title: String) -> String {
|
||||
SettingsTab.general.highlightID(for: title)
|
||||
|
|
@ -973,8 +975,24 @@ struct GeneralSettings: View {
|
|||
.disabled(!openNotchOnHover)
|
||||
.settingsHighlight(id: highlightID("Enable gestures"))
|
||||
if enableGestures {
|
||||
Toggle("Media change with horizontal gestures", isOn: .constant(false))
|
||||
.disabled(true)
|
||||
Defaults.Toggle("Media change with horizontal gestures", key: .enableHorizontalMusicGestures)
|
||||
.settingsHighlight(id: highlightID("Horizontal media gestures"))
|
||||
|
||||
if enableHorizontalMusicGestures {
|
||||
Picker("Gesture skip behavior", selection: $musicGestureBehavior) {
|
||||
ForEach(MusicSkipBehavior.allCases) { behavior in
|
||||
Text(behavior.displayName)
|
||||
.tag(behavior)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.settingsHighlight(id: highlightID("Gesture skip behavior"))
|
||||
|
||||
Text(musicGestureBehavior.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Defaults.Toggle("Close gesture", key: .closeGestureEnabled)
|
||||
.settingsHighlight(id: highlightID("Close gesture"))
|
||||
Slider(value: $gestureSensitivity, in: 100...300, step: 100) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue