ReviewOS

also looking at this

craft-native/craft

fix(native): interaction, audio, document and checkpoint gaps stranded after #401

#415
Merged glennmichael123 wants to merge codex/issues-388-400-344 into main
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.

docs/guides/native-stx.mdmodified+5-3
Changes to docs/guides/native-stx.md
@@ -1371,9 +1371,11 @@ documents that are not photos or videos.
13711371focus before preparing a player, reports a denied request as `AUDIO_FOCUS_DENIED`,
13721372pauses on a transient interruption and resumes on focus gain, ducks during a
13731373duckable interruption, and releases focus when the last sound ends or the
1374screen closes. A permanent focus loss releases every player and sends each
1375sound's error event; replacement, decode failure, cancellation, and teardown
1376also release their player resources.
1374screen closes. iOS observes `AVAudioSession` interruptions and resumes sounds
1375only when the system grants the `shouldResume` option. A permanent Android
1376focus loss releases every player and sends each sound's error event; replacement,
1377decode failure, cancellation, interruption, and teardown also release their
1378player resources.
13771379
13781380Checkpoint names use the same bounded filename contract on both native hosts:
13791381letters, digits, `.`, `_`, and `-`, up to 128 characters, without a leading
packages/ios/src/index.test.tsmodified+3-0
Changes to packages/ios/src/index.test.ts
@@ -2132,6 +2132,9 @@ describe('Craft iOS builder', () => {
21322132 expect(audio).toContain('CraftNativeSounds.releaseSession()')
21332133 expect(services).toContain('category = respectSilentSwitch && (playing == 1 || category == .ambient) ? .ambient : .playback')
21342134 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')
21352138 expect(plist).not.toContain('<string>audio</string>')
21362139 // A route change or an interruption stops AVAudioEngine; it starts again.
21372140 expect(audio).toContain('forName: .AVAudioEngineConfigurationChange, object: engine')
packages/ios/templates/CraftNativeServices.swiftmodified+35-0
Changes to packages/ios/templates/CraftNativeServices.swift
@@ -279,6 +279,7 @@ final class CraftNativeSounds {
279279 /// The volume asked for; a fade in or out moves toward or away from it.
280280 var volume: Float
281281 var paused = false
282 var interrupted = false
282283
283284 init(player: AVQueuePlayer, volume: Float) {
284285 self.player = player
@@ -306,9 +307,41 @@ final class CraftNativeSounds {
306307 private static var category: AVAudioSession.Category = .ambient
307308
308309 private var sounds: [String: Sound] = [:]
310 private var interruptionObserver: NSObjectProtocol?
309311 /// `{ id, event: "ended" | "error", message? }` to the screen's script.
310312 var emit: (([String: Any]) -> Void)?
311313
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
312345 /// Starts `url`. `answer` hears once it plays (or could not be loaded).
313346 func play(id: String, url: URL, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
314347 stop(id: id, fadeOut: 0)
@@ -365,6 +398,7 @@ final class CraftNativeSounds {
365398 func pause(id: String) {
366399 guard let sound = sounds[id] else { return }
367400 sound.paused = true
401 sound.interrupted = false
368402 sound.fade?.invalidate()
369403 sound.player.pause()
370404 }
@@ -372,6 +406,7 @@ final class CraftNativeSounds {
372406 func resume(id: String) {
373407 guard let sound = sounds[id], sound.paused else { return }
374408 sound.paused = false
409 sound.interrupted = false
375410 sound.player.volume = sound.volume
376411 sound.player.play()
377412 }