also looking at this
fix(native): interaction, audio, document and checkpoint gaps stranded after #401
#415
19 files
+987
-50
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -1371,9 +1371,11 @@ documents that are not photos or videos. | ||
| 1371 | 1371 | focus before preparing a player, reports a denied request as `AUDIO_FOCUS_DENIED`, |
| 1372 | 1372 | pauses on a transient interruption and resumes on focus gain, ducks during a |
| 1373 | 1373 | duckable interruption, and releases focus when the last sound ends or the |
| 1374 | screen closes. A permanent focus loss releases every player and sends each | |
| 1375 | sound's error event; replacement, decode failure, cancellation, and teardown | |
| 1376 | also release their player resources. | |
| 1374 | screen closes. iOS observes `AVAudioSession` interruptions and resumes sounds | |
| 1375 | only when the system grants the `shouldResume` option. A permanent Android | |
| 1376 | focus loss releases every player and sends each sound's error event; replacement, | |
| 1377 | decode failure, cancellation, interruption, and teardown also release their | |
| 1378 | player resources. | |
| 1377 | 1379 | |
| 1378 | 1380 | Checkpoint names use the same bounded filename contract on both native hosts: |
| 1379 | 1381 | letters, digits, `.`, `_`, and `-`, up to 128 characters, without a leading |
| @@ -2132,6 +2132,9 @@ describe('Craft iOS builder', () => { | ||
| 2132 | 2132 | expect(audio).toContain('CraftNativeSounds.releaseSession()') |
| 2133 | 2133 | expect(services).toContain('category = respectSilentSwitch && (playing == 1 || category == .ambient) ? .ambient : .playback') |
| 2134 | 2134 | expect(services).toContain('try session.setCategory(category, mode: .default, options: [.mixWithOthers])') |
| 2135 | expect(services).toContain('AVAudioSession.interruptionNotification') | |
| 2136 | expect(services).toContain('sound.interrupted = true') | |
| 2137 | expect(services).toContain('AVAudioSession.InterruptionOptions.shouldResume') | |
| 2135 | 2138 | expect(plist).not.toContain('<string>audio</string>') |
| 2136 | 2139 | // A route change or an interruption stops AVAudioEngine; it starts again. |
| 2137 | 2140 | expect(audio).toContain('forName: .AVAudioEngineConfigurationChange, object: engine') |
| @@ -279,6 +279,7 @@ final class CraftNativeSounds { | ||
| 279 | 279 | /// The volume asked for; a fade in or out moves toward or away from it. |
| 280 | 280 | var volume: Float |
| 281 | 281 | var paused = false |
| 282 | var interrupted = false | |
| 282 | 283 | |
| 283 | 284 | init(player: AVQueuePlayer, volume: Float) { |
| 284 | 285 | self.player = player |
| @@ -306,9 +307,41 @@ final class CraftNativeSounds { | ||
| 306 | 307 | private static var category: AVAudioSession.Category = .ambient |
| 307 | 308 | |
| 308 | 309 | private var sounds: [String: Sound] = [:] |
| 310 | private var interruptionObserver: NSObjectProtocol? | |
| 309 | 311 | /// `{ id, event: "ended" | "error", message? }` to the screen's script. |
| 310 | 312 | var emit: (([String: Any]) -> Void)? |
| 311 | 313 | |
| 314 | init() { | |
| 315 | interruptionObserver = NotificationCenter.default.addObserver( | |
| 316 | forName: AVAudioSession.interruptionNotification, | |
| 317 | object: nil, | |
| 318 | queue: .main, | |
| 319 | ) { [weak self] note in | |
| 320 | guard let self else { return } | |
| 321 | let type = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? NSNumber)?.uintValue | |
| 322 | if type == AVAudioSession.InterruptionType.began.rawValue { | |
| 323 | self.sounds.values.forEach { sound in | |
| 324 | guard !sound.paused else { return } | |
| 325 | sound.interrupted = true | |
| 326 | sound.player.pause() | |
| 327 | } | |
| 328 | return | |
| 329 | } | |
| 330 | guard type == AVAudioSession.InterruptionType.ended.rawValue else { return } | |
| 331 | let options = (note.userInfo?[AVAudioSessionInterruptionOptionKey] as? NSNumber)?.uintValue ?? 0 | |
| 332 | guard options & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0 else { return } | |
| 333 | self.sounds.values.forEach { sound in | |
| 334 | guard sound.interrupted else { return } | |
| 335 | sound.interrupted = false | |
| 336 | sound.player.play() | |
| 337 | } | |
| 338 | } | |
| 339 | } | |
| 340 | ||
| 341 | deinit { | |
| 342 | if let interruptionObserver { NotificationCenter.default.removeObserver(interruptionObserver) } | |
| 343 | } | |
| 344 | ||
| 312 | 345 | /// Starts `url`. `answer` hears once it plays (or could not be loaded). |
| 313 | 346 | func play(id: String, url: URL, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) { |
| 314 | 347 | stop(id: id, fadeOut: 0) |
| @@ -365,6 +398,7 @@ final class CraftNativeSounds { | ||
| 365 | 398 | func pause(id: String) { |
| 366 | 399 | guard let sound = sounds[id] else { return } |
| 367 | 400 | sound.paused = true |
| 401 | sound.interrupted = false | |
| 368 | 402 | sound.fade?.invalidate() |
| 369 | 403 | sound.player.pause() |
| 370 | 404 | } |
| @@ -372,6 +406,7 @@ final class CraftNativeSounds { | ||
| 372 | 406 | func resume(id: String) { |
| 373 | 407 | guard let sound = sounds[id], sound.paused else { return } |
| 374 | 408 | sound.paused = false |
| 409 | sound.interrupted = false | |
| 375 | 410 | sound.player.volume = sound.volume |
| 376 | 411 | sound.player.play() |
| 377 | 412 | } |