-
-
Notifications
You must be signed in to change notification settings - Fork 26
Revert audio focus duck attempt (#423) — restore audio output for #421 #428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,13 +3,10 @@ package com.devil.phoenixproject.presentation.components | |
| import android.annotation.SuppressLint | ||
| import android.content.Context | ||
| import android.media.AudioAttributes | ||
| import android.media.AudioFocusRequest | ||
| import android.media.AudioManager | ||
| import android.media.MediaPlayer | ||
| import android.media.SoundPool | ||
| import android.os.Build | ||
| import android.os.Handler | ||
| import android.os.Looper | ||
| import android.os.VibrationEffect | ||
| import android.os.Vibrator | ||
| import android.os.VibratorManager | ||
|
|
@@ -90,25 +87,12 @@ actual fun HapticFeedbackEffect(hapticEvents: SharedFlow<HapticEvent>) { | |
|
|
||
| // Load badge celebration sounds | ||
| val badgeSoundIds = remember(soundPool) { | ||
| val badgeSoundNames = listOf( | ||
| "absolute_domination", "absolute_unit", "another_milestone_crushed", | ||
| "beast_mode", "insane_performance", "maxed_out", "new_peak_achieved", | ||
| "new_record_secured", "no_ones_stopping_you_now", "power", "pr", | ||
| "pressure_create_greatness", "record", "shattered", "strenght_unlocked", | ||
| "that_bar_never_stood_a_chance", "that_was_a_demolition", "that_was_god_mode", | ||
| "that_was_monster_level", "that_was_next_tier_strenght", "that_was_pure_savagery", | ||
| "the_grind_continues", "the_grind_is_real", "this_is_what_champions_are_made", | ||
| "unchained_power", "unstoppable", "victory", "you_crushed_that", | ||
| "you_dominated_that_set", "you_just_broke_your_limits", "you_just_destroyed_that_weight", | ||
| "you_just_levelled_up", "you_went_full_throttle", | ||
| ) | ||
| badgeSoundNames.mapNotNull { loadSoundByName(context, soundPool, it) } | ||
| BADGE_SOUND_NAMES.mapNotNull { loadSoundByName(context, soundPool, it) } | ||
| } | ||
|
|
||
| // Load PR-specific sounds | ||
| val prSoundIds = remember(soundPool) { | ||
| listOf("new_personal_record", "new_personal_record_2") | ||
| .mapNotNull { loadSoundByName(context, soundPool, it) } | ||
| PR_SOUND_NAMES.mapNotNull { loadSoundByName(context, soundPool, it) } | ||
| } | ||
|
|
||
| // Load rep count sounds (1-25) | ||
|
|
@@ -260,8 +244,6 @@ private fun playSound( | |
| return | ||
| } | ||
|
|
||
| val audioFocusSession = requestTransientDuckAudioFocus(context, AudioAttributes.USAGE_GAME) | ||
|
|
||
| try { | ||
| val streamId = soundPool.play( | ||
| soundId, | ||
|
|
@@ -274,14 +256,10 @@ private fun playSound( | |
| // If SoundPool fails, try MediaPlayer fallback | ||
| if (streamId == 0) { | ||
| Logger.d { "SoundPool.play returned 0 for $event (soundId=$soundId) — MediaPlayer fallback" } | ||
| audioFocusSession.abandon() | ||
| playWithMediaPlayer(event, context) | ||
| } else { | ||
| audioFocusSession.abandonAfterDelay(focusHoldDurationMs(context, event)) | ||
| } | ||
| } catch (e: Exception) { | ||
| Logger.w(e) { "SoundPool.play threw for $event — MediaPlayer fallback" } | ||
| audioFocusSession.abandon() | ||
| playWithMediaPlayer(event, context) | ||
| } | ||
| } | ||
|
|
@@ -319,156 +297,31 @@ private fun playWithMediaPlayer(event: HapticEvent, context: Context) { | |
| } | ||
| if (resId == 0) return | ||
|
|
||
| val playbackUsage = if (DeviceInfo.isFireOS()) { | ||
| AudioAttributes.USAGE_MEDIA | ||
| } else { | ||
| AudioAttributes.USAGE_GAME | ||
| } | ||
| val audioFocusSession = requestTransientDuckAudioFocus(context, playbackUsage) | ||
|
|
||
| var mediaPlayer: MediaPlayer? = null | ||
| try { | ||
| // Fire OS: Use USAGE_MEDIA to work around SoundPool volume bug | ||
| // Standard Android: Use USAGE_GAME to mix with music without interrupting | ||
| val audioAttributes = AudioAttributes.Builder() | ||
| .setUsage(playbackUsage) | ||
| .setUsage( | ||
| if (DeviceInfo.isFireOS()) { | ||
| AudioAttributes.USAGE_MEDIA | ||
| } else { | ||
| AudioAttributes.USAGE_GAME | ||
| }, | ||
| ) | ||
| .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) | ||
| .build() | ||
|
|
||
| val mediaPlayer = MediaPlayer.create(context, resId, audioAttributes, 0) | ||
| if (mediaPlayer == null) { | ||
| audioFocusSession.abandon() | ||
| return | ||
| } | ||
| mediaPlayer = MediaPlayer.create(context, resId, audioAttributes, 0) ?: return | ||
| mediaPlayer.setVolume(1.0f, 1.0f) | ||
| mediaPlayer.setOnCompletionListener { | ||
| it.release() | ||
| audioFocusSession.abandon() | ||
| } | ||
| mediaPlayer.setOnCompletionListener { it.release() } | ||
| mediaPlayer.start() | ||
| } catch (_: Exception) { | ||
| // Silently fail - sound is not critical | ||
| audioFocusSession.abandon() | ||
| } | ||
| } | ||
|
|
||
|
|
||
| private val soundDurationCacheMs = mutableMapOf<String, Long>() | ||
|
|
||
| private fun focusHoldDurationMs(context: Context, event: HapticEvent): Long { | ||
| val candidateSoundNames = when (event) { | ||
| is HapticEvent.BADGE_EARNED -> getBadgeSoundNames() | ||
| is HapticEvent.PERSONAL_RECORD -> getPRSoundNames() | ||
| is HapticEvent.DISCO_MODE_UNLOCKED -> listOf("discomode") | ||
| is HapticEvent.REP_COUNT_ANNOUNCED -> listOf("rep_%02d".format(event.repNumber)) | ||
| is HapticEvent.REST_ENDING -> listOf("restover") | ||
| else -> emptyList() | ||
| } | ||
|
|
||
| val maxDurationMs = candidateSoundNames.maxOfOrNull { getSoundDurationMs(context, it) } ?: 0L | ||
| val fallbackMs = 1500L | ||
| val safetyBufferMs = 250L | ||
| return maxOf(fallbackMs, maxDurationMs + safetyBufferMs) | ||
| } | ||
|
|
||
| private fun getSoundDurationMs(context: Context, soundName: String): Long { | ||
| soundDurationCacheMs[soundName]?.let { return it } | ||
|
|
||
| val packageName = context.packageName.removeSuffix(".debug") | ||
| var resId = context.resources.getIdentifier(soundName, "raw", packageName) | ||
| if (resId == 0) { | ||
| resId = context.resources.getIdentifier(soundName, "raw", context.packageName) | ||
| } | ||
| if (resId == 0) return 0L | ||
|
|
||
| val durationMs = try { | ||
| MediaPlayer.create(context, resId)?.useDurationOrZero() ?: 0L | ||
| } catch (_: Exception) { | ||
| 0L | ||
| } | ||
|
|
||
| soundDurationCacheMs[soundName] = durationMs | ||
| return durationMs | ||
| } | ||
|
|
||
| private fun MediaPlayer.useDurationOrZero(): Long = try { | ||
| duration.toLong() | ||
| } catch (_: Exception) { | ||
| 0L | ||
| } finally { | ||
| try { | ||
| release() | ||
| } catch (_: Exception) { | ||
| // Best effort cleanup | ||
| mediaPlayer?.release() | ||
| } | ||
| } | ||
|
|
||
| private fun AudioFocusSession.abandonAfterDelay(delayMs: Long = 1500L) { | ||
| Handler(Looper.getMainLooper()).postDelayed({ | ||
| abandon() | ||
| }, delayMs) | ||
| } | ||
|
|
||
| private interface AudioFocusSession { | ||
| fun abandon() | ||
| } | ||
|
|
||
| private fun requestTransientDuckAudioFocus( | ||
| context: Context, | ||
| playbackUsage: Int, | ||
| ): AudioFocusSession { | ||
| val focusChangeListener = AudioManager.OnAudioFocusChangeListener { | ||
| // No-op listener used as a unique identity token for each focus request session. | ||
| } | ||
| val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager | ||
| ?: return object : AudioFocusSession { | ||
| override fun abandon() = Unit | ||
| } | ||
|
|
||
| return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | ||
| try { | ||
| val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) | ||
| .setAudioAttributes( | ||
| AudioAttributes.Builder() | ||
| .setUsage(playbackUsage) | ||
| .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) | ||
| .build(), | ||
| ) | ||
| .setOnAudioFocusChangeListener(focusChangeListener) | ||
| .setAcceptsDelayedFocusGain(false) | ||
| .setWillPauseWhenDucked(false) | ||
| .build() | ||
| audioManager.requestAudioFocus(request) | ||
| object : AudioFocusSession { | ||
| override fun abandon() { | ||
| try { | ||
| audioManager.abandonAudioFocusRequest(request) | ||
| } catch (_: Exception) { | ||
| // Best effort | ||
| } | ||
| } | ||
| } | ||
| } catch (_: Exception) { | ||
| object : AudioFocusSession { | ||
| override fun abandon() = Unit | ||
| } | ||
| } | ||
| } else { | ||
| @Suppress("DEPRECATION") | ||
| audioManager.requestAudioFocus( | ||
| focusChangeListener, | ||
| AudioManager.STREAM_MUSIC, | ||
| AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK, | ||
| ) | ||
| object : AudioFocusSession { | ||
| override fun abandon() { | ||
| @Suppress("DEPRECATION") | ||
| audioManager.abandonAudioFocus(focusChangeListener) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun getBadgeSoundNames(): List<String> = listOf( | ||
| private val BADGE_SOUND_NAMES = listOf( | ||
| "absolute_domination", "absolute_unit", "another_milestone_crushed", | ||
| "beast_mode", "insane_performance", "maxed_out", "new_peak_achieved", | ||
| "new_record_secured", "no_ones_stopping_you_now", "power", "pr", | ||
|
|
@@ -481,25 +334,19 @@ private fun getBadgeSoundNames(): List<String> = listOf( | |
| "you_just_levelled_up", "you_went_full_throttle", | ||
| ) | ||
|
|
||
| private fun getPRSoundNames(): List<String> = listOf("new_personal_record", "new_personal_record_2") | ||
|
|
||
| private val PR_SOUND_NAMES = listOf("new_personal_record", "new_personal_record_2") | ||
|
|
||
| /** | ||
| * Get a random badge celebration sound name. | ||
| */ | ||
| private fun getRandomBadgeSound(): String { | ||
| val badgeSoundNames = getBadgeSoundNames() | ||
| return badgeSoundNames[Random.nextInt(badgeSoundNames.size)] | ||
| } | ||
| private fun getRandomBadgeSound(): String = | ||
| BADGE_SOUND_NAMES[Random.nextInt(BADGE_SOUND_NAMES.size)] | ||
|
Comment on lines
339
to
+343
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @copilot apply changes based on this feedback
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Implemented in commit 09d3b42. |
||
|
|
||
| /** | ||
| * Get a random PR celebration sound name. | ||
| */ | ||
| private fun getRandomPRSound(): String { | ||
| val prSoundNames = getPRSoundNames() | ||
| return prSoundNames[Random.nextInt(prSoundNames.size)] | ||
| } | ||
|
|
||
| private fun getRandomPRSound(): String = | ||
| PR_SOUND_NAMES[Random.nextInt(PR_SOUND_NAMES.size)] | ||
|
|
||
| @SuppressLint("MissingPermission") | ||
| private fun playHapticFeedback(vibrator: Vibrator, event: HapticEvent) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
MediaPlayerinstance created viaMediaPlayer.createis not released if an exception occurs duringsetVolume,setOnCompletionListener, orstart. SincemediaPlayeris declared inside thetryblock, it is not accessible in thecatchblock, leading to a potential resource leak of native audio resources. Declaring the variable outside thetryblock allows for proper cleanup in thecatchblock.