fix(player): do not start a video before audio focus is granted (DR-145)
Video manages audio focus by hand (handleAudioFocus=false, since ExoPlayer's automatic handling is reserved for the audio path), and all three outcomes of the request were treated as success. AUDIOFOCUS_ REQUEST_DELAYED — which setAcceptsDelayedFocusGain(true) explicitly invites, and which means the system is withholding our audio until it calls back — and an outright REQUEST_FAILED were logged and then followed by playWhenReady = true. The picture rolled with no sound, which to the user is indistinguishable from a broken stream. Hold playback when focus is not granted and start it from the AUDIOFOCUS_GAIN callback. An explicit play() re-requests focus instead of resuming into a stream the system is still muting, guarded by a held-focus flag so repeated plays do not leak focus requests. LOSS clears the pending flag so an unrelated later GAIN cannot start playback the user never asked for. Verified by compiling :app:compileArm64DebugKotlin. Not unit-tested: this tree has no Kotlin test source set (the Gradle project lives in the generated, gitignored gen/ tree), so the logic cannot be exercised off device without restructuring the Android build.
This commit is contained in:
@@ -240,6 +240,20 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
|
||||
/**
|
||||
* Set when a video was loaded but audio focus was not granted outright.
|
||||
*
|
||||
* `setAcceptsDelayedFocusGain(true)` means the system may answer DELAYED and
|
||||
* hand us focus later; until then it withholds our audio. Starting playback
|
||||
* anyway plays the video silently, which is exactly the "video has no sound"
|
||||
* symptom. We hold playback and start it from the AUDIOFOCUS_GAIN callback.
|
||||
*/
|
||||
private var pendingPlayOnFocusGain = false
|
||||
|
||||
/** Whether we currently hold audio focus, so `play()` does not re-request
|
||||
* (and leak) a focus request we already own. */
|
||||
private var hasAudioFocus = false
|
||||
|
||||
init {
|
||||
// Configure audio attributes for music playback with audio focus handling
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
@@ -431,6 +445,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
*/
|
||||
fun play() {
|
||||
mainHandler.post {
|
||||
// Video manages focus by hand, so an explicit play after a refusal (or
|
||||
// after a LOSS paused us) has to ask again — otherwise it resumes into
|
||||
// a stream the system is still muting.
|
||||
if (currentMediaType == MediaType.VIDEO && !hasAudioFocus && !requestAudioFocus()) {
|
||||
pendingPlayOnFocusGain = true
|
||||
android.util.Log.d("JellyTauPlayer", "play() without audio focus - holding until GAIN")
|
||||
return@post
|
||||
}
|
||||
pendingPlayOnFocusGain = false
|
||||
exoPlayer.play()
|
||||
}
|
||||
}
|
||||
@@ -819,14 +842,17 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
android.util.Log.d("JellyTauPlayer", "ExoPlayer audio session ID: ${exoPlayer.audioSessionId}")
|
||||
|
||||
// Setup video surface if needed
|
||||
var focusGranted = true
|
||||
if (currentMediaType == MediaType.VIDEO) {
|
||||
getOrCreateSurfaceView()
|
||||
android.util.Log.d("JellyTauPlayer", "Video surface created for playback")
|
||||
// Automatically attach the surface to the Activity
|
||||
autoAttachSurface()
|
||||
|
||||
// CRITICAL: Request audio focus for video playback
|
||||
requestAudioFocus()
|
||||
// CRITICAL: Request audio focus for video playback. Video manages
|
||||
// focus by hand (handleAudioFocus=false above), so nothing else
|
||||
// will hold playback back if the request is delayed or refused.
|
||||
focusGranted = requestAudioFocus()
|
||||
} else {
|
||||
clearVideoSurface()
|
||||
// Abandon audio focus when switching to audio (audio uses ExoPlayer's built-in handling)
|
||||
@@ -896,8 +922,13 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
android.util.Log.d("JellyTauPlayer", "✓ Current volume: ${exoPlayer.volume}, deviceVolume: ${audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)}/${audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}")
|
||||
|
||||
exoPlayer.playWhenReady = true
|
||||
android.util.Log.d("JellyTauPlayer", "playWhenReady set to TRUE. Current state: ${exoPlayer.playbackState}")
|
||||
// Only roll if we hold audio focus. A DELAYED grant means the system
|
||||
// is withholding our audio until it calls back with AUDIOFOCUS_GAIN;
|
||||
// playing through it produces picture with no sound. Playback resumes
|
||||
// from the focus listener instead.
|
||||
pendingPlayOnFocusGain = !focusGranted
|
||||
exoPlayer.playWhenReady = focusGranted
|
||||
android.util.Log.d("JellyTauPlayer", "playWhenReady set to $focusGranted (pendingPlayOnFocusGain=$pendingPlayOnFocusGain). Current state: ${exoPlayer.playbackState}")
|
||||
|
||||
// Start the foreground service for lockscreen controls
|
||||
startPlaybackService()
|
||||
@@ -1154,8 +1185,14 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
/**
|
||||
* Request audio focus for video playback.
|
||||
* This is critical for video to have audio on Android.
|
||||
*
|
||||
* TRACES: UR-004 | DR-145
|
||||
*
|
||||
* @return true if focus was granted outright and playback may start now.
|
||||
* false for a DELAYED or refused request — the caller must hold playback
|
||||
* and let the AUDIOFOCUS_GAIN callback start it, or the video plays mute.
|
||||
*/
|
||||
private fun requestAudioFocus() {
|
||||
private fun requestAudioFocus(): Boolean {
|
||||
android.util.Log.d("JellyTauPlayer", "Requesting audio focus for video playback")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@@ -1172,17 +1209,29 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GAINED - ensuring full volume")
|
||||
hasAudioFocus = true
|
||||
if (exoPlayer.volume < 1.0f) {
|
||||
exoPlayer.volume = 1.0f
|
||||
android.util.Log.d("JellyTauPlayer", " Volume restored to 1.0 from ${exoPlayer.volume}")
|
||||
}
|
||||
// A delayed grant arriving: this is the point at which
|
||||
// the video may actually be heard, so start it now.
|
||||
if (pendingPlayOnFocusGain) {
|
||||
pendingPlayOnFocusGain = false
|
||||
android.util.Log.d("JellyTauPlayer", " Delayed focus granted - starting held playback")
|
||||
exoPlayer.playWhenReady = true
|
||||
}
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
android.util.Log.d("JellyTauPlayer", "Audio focus LOST - pausing")
|
||||
hasAudioFocus = false
|
||||
pendingPlayOnFocusGain = false
|
||||
pause()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
android.util.Log.d("JellyTauPlayer", "Audio focus LOST TRANSIENT - pausing")
|
||||
hasAudioFocus = false
|
||||
pendingPlayOnFocusGain = false
|
||||
pause()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
@@ -1193,16 +1242,25 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
.build()
|
||||
|
||||
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
|
||||
when (result) {
|
||||
return when (val result = audioManager.requestAudioFocus(audioFocusRequest!!)) {
|
||||
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> {
|
||||
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GRANTED")
|
||||
hasAudioFocus = true
|
||||
true
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_REQUEST_FAILED -> {
|
||||
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED!")
|
||||
// Something holds exclusive focus (a call, say). Playing now
|
||||
// would be a silent video, so hold and wait for the grant.
|
||||
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED - holding playback")
|
||||
false
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> {
|
||||
android.util.Log.d("JellyTauPlayer", "⏳ Audio focus DELAYED")
|
||||
android.util.Log.d("JellyTauPlayer", "⏳ Audio focus DELAYED - holding playback until GAIN")
|
||||
false
|
||||
}
|
||||
else -> {
|
||||
android.util.Log.w("JellyTauPlayer", "Unknown audio focus result: $result - holding playback")
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1214,10 +1272,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN
|
||||
)
|
||||
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
return if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GRANTED (legacy)")
|
||||
hasAudioFocus = true
|
||||
true
|
||||
} else {
|
||||
// Pre-O has no delayed grant and no listener to resume from, so a
|
||||
// refusal is terminal for this attempt; the user can hit play again.
|
||||
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED (legacy)!")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1228,6 +1291,11 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
private fun abandonAudioFocus() {
|
||||
android.util.Log.d("JellyTauPlayer", "Abandoning audio focus")
|
||||
|
||||
// No focus, nothing to resume: a stale flag would start playback the next
|
||||
// time some unrelated GAIN arrives.
|
||||
pendingPlayOnFocusGain = false
|
||||
hasAudioFocus = false
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let {
|
||||
val result = audioManager.abandonAudioFocusRequest(it)
|
||||
|
||||
Reference in New Issue
Block a user