mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
commit
4e05de4131
10 changed files with 273 additions and 21 deletions
|
|
@ -39,6 +39,7 @@ struct ContentView: View {
|
||||||
@Default(.enableReminderLiveActivity) var enableReminderLiveActivity
|
@Default(.enableReminderLiveActivity) var enableReminderLiveActivity
|
||||||
@Default(.enableTimerFeature) var enableTimerFeature
|
@Default(.enableTimerFeature) var enableTimerFeature
|
||||||
@Default(.timerDisplayMode) var timerDisplayMode
|
@Default(.timerDisplayMode) var timerDisplayMode
|
||||||
|
@Default(.enableHorizontalMusicGestures) var enableHorizontalMusicGestures
|
||||||
|
|
||||||
// Dynamic sizing based on view type and graph count with smooth transitions
|
// Dynamic sizing based on view type and graph count with smooth transitions
|
||||||
var dynamicNotchSize: CGSize {
|
var dynamicNotchSize: CGSize {
|
||||||
|
|
@ -64,6 +65,7 @@ struct ContentView: View {
|
||||||
@State private var lastHapticTime: Date = Date()
|
@State private var lastHapticTime: Date = Date()
|
||||||
|
|
||||||
@State private var gestureProgress: CGFloat = .zero
|
@State private var gestureProgress: CGFloat = .zero
|
||||||
|
@State private var skipGestureActiveDirection: MusicManager.SkipDirection?
|
||||||
@State private var isMusicControlWindowVisible = false
|
@State private var isMusicControlWindowVisible = false
|
||||||
@State private var pendingMusicControlTask: Task<Void, Never>?
|
@State private var pendingMusicControlTask: Task<Void, Never>?
|
||||||
@State private var musicControlHideTask: Task<Void, Never>?
|
@State private var musicControlHideTask: Task<Void, Never>?
|
||||||
|
|
@ -212,6 +214,12 @@ struct ContentView: View {
|
||||||
.panGesture(direction: .down) { translation, phase in
|
.panGesture(direction: .down) { translation, phase in
|
||||||
handleDownGesture(translation: translation, phase: phase)
|
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
|
.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) {
|
private func handleMusicControlPlaybackChange(isPlaying: Bool) {
|
||||||
guard musicControlWindowEnabled else { return }
|
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" : {
|
"Get started" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -11758,6 +11762,7 @@
|
||||||
},
|
},
|
||||||
"Media change with horizontal gestures" : {
|
"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.",
|
"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,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,19 @@ import SwiftUI
|
||||||
|
|
||||||
struct HoverButton: View {
|
struct HoverButton: View {
|
||||||
var icon: String
|
var icon: String
|
||||||
var iconColor: Color = .white;
|
var iconColor: Color = .white
|
||||||
var scale: Image.Scale = .medium
|
var scale: Image.Scale = .medium
|
||||||
var pressEffect: PressEffect? = nil
|
var pressEffect: PressEffect? = nil
|
||||||
|
var contentTransition: ContentTransition = .symbolEffect
|
||||||
|
var externalTriggerToken: Int? = nil
|
||||||
|
var externalTriggerEffect: PressEffect? = nil
|
||||||
var action: () -> Void
|
var action: () -> Void
|
||||||
var contentTransition: ContentTransition = .symbolEffect;
|
|
||||||
|
|
||||||
@State private var isHovering = false
|
@State private var isHovering = false
|
||||||
@State private var pressOffset: CGFloat = 0
|
@State private var pressOffset: CGFloat = 0
|
||||||
@State private var wiggleAngle: Double = 0
|
@State private var wiggleAngle: Double = 0
|
||||||
@State private var wiggleToken: Int = 0
|
@State private var wiggleToken: Int = 0
|
||||||
|
@State private var lastExternalTriggerToken: Int?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let size = CGFloat(scale == .large ? 40 : 30)
|
let size = CGFloat(scale == .large ? 40 : 30)
|
||||||
|
|
@ -66,12 +69,17 @@ struct HoverButton: View {
|
||||||
isHovering = hovering
|
isHovering = hovering
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: externalTriggerToken) { _, newToken in
|
||||||
|
guard let newToken, newToken != lastExternalTriggerToken else { return }
|
||||||
|
lastExternalTriggerToken = newToken
|
||||||
|
triggerPressEffect(override: externalTriggerEffect)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func triggerPressEffect() {
|
private func triggerPressEffect(override: PressEffect? = nil) {
|
||||||
guard let pressEffect else { return }
|
guard let effect = override ?? pressEffect else { return }
|
||||||
|
|
||||||
switch pressEffect {
|
switch effect {
|
||||||
case .nudge(let amount):
|
case .nudge(let amount):
|
||||||
withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) {
|
withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) {
|
||||||
pressOffset = amount
|
pressOffset = amount
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,14 @@ struct MusicControlOverlay: View {
|
||||||
@ObservedObject private var musicManager = MusicManager.shared
|
@ObservedObject private var musicManager = MusicManager.shared
|
||||||
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
||||||
|
|
||||||
private let seekInterval: TimeInterval = 10
|
private let seekInterval: TimeInterval = MusicManager.skipGestureSeekInterval
|
||||||
private let skipPressMagnitude: CGFloat = 8
|
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 {
|
private var controlsEnabled: Bool {
|
||||||
!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil
|
!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil
|
||||||
}
|
}
|
||||||
|
|
@ -39,14 +44,14 @@ struct MusicControlOverlay: View {
|
||||||
case .track:
|
case .track:
|
||||||
return ButtonConfig(
|
return ButtonConfig(
|
||||||
icon: "backward.fill",
|
icon: "backward.fill",
|
||||||
pressEffect: .nudge(-skipPressMagnitude),
|
pressEffect: trackBackwardPressEffect,
|
||||||
symbolEffect: .replace,
|
symbolEffect: .replace,
|
||||||
action: { MusicManager.shared.previousTrack() }
|
action: { MusicManager.shared.previousTrack() }
|
||||||
)
|
)
|
||||||
case .tenSecond:
|
case .tenSecond:
|
||||||
return ButtonConfig(
|
return ButtonConfig(
|
||||||
icon: "gobackward.10",
|
icon: "gobackward.10",
|
||||||
pressEffect: .wiggle(.counterClockwise),
|
pressEffect: tenSecondBackwardPressEffect,
|
||||||
symbolEffect: .wiggle,
|
symbolEffect: .wiggle,
|
||||||
action: { MusicManager.shared.seek(by: -seekInterval) }
|
action: { MusicManager.shared.seek(by: -seekInterval) }
|
||||||
)
|
)
|
||||||
|
|
@ -58,14 +63,14 @@ struct MusicControlOverlay: View {
|
||||||
case .track:
|
case .track:
|
||||||
return ButtonConfig(
|
return ButtonConfig(
|
||||||
icon: "forward.fill",
|
icon: "forward.fill",
|
||||||
pressEffect: .nudge(skipPressMagnitude),
|
pressEffect: trackForwardPressEffect,
|
||||||
symbolEffect: .replace,
|
symbolEffect: .replace,
|
||||||
action: { MusicManager.shared.nextTrack() }
|
action: { MusicManager.shared.nextTrack() }
|
||||||
)
|
)
|
||||||
case .tenSecond:
|
case .tenSecond:
|
||||||
return ButtonConfig(
|
return ButtonConfig(
|
||||||
icon: "goforward.10",
|
icon: "goforward.10",
|
||||||
pressEffect: .wiggle(.clockwise),
|
pressEffect: tenSecondForwardPressEffect,
|
||||||
symbolEffect: .wiggle,
|
symbolEffect: .wiggle,
|
||||||
action: { MusicManager.shared.seek(by: seekInterval) }
|
action: { MusicManager.shared.seek(by: seekInterval) }
|
||||||
)
|
)
|
||||||
|
|
@ -84,6 +89,9 @@ struct MusicControlOverlay: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let verticalPadding = max(8, notchHeight * 0.12)
|
let verticalPadding = max(8, notchHeight * 0.12)
|
||||||
|
|
||||||
|
let backwardGestureTrigger = skipGestureTrigger(for: .backward)
|
||||||
|
let forwardGestureTrigger = skipGestureTrigger(for: .forward)
|
||||||
|
|
||||||
HStack(spacing: 18) {
|
HStack(spacing: 18) {
|
||||||
FloatingMediaButton(
|
FloatingMediaButton(
|
||||||
icon: backwardConfig.icon,
|
icon: backwardConfig.icon,
|
||||||
|
|
@ -92,6 +100,8 @@ struct MusicControlOverlay: View {
|
||||||
foregroundColor: .white.opacity(controlsEnabled ? 0.9 : 0.35),
|
foregroundColor: .white.opacity(controlsEnabled ? 0.9 : 0.35),
|
||||||
pressEffect: backwardConfig.pressEffect,
|
pressEffect: backwardConfig.pressEffect,
|
||||||
symbolEffectStyle: backwardConfig.symbolEffect,
|
symbolEffectStyle: backwardConfig.symbolEffect,
|
||||||
|
externalTriggerToken: backwardGestureTrigger?.token,
|
||||||
|
externalTriggerEffect: backwardGestureTrigger?.pressEffect,
|
||||||
isEnabled: controlsEnabled,
|
isEnabled: controlsEnabled,
|
||||||
action: backwardConfig.action
|
action: backwardConfig.action
|
||||||
)
|
)
|
||||||
|
|
@ -103,6 +113,8 @@ struct MusicControlOverlay: View {
|
||||||
foregroundColor: .white,
|
foregroundColor: .white,
|
||||||
pressEffect: playPauseConfig.pressEffect,
|
pressEffect: playPauseConfig.pressEffect,
|
||||||
symbolEffectStyle: playPauseConfig.symbolEffect,
|
symbolEffectStyle: playPauseConfig.symbolEffect,
|
||||||
|
externalTriggerToken: nil,
|
||||||
|
externalTriggerEffect: nil,
|
||||||
isEnabled: controlsEnabled,
|
isEnabled: controlsEnabled,
|
||||||
action: playPauseConfig.action
|
action: playPauseConfig.action
|
||||||
)
|
)
|
||||||
|
|
@ -114,6 +126,8 @@ struct MusicControlOverlay: View {
|
||||||
foregroundColor: .white.opacity(controlsEnabled ? 0.9 : 0.35),
|
foregroundColor: .white.opacity(controlsEnabled ? 0.9 : 0.35),
|
||||||
pressEffect: forwardConfig.pressEffect,
|
pressEffect: forwardConfig.pressEffect,
|
||||||
symbolEffectStyle: forwardConfig.symbolEffect,
|
symbolEffectStyle: forwardConfig.symbolEffect,
|
||||||
|
externalTriggerToken: forwardGestureTrigger?.token,
|
||||||
|
externalTriggerEffect: forwardGestureTrigger?.pressEffect,
|
||||||
isEnabled: controlsEnabled,
|
isEnabled: controlsEnabled,
|
||||||
action: forwardConfig.action
|
action: forwardConfig.action
|
||||||
)
|
)
|
||||||
|
|
@ -147,6 +161,8 @@ private struct FloatingMediaButton: View {
|
||||||
let foregroundColor: Color
|
let foregroundColor: Color
|
||||||
let pressEffect: PressEffect
|
let pressEffect: PressEffect
|
||||||
let symbolEffectStyle: SymbolEffectStyle
|
let symbolEffectStyle: SymbolEffectStyle
|
||||||
|
let externalTriggerToken: Int?
|
||||||
|
let externalTriggerEffect: PressEffect?
|
||||||
let isEnabled: Bool
|
let isEnabled: Bool
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
|
|
@ -154,6 +170,7 @@ private struct FloatingMediaButton: View {
|
||||||
@State private var pressOffset: CGFloat = 0
|
@State private var pressOffset: CGFloat = 0
|
||||||
@State private var rotationAngle: Double = 0
|
@State private var rotationAngle: Double = 0
|
||||||
@State private var wiggleToken: Int = 0
|
@State private var wiggleToken: Int = 0
|
||||||
|
@State private var lastExternalTriggerToken: Int?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
|
|
@ -178,6 +195,12 @@ private struct FloatingMediaButton: View {
|
||||||
isHovering = hovering
|
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 {
|
private var iconView: some View {
|
||||||
|
|
@ -219,8 +242,10 @@ private struct FloatingMediaButton: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func triggerPressEffect() {
|
private func triggerPressEffect(override: PressEffect? = nil) {
|
||||||
switch pressEffect {
|
let activeEffect = override ?? pressEffect
|
||||||
|
|
||||||
|
switch activeEffect {
|
||||||
case .none:
|
case .none:
|
||||||
return
|
return
|
||||||
case .nudge(let amount):
|
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 {
|
#Preview {
|
||||||
MusicControlOverlay(notchHeight: 34, cornerRadius: 14)
|
MusicControlOverlay(notchHeight: 34, cornerRadius: 14)
|
||||||
.padding()
|
.padding()
|
||||||
|
|
|
||||||
|
|
@ -674,6 +674,11 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct SkipTrigger {
|
||||||
|
let token: Int
|
||||||
|
let pressEffect: MinimalisticSquircircleButton.PressEffect
|
||||||
|
}
|
||||||
|
|
||||||
private func controlButton(
|
private func controlButton(
|
||||||
icon: String,
|
icon: String,
|
||||||
size: CGFloat = 18,
|
size: CGFloat = 18,
|
||||||
|
|
@ -681,6 +686,7 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
activeColor: Color = .red,
|
activeColor: Color = .red,
|
||||||
pressEffect: MinimalisticSquircircleButton.PressEffect = .none,
|
pressEffect: MinimalisticSquircircleButton.PressEffect = .none,
|
||||||
symbolEffect: MinimalisticSquircircleButton.SymbolEffectStyle = .none,
|
symbolEffect: MinimalisticSquircircleButton.SymbolEffectStyle = .none,
|
||||||
|
trigger: SkipTrigger? = nil,
|
||||||
action: @escaping () -> Void
|
action: @escaping () -> Void
|
||||||
) -> some View {
|
) -> some View {
|
||||||
MinimalisticSquircircleButton(
|
MinimalisticSquircircleButton(
|
||||||
|
|
@ -692,6 +698,8 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
foregroundColor: isActive ? activeColor : .white.opacity(0.85),
|
foregroundColor: isActive ? activeColor : .white.opacity(0.85),
|
||||||
pressEffect: pressEffect,
|
pressEffect: pressEffect,
|
||||||
symbolEffectStyle: symbolEffect,
|
symbolEffectStyle: symbolEffect,
|
||||||
|
externalTriggerToken: trigger?.token,
|
||||||
|
externalTriggerEffect: trigger?.pressEffect,
|
||||||
action: action
|
action: action
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -723,6 +731,7 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
size: 18,
|
size: 18,
|
||||||
pressEffect: .nudge(-skipMagnitude),
|
pressEffect: .nudge(-skipMagnitude),
|
||||||
symbolEffect: .replace,
|
symbolEffect: .replace,
|
||||||
|
trigger: skipGestureTrigger(for: .trackBackward),
|
||||||
action: { musicManager.previousTrack() }
|
action: { musicManager.previousTrack() }
|
||||||
)
|
)
|
||||||
case .trackForward:
|
case .trackForward:
|
||||||
|
|
@ -731,6 +740,7 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
size: 18,
|
size: 18,
|
||||||
pressEffect: .nudge(skipMagnitude),
|
pressEffect: .nudge(skipMagnitude),
|
||||||
symbolEffect: .replace,
|
symbolEffect: .replace,
|
||||||
|
trigger: skipGestureTrigger(for: .trackForward),
|
||||||
action: { musicManager.nextTrack() }
|
action: { musicManager.nextTrack() }
|
||||||
)
|
)
|
||||||
case .seekBackward:
|
case .seekBackward:
|
||||||
|
|
@ -739,6 +749,7 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
size: 18,
|
size: 18,
|
||||||
pressEffect: .wiggle(.counterClockwise),
|
pressEffect: .wiggle(.counterClockwise),
|
||||||
symbolEffect: .wiggle,
|
symbolEffect: .wiggle,
|
||||||
|
trigger: skipGestureTrigger(for: .seekBackward),
|
||||||
action: { musicManager.seek(by: -seekInterval) }
|
action: { musicManager.seek(by: -seekInterval) }
|
||||||
)
|
)
|
||||||
case .seekForward:
|
case .seekForward:
|
||||||
|
|
@ -747,6 +758,7 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
size: 18,
|
size: 18,
|
||||||
pressEffect: .wiggle(.clockwise),
|
pressEffect: .wiggle(.clockwise),
|
||||||
symbolEffect: .wiggle,
|
symbolEffect: .wiggle,
|
||||||
|
trigger: skipGestureTrigger(for: .seekForward),
|
||||||
action: { musicManager.seek(by: seekInterval) }
|
action: { musicManager.seek(by: seekInterval) }
|
||||||
)
|
)
|
||||||
case .shuffle:
|
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 {
|
private struct MinimalisticMediaOutputButton: View {
|
||||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||||
|
|
@ -901,12 +930,15 @@ private struct MinimalisticSquircircleButton: View {
|
||||||
let foregroundColor: Color
|
let foregroundColor: Color
|
||||||
let pressEffect: PressEffect
|
let pressEffect: PressEffect
|
||||||
let symbolEffectStyle: SymbolEffectStyle
|
let symbolEffectStyle: SymbolEffectStyle
|
||||||
|
let externalTriggerToken: Int?
|
||||||
|
let externalTriggerEffect: PressEffect?
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
@State private var isHovering = false
|
@State private var isHovering = false
|
||||||
@State private var pressOffset: CGFloat = 0
|
@State private var pressOffset: CGFloat = 0
|
||||||
@State private var rotationAngle: Double = 0
|
@State private var rotationAngle: Double = 0
|
||||||
@State private var wiggleToken: Int = 0
|
@State private var wiggleToken: Int = 0
|
||||||
|
@State private var lastExternalTriggerToken: Int?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
icon: String,
|
icon: String,
|
||||||
|
|
@ -917,6 +949,8 @@ private struct MinimalisticSquircircleButton: View {
|
||||||
foregroundColor: Color,
|
foregroundColor: Color,
|
||||||
pressEffect: PressEffect = .none,
|
pressEffect: PressEffect = .none,
|
||||||
symbolEffectStyle: SymbolEffectStyle = .none,
|
symbolEffectStyle: SymbolEffectStyle = .none,
|
||||||
|
externalTriggerToken: Int? = nil,
|
||||||
|
externalTriggerEffect: PressEffect? = nil,
|
||||||
action: @escaping () -> Void
|
action: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
self.icon = icon
|
self.icon = icon
|
||||||
|
|
@ -927,6 +961,8 @@ private struct MinimalisticSquircircleButton: View {
|
||||||
self.foregroundColor = foregroundColor
|
self.foregroundColor = foregroundColor
|
||||||
self.pressEffect = pressEffect
|
self.pressEffect = pressEffect
|
||||||
self.symbolEffectStyle = symbolEffectStyle
|
self.symbolEffectStyle = symbolEffectStyle
|
||||||
|
self.externalTriggerToken = externalTriggerToken
|
||||||
|
self.externalTriggerEffect = externalTriggerEffect
|
||||||
self.action = action
|
self.action = action
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -951,10 +987,17 @@ private struct MinimalisticSquircircleButton: View {
|
||||||
isHovering = hovering
|
isHovering = hovering
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: externalTriggerToken) { _, newToken in
|
||||||
|
guard let newToken, newToken != lastExternalTriggerToken else { return }
|
||||||
|
lastExternalTriggerToken = newToken
|
||||||
|
triggerPressEffect(override: externalTriggerEffect)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func triggerPressEffect() {
|
private func triggerPressEffect(override: PressEffect? = nil) {
|
||||||
switch pressEffect {
|
let effect = override ?? pressEffect
|
||||||
|
|
||||||
|
switch effect {
|
||||||
case .none:
|
case .none:
|
||||||
return
|
return
|
||||||
case .nudge(let amount):
|
case .nudge(let amount):
|
||||||
|
|
|
||||||
|
|
@ -271,19 +271,35 @@ struct MusicControlsView: View {
|
||||||
MusicManager.shared.togglePlay()
|
MusicManager.shared.togglePlay()
|
||||||
}
|
}
|
||||||
case .trackBackward:
|
case .trackBackward:
|
||||||
playbackButton(icon: "backward.fill", press: .nudge(-skipMagnitude)) {
|
playbackButton(
|
||||||
|
icon: "backward.fill",
|
||||||
|
press: .nudge(-skipMagnitude),
|
||||||
|
trigger: skipGestureTrigger(for: .trackBackward)
|
||||||
|
) {
|
||||||
musicManager.previousTrack()
|
musicManager.previousTrack()
|
||||||
}
|
}
|
||||||
case .trackForward:
|
case .trackForward:
|
||||||
playbackButton(icon: "forward.fill", press: .nudge(skipMagnitude)) {
|
playbackButton(
|
||||||
|
icon: "forward.fill",
|
||||||
|
press: .nudge(skipMagnitude),
|
||||||
|
trigger: skipGestureTrigger(for: .trackForward)
|
||||||
|
) {
|
||||||
musicManager.nextTrack()
|
musicManager.nextTrack()
|
||||||
}
|
}
|
||||||
case .seekBackward:
|
case .seekBackward:
|
||||||
playbackButton(icon: "gobackward.10", press: .wiggle(.counterClockwise)) {
|
playbackButton(
|
||||||
|
icon: "gobackward.10",
|
||||||
|
press: .wiggle(.counterClockwise),
|
||||||
|
trigger: skipGestureTrigger(for: .seekBackward)
|
||||||
|
) {
|
||||||
musicManager.seek(by: -seekInterval)
|
musicManager.seek(by: -seekInterval)
|
||||||
}
|
}
|
||||||
case .seekForward:
|
case .seekForward:
|
||||||
playbackButton(icon: "goforward.10", press: .wiggle(.clockwise)) {
|
playbackButton(
|
||||||
|
icon: "goforward.10",
|
||||||
|
press: .wiggle(.clockwise),
|
||||||
|
trigger: skipGestureTrigger(for: .seekForward)
|
||||||
|
) {
|
||||||
musicManager.seek(by: seekInterval)
|
musicManager.seek(by: seekInterval)
|
||||||
}
|
}
|
||||||
case .shuffle:
|
case .shuffle:
|
||||||
|
|
@ -315,19 +331,44 @@ struct MusicControlsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct SkipTrigger {
|
||||||
|
let token: Int
|
||||||
|
let pressEffect: HoverButton.PressEffect
|
||||||
|
}
|
||||||
|
|
||||||
private func playbackButton(
|
private func playbackButton(
|
||||||
icon: String,
|
icon: String,
|
||||||
press: HoverButton.PressEffect?,
|
press: HoverButton.PressEffect?,
|
||||||
|
trigger: SkipTrigger?,
|
||||||
action: @escaping () -> Void
|
action: @escaping () -> Void
|
||||||
) -> some View {
|
) -> some View {
|
||||||
HoverButton(
|
HoverButton(
|
||||||
icon: icon,
|
icon: icon,
|
||||||
scale: .medium,
|
scale: .medium,
|
||||||
pressEffect: press
|
pressEffect: press,
|
||||||
|
externalTriggerToken: trigger?.token,
|
||||||
|
externalTriggerEffect: trigger?.pressEffect
|
||||||
) {
|
) {
|
||||||
action()
|
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
|
// MARK: - Main View
|
||||||
|
|
|
||||||
|
|
@ -831,6 +831,8 @@ struct GeneralSettings: View {
|
||||||
@Default(.enableGestures) var enableGestures
|
@Default(.enableGestures) var enableGestures
|
||||||
@Default(.openNotchOnHover) var openNotchOnHover
|
@Default(.openNotchOnHover) var openNotchOnHover
|
||||||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||||||
|
@Default(.enableHorizontalMusicGestures) var enableHorizontalMusicGestures
|
||||||
|
@Default(.musicGestureBehavior) var musicGestureBehavior
|
||||||
|
|
||||||
private func highlightID(_ title: String) -> String {
|
private func highlightID(_ title: String) -> String {
|
||||||
SettingsTab.general.highlightID(for: title)
|
SettingsTab.general.highlightID(for: title)
|
||||||
|
|
@ -973,8 +975,24 @@ struct GeneralSettings: View {
|
||||||
.disabled(!openNotchOnHover)
|
.disabled(!openNotchOnHover)
|
||||||
.settingsHighlight(id: highlightID("Enable gestures"))
|
.settingsHighlight(id: highlightID("Enable gestures"))
|
||||||
if enableGestures {
|
if enableGestures {
|
||||||
Toggle("Media change with horizontal gestures", isOn: .constant(false))
|
Defaults.Toggle("Media change with horizontal gestures", key: .enableHorizontalMusicGestures)
|
||||||
.disabled(true)
|
.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)
|
Defaults.Toggle("Close gesture", key: .closeGestureEnabled)
|
||||||
.settingsHighlight(id: highlightID("Close gesture"))
|
.settingsHighlight(id: highlightID("Close gesture"))
|
||||||
Slider(value: $gestureSensitivity, in: 100...300, step: 100) {
|
Slider(value: $gestureSensitivity, in: 100...300, step: 100) {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,19 @@ let defaultImage: NSImage = .init(
|
||||||
)!
|
)!
|
||||||
|
|
||||||
class MusicManager: ObservableObject {
|
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
|
// MARK: - Properties
|
||||||
static let shared = MusicManager()
|
static let shared = MusicManager()
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
@ -61,6 +74,7 @@ class MusicManager: ObservableObject {
|
||||||
@Published var isLiveStream: Bool = false
|
@Published var isLiveStream: Bool = false
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@Published var usingAppIconForArtwork: Bool = false
|
@Published var usingAppIconForArtwork: Bool = false
|
||||||
|
@Published private(set) var skipGesturePulse: SkipGesturePulse?
|
||||||
|
|
||||||
// MARK: - Lyrics Properties
|
// MARK: - Lyrics Properties
|
||||||
@Published var currentLyrics: String = ""
|
@Published var currentLyrics: String = ""
|
||||||
|
|
@ -89,6 +103,7 @@ class MusicManager: ObservableObject {
|
||||||
|
|
||||||
@Published var isTransitioning: Bool = false
|
@Published var isTransitioning: Bool = false
|
||||||
private var transitionWorkItem: DispatchWorkItem?
|
private var transitionWorkItem: DispatchWorkItem?
|
||||||
|
private var skipGestureToken: Int = 0
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init() {
|
init() {
|
||||||
|
|
@ -556,6 +571,34 @@ class MusicManager: ObservableObject {
|
||||||
seek(to: target)
|
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() {
|
func openMusicApp() {
|
||||||
guard let bundleID = bundleIdentifier else {
|
guard let bundleID = bundleIdentifier else {
|
||||||
print("Error: appBundleIdentifier is nil")
|
print("Error: appBundleIdentifier is nil")
|
||||||
|
|
|
||||||
|
|
@ -435,6 +435,8 @@ extension Defaults.Keys {
|
||||||
static let enableGestures = Key<Bool>("enableGestures", default: true)
|
static let enableGestures = Key<Bool>("enableGestures", default: true)
|
||||||
static let closeGestureEnabled = Key<Bool>("closeGestureEnabled", default: true)
|
static let closeGestureEnabled = Key<Bool>("closeGestureEnabled", default: true)
|
||||||
static let gestureSensitivity = Key<CGFloat>("gestureSensitivity", default: 200.0)
|
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
|
// MARK: Media playback
|
||||||
static let coloredSpectrogram = Key<Bool>("coloredSpectrogram", default: true)
|
static let coloredSpectrogram = Key<Bool>("coloredSpectrogram", default: true)
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,12 @@ open DynamicIsland.xcodeproj
|
||||||
- Remap global shortcuts and adjust hover behaviour.
|
- Remap global shortcuts and adjust hover behaviour.
|
||||||
- Enable lock screen widgets and select data sources.
|
- Enable lock screen widgets and select data sources.
|
||||||
|
|
||||||
|
## Gesture Controls
|
||||||
|
- Two-finger swipe down to open the notch when hover-to-open is disabled; swipe up to close.
|
||||||
|
- Enable horizontal media gestures in **Settings → General → Gesture control** to turn the music pane into a trackpad for previous/next or ±10 second seeks.
|
||||||
|
- Pick the gesture skip behaviour (track vs ±10s) independently from the skip button configuration so swipes can scrub while buttons change tracks—or vice versa.
|
||||||
|
- Horizontal swipes trigger the same haptics and button animations you see in the notch, keeping visual feedback consistent with tap interactions.
|
||||||
|
|
||||||
## Troubleshooting (Basics)
|
## Troubleshooting (Basics)
|
||||||
- After granting Accessibility or Screen Recording, quit and relaunch the app.
|
- After granting Accessibility or Screen Recording, quit and relaunch the app.
|
||||||
- If metrics are empty, enable categories in Settings → Stats.
|
- If metrics are empty, enable categories in Settings → Stats.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue