diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 8d2e3ea..d5f5500 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -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? @State private var musicControlHideTask: Task? @@ -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 } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 8b3d0bf..285e6c8 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -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" : { diff --git a/DynamicIsland/components/HoverButton.swift b/DynamicIsland/components/HoverButton.swift index 3c38c8b..dc431f2 100644 --- a/DynamicIsland/components/HoverButton.swift +++ b/DynamicIsland/components/HoverButton.swift @@ -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 diff --git a/DynamicIsland/components/Music/MusicControlOverlay.swift b/DynamicIsland/components/Music/MusicControlOverlay.swift index ff38af3..8b5c5ee 100644 --- a/DynamicIsland/components/Music/MusicControlOverlay.swift +++ b/DynamicIsland/components/Music/MusicControlOverlay.swift @@ -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() diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 8da6261..2b0141f 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -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): diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 3174032..4fa9b92 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -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 diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 49660ed..22f4f3e 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -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) { diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 360daf8..0ec3e48 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -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() @@ -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") diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 36093b3..0a5c9f5 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -435,6 +435,8 @@ extension Defaults.Keys { static let enableGestures = Key("enableGestures", default: true) static let closeGestureEnabled = Key("closeGestureEnabled", default: true) static let gestureSensitivity = Key("gestureSensitivity", default: 200.0) + static let enableHorizontalMusicGestures = Key("enableHorizontalMusicGestures", default: true) + static let musicGestureBehavior = Key("musicGestureBehavior", default: .track) // MARK: Media playback static let coloredSpectrogram = Key("coloredSpectrogram", default: true)