Add Gesture controls for media skip and forward

This commit is contained in:
Hariharan Mudaliar 2025-12-24 14:50:36 +05:30
commit d70569e32f
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
9 changed files with 267 additions and 21 deletions

View file

@ -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

View file

@ -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()

View file

@ -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):

View file

@ -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

View file

@ -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) {