fix(player): не глохнуть в фоне (wake mode) + авто-переподключение потока

Симптом: по Bluetooth в машине с выключенным экраном радио через время замолкало.
Причины и фиксы:
- setWakeMode(C.WAKE_MODE_NETWORK) + право WAKE_LOCK — ExoPlayer держит partial
  wakelock + wifilock во время игры. Без этого система усыпляла CPU/Wi-Fi при
  выключенном экране → буфер пустел → поток глох (главная причина).
- onPlayerError → scheduleReconnect(): при обрыве сети (туннели, край соты) поток
  пере-готавливается с нарастающей задержкой (2с→15с, до 10 попыток), а не
  замолкает навсегда. Счётчик сбрасывается при успешном старте; переподключение
  отменяется при ручной паузе/стопе/смене станции.
This commit is contained in:
nk
2026-06-07 12:35:07 +03:00
parent e736c2393f
commit 6eb614a729
2 changed files with 46 additions and 0 deletions

View File

@@ -13,6 +13,9 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Авто-обновление: установка скачанного APK --> <!-- Авто-обновление: установка скачанного APK -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Держать CPU/Wi-Fi активными во время проигрывания при выключенном экране
(иначе поток глохнет в фоне — особенно в машине по Bluetooth). -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:name=".RadiolaApplication" android:name=".RadiolaApplication"

View File

@@ -11,6 +11,7 @@ import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata import androidx.media3.common.Metadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import android.util.Log import android.util.Log
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
@@ -85,6 +86,10 @@ class PlayerController @Inject constructor(
private val timerScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val timerScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var sleepJob: Job? = null private var sleepJob: Job? = null
// Переподключение при обрыве потока (дорога/туннели).
private var retryCount = 0
private var reconnectJob: Job? = null
private companion object { private companion object {
const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера
const val CROSSFADE_MS = 90_000L // переход радио → звук для сна (внутри аутро) const val CROSSFADE_MS = 90_000L // переход радио → звук для сна (внутри аутро)
@@ -148,11 +153,24 @@ class PlayerController @Inject constructor(
true true
) )
.setHandleAudioBecomingNoisy(true) .setHandleAudioBecomingNoisy(true)
// Держим CPU + Wi-Fi активными, пока играем (partial wakelock + wifilock).
// Без этого при выключенном экране система усыпляет сеть → буфер пустеет →
// радио глохнет (главная причина «обрыва» в машине по Bluetooth).
.setWakeMode(C.WAKE_MODE_NETWORK)
.build() .build()
.apply { .apply {
addListener(object : Player.Listener { addListener(object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) { override fun onIsPlayingChanged(playing: Boolean) {
_isPlaying.value = playing _isPlaying.value = playing
// Успешно играем — сбрасываем счётчик попыток переподключения.
if (playing) retryCount = 0
}
override fun onPlayerError(error: PlaybackException) {
// В дороге сигнал рвётся (туннели, край соты). Не глушим радио
// навсегда — пере-готовим поток с нарастающей задержкой.
Log.w("PlayerController", "Ошибка плеера: ${error.errorCodeName}, переподключение")
scheduleReconnect()
} }
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
@@ -188,6 +206,24 @@ class PlayerController @Inject constructor(
audioEffects.attach(sessionId) audioEffects.attach(sessionId)
} }
/**
* Переподключение после ошибки потока с нарастающей задержкой (2с→15с, до 10
* попыток ≈ пережить туннель). Счётчик сбрасывается, как только снова заиграло.
*/
private fun scheduleReconnect() {
reconnectJob?.cancel()
if (retryCount >= 10) return
val delayMs = (2_000L * (retryCount + 1)).coerceAtMost(15_000L)
retryCount++
reconnectJob = timerScope.launch {
delay(delayMs)
runCatching {
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
}
}
val player: Player = object : ForwardingPlayer(exoPlayer) { val player: Player = object : ForwardingPlayer(exoPlayer) {
override fun getAvailableCommands(): Player.Commands { override fun getAvailableCommands(): Player.Commands {
return super.getAvailableCommands() return super.getAvailableCommands()
@@ -212,6 +248,9 @@ class PlayerController @Inject constructor(
fun play(url: String, stationPrefix: String, stationName: String, stationId: Int? = null) { fun play(url: String, stationPrefix: String, stationName: String, stationId: Int? = null) {
Log.d("PlayerController", "play() called with url=$url prefix=$stationPrefix") Log.d("PlayerController", "play() called with url=$url prefix=$stationPrefix")
// Новая станция — сбрасываем переподключение предыдущего потока.
reconnectJob?.cancel()
retryCount = 0
_currentStationId.value = stationId _currentStationId.value = stationId
_icyTitle.value = null _icyTitle.value = null
val mediaItem = MediaItem.Builder() val mediaItem = MediaItem.Builder()
@@ -373,6 +412,9 @@ class PlayerController @Inject constructor(
} }
fun pause() { fun pause() {
// Пауза пользователем — отменяем отложенное переподключение, иначе оно
// позже само возобновит воспроизведение.
reconnectJob?.cancel()
exoPlayer.pause() exoPlayer.pause()
} }
@@ -381,6 +423,7 @@ class PlayerController @Inject constructor(
} }
fun stop() { fun stop() {
reconnectJob?.cancel()
exoPlayer.stop() exoPlayer.stop()
_currentStationPrefix.value = null _currentStationPrefix.value = null
_currentStationId.value = null _currentStationId.value = null