From c193f282821c0604ef9a3c08f410a3839efac6e0 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Wed, 9 Sep 2026 21:44:42 +0200 Subject: [PATCH] fix(ios): let a preset play again after it has been stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CHHapticPatternPlayer that CoreHaptics has already stopped does not start again, so replaying a preset at the same position — which reuses the parsed composer — was silent: no audio, no haptics. Stopping a player now unregisters it, and the next play rebuilds it from its pattern through the path that already existed for recreated players. Re-parsing also releases the players it replaces, instead of leaking them into the engine registry until eviction stopped ones still in use. --- .../Pulsar/Composers/PatternComposer.swift | 17 +++++++++++------ .../Sources/Pulsar/HapticEngineWrapper.swift | 9 +++++++-- .../PulsarTests/CoreHapticsMockTests.swift | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift index 825f2804..13063886 100644 --- a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift +++ b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift @@ -52,6 +52,7 @@ public class PatternComposer: NSObject { } private func parse(hapticsData: PatternData, audioEvent: CHHapticEvent?) { + releasePlayers() discreteLine.reset() continuousLine.reset() hasSound = audioEvent != nil @@ -162,6 +163,15 @@ public class PatternComposer: NSObject { } } + private func releasePlayers() { + if let id = continuousPlayerId { engine.removePlayer(id: id) } + if let id = discretePlayerId { engine.removePlayer(id: id) } + continuousPlayerId = nil + discretePlayerId = nil + continuousPattern = nil + discretePattern = nil + } + private func releaseAudio() { if let id = audioResourceID { engine.unregisterAudioResource(id) @@ -202,12 +212,7 @@ public class PatternComposer: NSObject { @objc public func dispose() { stop() - if let id = continuousPlayerId { engine.removePlayer(id: id) } - if let id = discretePlayerId { engine.removePlayer(id: id) } - continuousPlayerId = nil - discretePlayerId = nil - continuousPattern = nil - discretePattern = nil + releasePlayers() audioBuffer = nil hasSound = false releaseAudio() diff --git a/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift b/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift index 5e4bbbe0..6aeb4c6f 100644 --- a/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift +++ b/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift @@ -122,6 +122,7 @@ public extension HapticEngineWrapper { func stopPlayer(id: Int) { guard let player = playerRegistry[id] else { return } stopPlayer(player, errorPrefix: "Error stopping player") + unregisterPlayer(id) } func removePlayer(id: Int) { @@ -129,8 +130,7 @@ public extension HapticEngineWrapper { try? player.stop(atTime: 0) } - playerRegistry.removeValue(forKey: id) - playerCreationOrder.removeAll { $0 == id } + unregisterPlayer(id) } } @@ -350,6 +350,11 @@ private extension HapticEngineWrapper { playerCreationOrder.append(id) } + func unregisterPlayer(_ id: Int) { + playerRegistry.removeValue(forKey: id) + playerCreationOrder.removeAll { $0 == id } + } + func evictOldestPlayerIfNeeded() { guard playerRegistry.count >= playerLimit else { return } diff --git a/iOS/Pulsar/Tests/PulsarTests/CoreHapticsMockTests.swift b/iOS/Pulsar/Tests/PulsarTests/CoreHapticsMockTests.swift index 2dab27db..f9b184cc 100644 --- a/iOS/Pulsar/Tests/PulsarTests/CoreHapticsMockTests.swift +++ b/iOS/Pulsar/Tests/PulsarTests/CoreHapticsMockTests.swift @@ -67,6 +67,24 @@ struct CoreHapticsMockTests { #expect(HapticMockRecorder.shared.playerStops >= 1) } + @Test func playingAfterAStopBuildsAFreshPlayer() { + CoreHapticsMock.install() + defer { CoreHapticsMock.uninstall() } + + let engine = activeEngine() + let pattern = makePattern() + let created = engine.createPlayer(pattern: pattern) + #expect(created != nil) + guard let id = created else { return } + + engine.playPlayer(id: id, pattern: pattern) + engine.stopPlayer(id: id) + engine.playPlayer(id: id, pattern: pattern) + + #expect(HapticMockRecorder.shared.playersCreated == 2) + #expect(HapticMockRecorder.shared.playerStarts == 2) + } + @Test func stopHapticsStopsEveryRegisteredPlayer() { CoreHapticsMock.install() defer { CoreHapticsMock.uninstall() }