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.
| @@ -1367,6 +1367,14 @@ dismissed picker resolves with an empty array; a canceled or oversized copy | ||
| 1367 | 1367 | never leaves a partial file behind. Use this for GPX, TCX, FIT, PDF, or other |
| 1368 | 1368 | documents that are not photos or videos. |
| 1369 | 1369 | |
| 1370 | `craft.audio` playback is screen-owned on both hosts. Android requests audio | |
| 1371 | focus before preparing a player, reports a denied request as `AUDIO_FOCUS_DENIED`, | |
| 1372 | pauses on a transient interruption and resumes on focus gain, ducks during a | |
| 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. | |
| 1377 | ||
| 1370 | 1378 | The `media` capability (iOS) keeps video and audio on the phone for offline, |
| 1371 | 1379 | in Application Support rather than the temporary directory, left out of |
| 1372 | 1380 | iCloud backups. `craft.media.keep(url, { headers })` downloads the file once |
| @@ -793,6 +793,11 @@ describe('Craft Android builder', () => { | ||
| 793 | 793 | expect(sounds).toContain('setOnErrorListener') |
| 794 | 794 | expect(sounds).toContain('sound.player.release()') |
| 795 | 795 | 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') | |
| 796 | 801 | expect(native).toContain('put("audio")') |
| 797 | 802 | expect(native).toContain('screen.soundRequestCancellers[token]') |
| 798 | 803 | expect(native).toContain('sounds.close()') |
| @@ -2,6 +2,8 @@ package | ||
| 2 | 2 | |
| 3 | 3 | import android.content.Context |
| 4 | 4 | import android.media.AudioAttributes |
| 5 | import android.media.AudioFocusRequest | |
| 6 | import android.media.AudioManager | |
| 5 | 7 | import android.media.MediaPlayer |
| 6 | 8 | import android.net.Uri |
| 7 | 9 | import android.os.Handler |
| @@ -23,11 +25,23 @@ internal class CraftNativeSounds(private val context: Context) { | ||
| 23 | 25 | val player: MediaPlayer, |
| 24 | 26 | var volume: Float, |
| 25 | 27 | var paused: Boolean = false, |
| 28 | var interrupted: Boolean = false, | |
| 26 | 29 | var fade: Runnable? = null, |
| 27 | 30 | ) |
| 28 | 31 | |
| 29 | 32 | private val main = Handler(Looper.getMainLooper()) |
| 30 | 33 | 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 | } | |
| 31 | 45 | |
| 32 | 46 | /** `{ id, event: "ended" | "error", message? }` for the screen bridge. */ |
| 33 | 47 | var emit: ((JSONObject) -> Unit)? = null |
| @@ -59,6 +73,13 @@ internal class CraftNativeSounds(private val context: Context) { | ||
| 59 | 73 | emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", message)) |
| 60 | 74 | } |
| 61 | 75 | |
| 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 | ||
| 62 | 83 | runCatching { |
| 63 | 84 | player.setAudioAttributes(AudioAttributes.Builder() |
| 64 | 85 | .setUsage(AudioAttributes.USAGE_GAME) |
| @@ -105,6 +126,7 @@ internal class CraftNativeSounds(private val context: Context) { | ||
| 105 | 126 | val sound = sounds[id] ?: return false |
| 106 | 127 | runCatching { sound.player.start() }.onFailure { return false } |
| 107 | 128 | sound.paused = false |
| 129 | sound.interrupted = false | |
| 108 | 130 | return true |
| 109 | 131 | } |
| 110 | 132 | |
| @@ -158,6 +180,37 @@ internal class CraftNativeSounds(private val context: Context) { | ||
| 158 | 180 | sound.player.stop() |
| 159 | 181 | } |
| 160 | 182 | 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 | } | |
| 161 | 214 | } |
| 162 | 215 | |
| 163 | 216 | private fun ramp(sound: Sound, target: Float, seconds: Double, done: (() -> Unit)? = null) { |