From d6d728350fd3c7e892e6caa7f09fdc602f3df0b5 Mon Sep 17 00:00:00 2001 From: Gary Tokman Date: Mon, 6 Nov 2023 08:33:22 -0500 Subject: [PATCH] feat: add support for ahap --- README.md | 24 +- example/ios/Haptics/test.ahap | 135 ++++++++ .../HapticsExample.xcodeproj/project.pbxproj | 18 +- example/src/App.tsx | 66 ++-- ios/Haptics.mm | 3 +- ios/Haptics.swift | 9 +- ios/HapticsFileHelper.swift | 311 ++++++++++++++++++ src/index.tsx | 8 + 8 files changed, 529 insertions(+), 45 deletions(-) create mode 100644 example/ios/Haptics/test.ahap create mode 100644 ios/HapticsFileHelper.swift diff --git a/README.md b/README.md index 2136995..e5f4977 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@
-The package only supports **iOS** 13+ using [Haptico](https://github.com/iSapozhnik/Haptico) under the hood. +Supports playing haptics on iOS with default UIImpactFeedbackGenerator and CoreHaptics for patterns and ahap files. ## Installation @@ -41,19 +41,23 @@ import { haptic, hapticWithPattern } from '@candlefinance/haptics'; // light, medium, heavy, soft, rigid, warning, error, success, selectionChanged haptic('medium'); -// pattern, delay -hapticWithPattern( - ['.', '.', '.', 'o', 'O', '-', 'O', 'o', '.', '.', '.', '.'], - 0.1 -); +// pattern +hapticWithPattern(['.', '.', '.', 'o', 'O', '-', 'O', 'o', '.', '.', '.', '.']); + +// play ahap file +play('fileName'); ``` The pattern format: -- `O` - heavy impact -- `o` - medium impact -- `.` - light impact -- `-` - wait 0.1 second +- 'o' // medium impact +- 'O' // heavy impact +- '.' // light impact +- ':' // soft impact +- '-' // wait of 0.1 second +- '=' // wait of 1 second + +For playing ahap files to the root of your project add a folder called `haptics` and add your ahap files there. Use (Haptrix)[https://www.haptrix.com/] or equivalent to generate ahap files. ## Contributing diff --git a/example/ios/Haptics/test.ahap b/example/ios/Haptics/test.ahap new file mode 100644 index 0000000..e9c958c --- /dev/null +++ b/example/ios/Haptics/test.ahap @@ -0,0 +1,135 @@ +{ + "Version": 1, + "Pattern": [ + { + "Event": { + "Time": 0.43787878787878787, + "EventType": "HapticTransient", + "EventParameters": [ + { + "ParameterID": "HapticIntensity", + "ParameterValue": 0.5529411764705883 + }, + { + "ParameterID": "HapticSharpness", + "ParameterValue": 0.5529411764705883 + } + ] + } + }, + { + "Event": { + "Time": 0.25681818181818183, + "EventType": "HapticTransient", + "EventParameters": [ + { + "ParameterID": "HapticIntensity", + "ParameterValue": 0.4411764705882353 + }, + { + "ParameterID": "HapticSharpness", + "ParameterValue": 0.4411764705882353 + } + ] + } + }, + { + "Event": { + "Time": 0.3325757575757576, + "EventType": "HapticContinuous", + "EventDuration": 0.005, + "EventParameters": [ + { + "ParameterID": "HapticIntensity", + "ParameterValue": 0.4235294117647059 + }, + { + "ParameterID": "HapticSharpness", + "ParameterValue": 0.4235294117647059 + } + ] + } + }, + { + "Event": { + "Time": 0.3325757575757576, + "EventType": "HapticTransient", + "EventParameters": [ + { + "ParameterID": "HapticIntensity", + "ParameterValue": 0.4235294117647059 + }, + { + "ParameterID": "HapticSharpness", + "ParameterValue": 0.4235294117647059 + } + ] + } + }, + { + "Event": { + "Time": 0.740909090909091, + "EventType": "HapticTransient", + "EventParameters": [ + { + "ParameterID": "HapticIntensity", + "ParameterValue": 0.6529411764705882 + }, + { + "ParameterID": "HapticSharpness", + "ParameterValue": 0.6529411764705882 + } + ] + } + }, + { + "Event": { + "Time": 0.6659090909090909, + "EventType": "HapticContinuous", + "EventDuration": 0.005, + "EventParameters": [ + { + "ParameterID": "HapticIntensity", + "ParameterValue": 0.38235294117647056 + }, + { + "ParameterID": "HapticSharpness", + "ParameterValue": 0.38235294117647056 + } + ] + } + }, + { + "Event": { + "Time": 0.5409090909090909, + "EventType": "HapticTransient", + "EventParameters": [ + { + "ParameterID": "HapticIntensity", + "ParameterValue": 0.5058823529411764 + }, + { + "ParameterID": "HapticSharpness", + "ParameterValue": 0.5058823529411764 + } + ] + } + }, + { + "Event": { + "Time": 0.12575757575757576, + "EventType": "HapticTransient", + "EventParameters": [ + { + "ParameterID": "HapticIntensity", + "ParameterValue": 0.3941176470588235 + }, + { + "ParameterID": "HapticSharpness", + "ParameterValue": 0.3941176470588235 + } + ] + } + } + ] +} diff --git a/example/ios/HapticsExample.xcodeproj/project.pbxproj b/example/ios/HapticsExample.xcodeproj/project.pbxproj index 460d27d..a8bb996 100644 --- a/example/ios/HapticsExample.xcodeproj/project.pbxproj +++ b/example/ios/HapticsExample.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 4EDBA3772AF91EF900D5CF41 /* test.ahap in Resources */ = {isa = PBXBuildFile; fileRef = 4EDBA3752AF91EF900D5CF41 /* test.ahap */; }; 7699B88040F8A987B510C191 /* libPods-HapticsExample-HapticsExampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 19F6CBCC0A4E27FBF8BF4A61 /* libPods-HapticsExample-HapticsExampleTests.a */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; /* End PBXBuildFile section */ @@ -38,6 +39,7 @@ 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = HapticsExample/main.m; sourceTree = ""; }; 19F6CBCC0A4E27FBF8BF4A61 /* libPods-HapticsExample-HapticsExampleTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-HapticsExample-HapticsExampleTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4392A12AC88292D35C810B /* Pods-HapticsExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HapticsExample.debug.xcconfig"; path = "Target Support Files/Pods-HapticsExample/Pods-HapticsExample.debug.xcconfig"; sourceTree = ""; }; + 4EDBA3752AF91EF900D5CF41 /* test.ahap */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = test.ahap; sourceTree = ""; }; 5709B34CF0A7D63546082F79 /* Pods-HapticsExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HapticsExample.release.xcconfig"; path = "Target Support Files/Pods-HapticsExample/Pods-HapticsExample.release.xcconfig"; sourceTree = ""; }; 5B7EB9410499542E8C5724F5 /* Pods-HapticsExample-HapticsExampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HapticsExample-HapticsExampleTests.debug.xcconfig"; path = "Target Support Files/Pods-HapticsExample-HapticsExampleTests/Pods-HapticsExample-HapticsExampleTests.debug.xcconfig"; sourceTree = ""; }; 5DCACB8F33CDC322A6C60F78 /* libPods-HapticsExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-HapticsExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -86,6 +88,7 @@ 13B07FAE1A68108700A75B9A /* HapticsExample */ = { isa = PBXGroup; children = ( + 4EDBA3742AF91EF900D5CF41 /* Haptics */, 13B07FAF1A68108700A75B9A /* AppDelegate.h */, 13B07FB01A68108700A75B9A /* AppDelegate.mm */, 13B07FB51A68108700A75B9A /* Images.xcassets */, @@ -106,6 +109,14 @@ name = Frameworks; sourceTree = ""; }; + 4EDBA3742AF91EF900D5CF41 /* Haptics */ = { + isa = PBXGroup; + children = ( + 4EDBA3752AF91EF900D5CF41 /* test.ahap */, + ); + path = Haptics; + sourceTree = ""; + }; 832341AE1AAA6A7D00B99B32 /* Libraries */ = { isa = PBXGroup; children = ( @@ -243,6 +254,7 @@ buildActionMask = 2147483647; files = ( 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, + 4EDBA3772AF91EF900D5CF41 /* test.ahap in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -609,7 +621,8 @@ "-ld_classic", "-Wl", "-ld_classic", - "-Wl -ld_classic ", + "-Wl", + "-ld_classic", ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; @@ -686,7 +699,8 @@ "-ld_classic", "-Wl", "-ld_classic", - "-Wl -ld_classic ", + "-Wl", + "-ld_classic", ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; diff --git a/example/src/App.tsx b/example/src/App.tsx index 33b82ec..141009d 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import type { HapticType } from '@candlefinance/haptics'; -import { haptic, hapticWithPattern } from '@candlefinance/haptics'; +import { haptic, hapticWithPattern, play } from '@candlefinance/haptics'; import { Pressable, StyleSheet, Text, View } from 'react-native'; export default function App() { @@ -36,39 +36,45 @@ export default function App() { style={styles.button} onPress={() => { console.log('hapticWithPattern'); - hapticWithPattern( - [ - '.', - '.', - '.', - 'o', - 'O', - '-', - 'O', - 'o', - '.', - '.', - '.', - '.', - '.', - '.', - '.', - 'o', - 'O', - '-', - 'O', - 'o', - '.', - '.', - '.', - '.', - ], - 1 - ); + hapticWithPattern([ + '.', + '.', + '.', + 'o', + 'O', + '-', + 'O', + 'o', + '.', + '.', + '.', + '.', + '.', + '.', + '.', + 'o', + 'O', + '=', + 'O', + 'o', + '.', + '.', + '.', + '.', + ]); }} > Pattern + { + console.log('hapticWithPatternFile'); + play('test'); + }} + > + Play File + ); } diff --git a/ios/Haptics.mm b/ios/Haptics.mm index d4a3e67..0d0efbb 100644 --- a/ios/Haptics.mm +++ b/ios/Haptics.mm @@ -3,7 +3,8 @@ @interface RCT_EXTERN_MODULE(Haptics, NSObject) RCT_EXTERN_METHOD(haptic:(NSString *)type) -RCT_EXTERN_METHOD(hapticWithPattern:(NSArray *)pattern delay:(nonnull NSNumber *)delay) +RCT_EXTERN_METHOD(hapticWithPattern:(NSArray *)pattern) +RCT_EXTERN_METHOD(play:(nonnull NSString *)fileName loop:(nonnull BOOL)loop) + (BOOL)requiresMainQueueSetup { diff --git a/ios/Haptics.swift b/ios/Haptics.swift index 01f5318..3fa944a 100644 --- a/ios/Haptics.swift +++ b/ios/Haptics.swift @@ -33,8 +33,8 @@ class Haptics: NSObject { } } - @objc(hapticWithPattern:delay:) - func hapticWithPattern(pattern: [String], delay: Double) { + @objc(hapticWithPattern:) + func hapticWithPattern(pattern: [String]) { print("running haptic pattern", pattern) try? HapticsHelper.initialize() var components: [HapticsHelper.HapticPatternComponent] = [] @@ -62,4 +62,9 @@ class Haptics: NSObject { } try? HapticsHelper.generateHaptic(fromComponents: components).play() } + + @objc(play:loop:) + func play(fileName: String, loop: Bool) { + Vibrator.shared.startHaptic(named: fileName, loop: loop) + } } diff --git a/ios/HapticsFileHelper.swift b/ios/HapticsFileHelper.swift new file mode 100644 index 0000000..88238b9 --- /dev/null +++ b/ios/HapticsFileHelper.swift @@ -0,0 +1,311 @@ +import Foundation +import AudioToolbox +import CoreHaptics + +/// A class that allows your app to play system vibrations and Apple Haptic and Audio Pattern (AHAP) files generated with [Lofelt Composer](https://composer.lofelt.com). +public class Vibrator { + + /// Options for device vibration rates when looping. + public enum Frequency { + case high + case low + + fileprivate var timeInterval: TimeInterval { + switch self { + case .high: return 0.01 + case .low: return 1.0 + } + } + } + + /// Indicates if the device supports haptic event playback. + public let supportsHaptics: Bool = { + return CHHapticEngine.capabilitiesForHardware().supportsHaptics + }() + + private var hapticEngine: CHHapticEngine? { + didSet { + guard let hapticEngine: CHHapticEngine = hapticEngine else { return } + hapticEngine.playsHapticsOnly = true + hapticEngine.isAutoShutdownEnabled = false + hapticEngine.notifyWhenPlayersFinished { (_) -> CHHapticEngine.FinishedAction in return .leaveEngineRunning } + hapticEngine.stoppedHandler = { reason in self.hapticEngineDidStop(reason: reason) } + hapticEngine.resetHandler = { self.hapticEngineDidRecoverFromServerError() } + } + } + + private var hapticPlayer: CHHapticPatternPlayer? + + private var vibrateLoopTimer: Timer? + private var hapticLoopTimer: Timer? + + // MARK: - Init + /// The shared singleton instance. + public static let shared: Vibrator = Vibrator() + private init() { + guard supportsHaptics else { return } + hapticEngine = try? CHHapticEngine() + } + + /// Prepares the vibrator by acquiring hardware needed for vibrations. + public func prepare() { + guard let hapticEngine: CHHapticEngine = hapticEngine else { return } + try? hapticEngine.start() + } + + // MARK: - Vibrate + /// Vibrates the device. + /// - Parameters: + /// - frequency: Rate at which device vibrates when looping. Has no effect if `loop` is `false`. + /// - loop: Determines whether the vibration repeats itself based on the `frequency`. + public func startVibrate(frequency: Vibrator.Frequency = Vibrator.Frequency.low, loop: Bool) { + stopVibrate() + + loop + ? playVibrateSystemSoundLoop(frequency: frequency) + : playVibrateSystemSound() + } + + /// Stops vibrating the device. + /// + /// Has no effect if `loop` is `false` when starting the vibration. + public func stopVibrate() { + stopVibrateLoopTimer() + } + + @objc private func playVibrateSystemSound() { + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + } + + private func playVibrateSystemSoundLoop(frequency: Vibrator.Frequency) { + playVibrateSystemSound() + startVibrateLoopTimer(frequency: frequency) + } + + private func startVibrateLoopTimer(frequency: Vibrator.Frequency) { + guard vibrateLoopTimer == nil else { return } + vibrateLoopTimer = Timer.scheduledTimer(timeInterval: frequency.timeInterval, + target: self, + selector: #selector(playVibrateSystemSound), + userInfo: nil, + repeats: true) + } + + private func stopVibrateLoopTimer() { + guard + let timer: Timer = vibrateLoopTimer, + timer.isValid + else { return } + + timer.invalidate() + vibrateLoopTimer = nil + } + + // MARK: - Haptics + /// Plays an Apple Haptic and Audio Pattern (AHAP) file. + /// - Parameters: + /// - filename: The filename of the AHAP file containing the haptic pattern. + /// - loop: Determines whether the haptic repeats itself on completion. + public func startHaptic(named filename: String, loop: Bool) { + stopHaptic() + + loop + ? playHapticLoop(named: filename) + : playHaptic(named: filename) + } + + /// Stops the current playing haptic pattern. + /// + /// Has no effect if `loop` is `false` when starting the haptic. + public func stopHaptic() { + stopHapticLoopTimer() + try? hapticPlayer?.stop(atTime: CHHapticTimeImmediate) + hapticPlayer = nil + } + + private func playHaptic(named filename: String) { + guard + let hapticEngine: CHHapticEngine = hapticEngine, + let hapticPath: String = Bundle.main.path(forResource: filename, ofType: AppleHapticAudioPattern.fileExtension) + else { return } + + try? hapticEngine.start() + try? hapticEngine.playPattern(from: URL(fileURLWithPath: hapticPath)) + } + + private func playHapticLoop(named filename: String) { + guard + let hapticEngine: CHHapticEngine = hapticEngine, + let hapticPath: String = Bundle.main.path(forResource: filename, ofType: AppleHapticAudioPattern.fileExtension), + let hapticData: Data = try? Data(contentsOf: URL(fileURLWithPath: hapticPath)), + let appleHapticAudioPattern: AppleHapticAudioPattern = AppleHapticAudioPattern(data: hapticData), + let appleHapticAudioPatternDictionary: [CHHapticPattern.Key: Any] = appleHapticAudioPattern.dictionaryRepresentation(), + let hapticDuration: TimeInterval = appleHapticAudioPattern.pattern?.first(where: { $0.event?.eventDuration != nil })?.event?.eventDuration, + let hapticPattern: CHHapticPattern = try? CHHapticPattern(dictionary: appleHapticAudioPatternDictionary), + let hapticPlayer: CHHapticPatternPlayer = try? hapticEngine.makePlayer(with: hapticPattern) + else { return } + + try? hapticEngine.start() + self.hapticPlayer = hapticPlayer + try? self.hapticPlayer?.start(atTime: CHHapticTimeImmediate) + startHapticLoopTimer(timeInterval: hapticDuration) + } + + @objc private func restartHapticPlayer() { + try? hapticPlayer?.start(atTime: 0.0) + } + + private func startHapticLoopTimer(timeInterval: TimeInterval) { + guard hapticLoopTimer == nil else { return } + hapticLoopTimer = Timer.scheduledTimer(timeInterval: timeInterval, + target: self, + selector: #selector(restartHapticPlayer), + userInfo: nil, + repeats: true) + } + + private func stopHapticLoopTimer() { + guard + let timer: Timer = hapticLoopTimer, + timer.isValid + else { return } + + timer.invalidate() + hapticLoopTimer = nil + } + + /// Called when the haptic engine stops due to an external reason. + private func hapticEngineDidStop(reason: CHHapticEngine.StoppedReason) { + log("\(#function) -> reason: \(reason)") + } + + /// Called when the haptic engine fails. Will attempt to restart the haptic engine. + private func hapticEngineDidRecoverFromServerError() { + log("\(#function)") + try? hapticEngine?.start() + } + +} + +private extension Vibrator { + + // MARK: - Logging + func log(_ message: String) { + #if DEBUG + print("\n📳 \(String(describing: Vibrator.self)): \(#function) -> message: \(message)\n") + #endif + } + +} + +public extension AppleHapticAudioPattern { + + static let fileExtension: String = "ahap" + + // MARK: - Init + init?(data: Data) { + guard let appleHapticAudioPattern: AppleHapticAudioPattern = try? JSONDecoder().decode(AppleHapticAudioPattern.self, from: data) else { return nil } + self = appleHapticAudioPattern + } + + // MARK: - Dictionary + func dictionaryRepresentation() -> [CHHapticPattern.Key: Any]? { + guard let data: Data = try? JSONEncoder().encode(self) else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [CHHapticPattern.Key: Any] + } + +} + +// MARK: - AppleHapticAudioPattern +/// Codable representation of an Apple Haptic and Audio Pattern (AHAP) file. +/// +/// # Support +/// - Works with version 1.0 AHAP files generated with [Lofelt Composer](https://composer.lofelt.com). +/// - May work with all version 1.0 AHAP files but this has not been tested. +/// +/// - Note: Apple Documentation: [Representing Haptic Patterns in AHAP Files](https://developer.apple.com/documentation/corehaptics/representing_haptic_patterns_in_ahap_files). +public struct AppleHapticAudioPattern: Codable { + public let version: Double? + public let pattern: [Pattern]? + + enum CodingKeys: CHHapticPattern.Key.RawValue, CodingKey { + case version = "Version" + case pattern = "Pattern" + } +} + +// MARK: - Pattern +public struct Pattern: Codable { + public let event: Event? + public let parameterCurve: ParameterCurve? + + enum CodingKeys: CHHapticPattern.Key.RawValue, CodingKey { + case event = "Event" + case parameterCurve = "ParameterCurve" + } +} + +// MARK: - Event +public struct Event: Codable { + public let time: TimeInterval? + public let eventType: EventType? + public let eventDuration: TimeInterval? + public let eventParameters: [EventParameter]? + + enum CodingKeys: CHHapticPattern.Key.RawValue, CodingKey { + case time = "Time" + case eventType = "EventType" + case eventDuration = "EventDuration" + case eventParameters = "EventParameters" + } +} + +public enum EventType: CHHapticPattern.Key.RawValue, Codable { + case hapticContinuous = "HapticContinuous" + case hapticTransient = "HapticTransient" +} + +// MARK: - EventParameter +public struct EventParameter: Codable { + public let parameterID: EventParameterID? + public let parameterValue: Float? + + enum CodingKeys: CHHapticPattern.Key.RawValue, CodingKey { + case parameterID = "ParameterID" + case parameterValue = "ParameterValue" + } +} + +public enum EventParameterID: CHHapticPattern.Key.RawValue, Codable { + case hapticIntensity = "HapticIntensity" + case hapticSharpness = "HapticSharpness" +} + +// MARK: - ParameterCurve +public struct ParameterCurve: Codable { + public let parameterID: ParameterID? + public let time: TimeInterval? + public let parameterCurveControlPoints: [ParameterCurveControlPoint]? + + enum CodingKeys: CHHapticPattern.Key.RawValue, CodingKey { + case parameterID = "ParameterID" + case time = "Time" + case parameterCurveControlPoints = "ParameterCurveControlPoints" + } +} + +public enum ParameterID: CHHapticPattern.Key.RawValue, Codable { + case hapticIntensityControl = "HapticIntensityControl" + case hapticSharpnessControl = "HapticSharpnessControl" +} + +// MARK: - ParameterCurveControlPoint +public struct ParameterCurveControlPoint: Codable { + public let time: TimeInterval? + public let parameterValue: Float? + + enum CodingKeys: CHHapticPattern.Key.RawValue, CodingKey { + case time = "Time" + case parameterValue = "ParameterValue" + } +} diff --git a/src/index.tsx b/src/index.tsx index 526530f..3176d64 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -46,3 +46,11 @@ export function hapticWithPattern(pattern: HapticPattern[]) { } Haptics.hapticWithPattern(pattern); } + +export function play(fileName: string, loop: boolean = false) { + if (Platform.OS === 'android') { + console.log('Haptics is not supported on Android'); + return; + } + Haptics.play(fileName, loop); +}