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+8-0
Changes to docs/guides/native-stx.md
@@ -1367,6 +1367,14 @@ dismissed picker resolves with an empty array; a canceled or oversized copy
13671367never leaves a partial file behind. Use this for GPX, TCX, FIT, PDF, or other
13681368documents that are not photos or videos.
13691369
1370`craft.audio` playback is screen-owned on both hosts. Android requests audio
1371focus before preparing a player, reports a denied request as `AUDIO_FOCUS_DENIED`,
1372pauses on a transient interruption and resumes on focus gain, ducks during a
1373duckable 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.
1377
13701378The `media` capability (iOS) keeps video and audio on the phone for offline,
13711379in Application Support rather than the temporary directory, left out of
13721380iCloud backups. `craft.media.keep(url, { headers })` downloads the file once
packages/android/src/index.test.tsmodified+5-0
Changes to packages/android/src/index.test.ts
@@ -793,6 +793,11 @@ describe('Craft Android builder', () => {
793793 expect(sounds).toContain('setOnErrorListener')
794794 expect(sounds).toContain('sound.player.release()')
795795 expect(sounds).toContain('CraftNativeMedia.local(context, text)')
796 expect(sounds).toContain('AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)')
797 expect(sounds).toContain('AUDIO_FOCUS_DENIED')
798 expect(sounds).toContain('AUDIOFOCUS_LOSS_TRANSIENT')
799 expect(sounds).toContain('AudioManager.AUDIOFOCUS_GAIN')
800 expect(sounds).toContain('abandonAudioFocusRequest')
796801 expect(native).toContain('put("audio")')
797802 expect(native).toContain('screen.soundRequestCancellers[token]')
798803 expect(native).toContain('sounds.close()')
packages/android/templates/CraftNativeSounds.kt.templatemodified+53-0
Changes to packages/android/templates/CraftNativeSounds.kt.template
@@ -2,6 +2,8 @@ package
22
33import android.content.Context
44import android.media.AudioAttributes
5import android.media.AudioFocusRequest
6import android.media.AudioManager
57import android.media.MediaPlayer
68import android.net.Uri
79import android.os.Handler
@@ -23,11 +25,23 @@ internal class CraftNativeSounds(private val context: Context) {
2325 val player: MediaPlayer,
2426 var volume: Float,
2527 var paused: Boolean = false,
28 var interrupted: Boolean = false,
2629 var fade: Runnable? = null,
2730 )
2831
2932 private val main = Handler(Looper.getMainLooper())
3033 private val sounds = mutableMapOf<String, Sound>()
34 private val audioManager by lazy { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
35 private val focusRequest by lazy {
36 AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
37 .setAudioAttributes(AudioAttributes.Builder()
38 .setUsage(AudioAttributes.USAGE_GAME)
39 .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
40 .build())
41 .setAcceptsDelayedFocusGain(false)
42 .setOnAudioFocusChangeListener({ change -> onAudioFocusChange(change) }, main)
43 .build()
44 }
3145
3246 /** `{ id, event: "ended" | "error", message? }` for the screen bridge. */
3347 var emit: ((JSONObject) -> Unit)? = null
@@ -59,6 +73,13 @@ internal class CraftNativeSounds(private val context: Context) {
5973 emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", message))
6074 }
6175
76 if (audioManager.requestAudioFocus(focusRequest) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
77 remove(id)
78 settle(failure("AUDIO_FOCUS_DENIED", "Audio focus was denied by the operating system"))
79 emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", "Audio focus was denied by the operating system"))
80 return
81 }
82
6283 runCatching {
6384 player.setAudioAttributes(AudioAttributes.Builder()
6485 .setUsage(AudioAttributes.USAGE_GAME)
@@ -105,6 +126,7 @@ internal class CraftNativeSounds(private val context: Context) {
105126 val sound = sounds[id] ?: return false
106127 runCatching { sound.player.start() }.onFailure { return false }
107128 sound.paused = false
129 sound.interrupted = false
108130 return true
109131 }
110132
@@ -158,6 +180,37 @@ internal class CraftNativeSounds(private val context: Context) {
158180 sound.player.stop()
159181 }
160182 sound.player.release()
183 if (sounds.isEmpty()) audioManager.abandonAudioFocusRequest(focusRequest)
184 }
185
186 private fun onAudioFocusChange(change: Int) {
187 when (change) {
188 AudioManager.AUDIOFOCUS_LOSS -> {
189 sounds.keys.toList().forEach { id ->
190 if (sounds[id] != null) {
191 remove(id)
192 emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", "Audio focus was lost"))
193 }
194 }
195 }
196 AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> sounds.values.toList().forEach { sound ->
197 if (!sound.paused && runCatching { sound.player.isPlaying }.getOrDefault(false)) {
198 sound.interrupted = true
199 runCatching { sound.player.pause() }
200 }
201 }
202 AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> sounds.values.toList().forEach { sound ->
203 runCatching { sound.player.setVolume(sound.volume * 0.2f, sound.volume * 0.2f) }
204 }
205 AudioManager.AUDIOFOCUS_GAIN -> sounds.values.toList().forEach { sound ->
206 if (sound.interrupted) {
207 sound.interrupted = false
208 runCatching { sound.player.start() }
209 } else {
210 runCatching { sound.player.setVolume(sound.volume, sound.volume) }
211 }
212 }
213 }
161214 }
162215
163216 private fun ramp(sound: Sound, target: Float, seconds: Double, done: (() -> Unit)? = null) {