Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -260,8 +244,6 @@ private fun playSound(
return
}

val audioFocusSession = requestTransientDuckAudioFocus(context, AudioAttributes.USAGE_GAME)

try {
val streamId = soundPool.play(
soundId,
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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()
}
Comment on lines 301 to 321

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The MediaPlayer instance created via MediaPlayer.create is not released if an exception occurs during setVolume, setOnCompletionListener, or start. Since mediaPlayer is declared inside the try block, it is not accessible in the catch block, leading to a potential resource leak of native audio resources. Declaring the variable outside the try block allows for proper cleanup in the catch block.

    var mediaPlayer: MediaPlayer? = null
    try {
        val audioAttributes = AudioAttributes.Builder()
            .setUsage(
                if (DeviceInfo.isFireOS()) {
                    AudioAttributes.USAGE_MEDIA
                } else {
                    AudioAttributes.USAGE_GAME
                },
            )
            .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
            .build()

        mediaPlayer = MediaPlayer.create(context, resId, audioAttributes, 0) ?: return
        mediaPlayer.setVolume(1.0f, 1.0f)
        mediaPlayer.setOnCompletionListener { it.release() }
        mediaPlayer.start()
    } catch (_: Exception) {
        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",
Expand All @@ -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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in commit 09d3b42. HapticFeedbackEffect.android.kt now uses shared BADGE_SOUND_NAMES/PR_SOUND_NAMES constants for both SoundPool loading and random selection, so the badge/PR lists are no longer duplicated.


/**
* 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) {
Expand Down
Loading