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
|
|
@ -39,6 +39,7 @@ struct ContentView: View {
|
|||
@Default(.enableReminderLiveActivity) var enableReminderLiveActivity
|
||||
@Default(.enableTimerFeature) var enableTimerFeature
|
||||
@Default(.timerDisplayMode) var timerDisplayMode
|
||||
@Default(.enableHorizontalMusicGestures) var enableHorizontalMusicGestures
|
||||
|
||||
// Dynamic sizing based on view type and graph count with smooth transitions
|
||||
var dynamicNotchSize: CGSize {
|
||||
|
|
@ -64,6 +65,7 @@ struct ContentView: View {
|
|||
@State private var lastHapticTime: Date = Date()
|
||||
|
||||
@State private var gestureProgress: CGFloat = .zero
|
||||
@State private var skipGestureActiveDirection: MusicManager.SkipDirection?
|
||||
@State private var isMusicControlWindowVisible = false
|
||||
@State private var pendingMusicControlTask: Task<Void, Never>?
|
||||
@State private var musicControlHideTask: Task<Void, Never>?
|
||||
|
|
@ -212,6 +214,12 @@ struct ContentView: View {
|
|||
.panGesture(direction: .down) { translation, phase in
|
||||
handleDownGesture(translation: translation, phase: phase)
|
||||
}
|
||||
.panGesture(direction: .left) { translation, phase in
|
||||
handleSkipGesture(direction: .forward, translation: translation, phase: phase)
|
||||
}
|
||||
.panGesture(direction: .right) { translation, phase in
|
||||
handleSkipGesture(direction: .backward, translation: translation, phase: phase)
|
||||
}
|
||||
}
|
||||
}
|
||||
.conditionalModifier(Defaults[.closeGestureEnabled] && Defaults[.enableGestures] && interactionsEnabled) { view in
|
||||
|
|
@ -910,6 +918,37 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func handleSkipGesture(direction: MusicManager.SkipDirection, translation: CGFloat, phase: NSEvent.Phase) {
|
||||
if phase == .ended {
|
||||
skipGestureActiveDirection = nil
|
||||
return
|
||||
}
|
||||
|
||||
guard canPerformSkipGesture() else {
|
||||
skipGestureActiveDirection = nil
|
||||
return
|
||||
}
|
||||
|
||||
if skipGestureActiveDirection == nil && translation > Defaults[.gestureSensitivity] {
|
||||
skipGestureActiveDirection = direction
|
||||
|
||||
if Defaults[.enableHaptics] {
|
||||
triggerHapticIfAllowed()
|
||||
}
|
||||
|
||||
musicManager.handleSkipGesture(direction: direction)
|
||||
}
|
||||
}
|
||||
|
||||
private func canPerformSkipGesture() -> Bool {
|
||||
enableHorizontalMusicGestures
|
||||
&& vm.notchState == .open
|
||||
&& coordinator.currentView == .home
|
||||
&& (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil)
|
||||
&& !lockScreenManager.isLocked
|
||||
&& !hasAnyActivePopovers()
|
||||
}
|
||||
|
||||
private func handleMusicControlPlaybackChange(isPlaying: Bool) {
|
||||
guard musicControlWindowEnabled else { return }
|
||||
|
||||
|
|
|
|||
|
|
@ -9218,6 +9218,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Gesture skip behavior" : {
|
||||
"comment" : "A picker that lets the user choose how a music player skips tracks using horizontal gestures.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Get started" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -11758,6 +11762,7 @@
|
|||
},
|
||||
"Media change with horizontal gestures" : {
|
||||
"comment" : "A toggle that lets the user enable or disable the feature of changing the currently playing media using horizontal gestures.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,19 @@ let defaultImage: NSImage = .init(
|
|||
)!
|
||||
|
||||
class MusicManager: ObservableObject {
|
||||
enum SkipDirection: Equatable {
|
||||
case backward
|
||||
case forward
|
||||
}
|
||||
|
||||
struct SkipGesturePulse: Equatable {
|
||||
let token: Int
|
||||
let direction: SkipDirection
|
||||
let behavior: MusicSkipBehavior
|
||||
}
|
||||
|
||||
static let skipGestureSeekInterval: TimeInterval = 10
|
||||
|
||||
// MARK: - Properties
|
||||
static let shared = MusicManager()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
|
@ -61,6 +74,7 @@ class MusicManager: ObservableObject {
|
|||
@Published var isLiveStream: Bool = false
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@Published var usingAppIconForArtwork: Bool = false
|
||||
@Published private(set) var skipGesturePulse: SkipGesturePulse?
|
||||
|
||||
// MARK: - Lyrics Properties
|
||||
@Published var currentLyrics: String = ""
|
||||
|
|
@ -89,6 +103,7 @@ class MusicManager: ObservableObject {
|
|||
|
||||
@Published var isTransitioning: Bool = false
|
||||
private var transitionWorkItem: DispatchWorkItem?
|
||||
private var skipGestureToken: Int = 0
|
||||
|
||||
// MARK: - Initialization
|
||||
init() {
|
||||
|
|
@ -556,6 +571,34 @@ class MusicManager: ObservableObject {
|
|||
seek(to: target)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleSkipGesture(direction: SkipDirection) {
|
||||
guard Defaults[.enableHorizontalMusicGestures] else { return }
|
||||
guard !isPlayerIdle || bundleIdentifier != nil else { return }
|
||||
|
||||
let behavior = Defaults[.musicGestureBehavior]
|
||||
|
||||
switch behavior {
|
||||
case .track:
|
||||
if direction == .forward {
|
||||
nextTrack()
|
||||
} else {
|
||||
previousTrack()
|
||||
}
|
||||
case .tenSecond:
|
||||
let interval = Self.skipGestureSeekInterval
|
||||
let offset = direction == .forward ? interval : -interval
|
||||
seek(by: offset)
|
||||
}
|
||||
|
||||
skipGestureToken = skipGestureToken &+ 1
|
||||
skipGesturePulse = SkipGesturePulse(
|
||||
token: skipGestureToken,
|
||||
direction: direction,
|
||||
behavior: behavior
|
||||
)
|
||||
}
|
||||
|
||||
func openMusicApp() {
|
||||
guard let bundleID = bundleIdentifier else {
|
||||
print("Error: appBundleIdentifier is nil")
|
||||
|
|
|
|||
|
|
@ -435,6 +435,8 @@ extension Defaults.Keys {
|
|||
static let enableGestures = Key<Bool>("enableGestures", default: true)
|
||||
static let closeGestureEnabled = Key<Bool>("closeGestureEnabled", default: true)
|
||||
static let gestureSensitivity = Key<CGFloat>("gestureSensitivity", default: 200.0)
|
||||
static let enableHorizontalMusicGestures = Key<Bool>("enableHorizontalMusicGestures", default: true)
|
||||
static let musicGestureBehavior = Key<MusicSkipBehavior>("musicGestureBehavior", default: .track)
|
||||
|
||||
// MARK: Media playback
|
||||
static let coloredSpectrogram = Key<Bool>("coloredSpectrogram", default: true)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue