From 632a3e37af30ca17934d2d511d7b79e8f11cb4ba Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Wed, 9 Sep 2026 23:17:31 +0200 Subject: [PATCH] fix(ios): rebuild a pattern player once it has been stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple's contract for `startAtTime:` only promises a rewind for a player that is already playing: "If you call this method on a player that's already playing, it restarts itself at the beginning of the pattern." Nothing covers a stopped player, and the basic CHHapticPatternPlayer has no way to rewind one — seek(toOffset:) exists only on the advanced player. A stopped player stays parked at its stop offset, so starting it again produces no output. Stopping a player therefore unregisters it, and the next play rebuilds it from its pattern through the path already used after an engine reset. --- .../Sources/Pulsar/HapticEngineWrapper.swift | 9 +++++++-- .../PulsarTests/CoreHapticsMockTests.swift | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) 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() }