fix(player): восстановление потока после смены/пропажи сети (машина, туннели)

Причина «падений через 10-15 мин в машине»: при смене сети (Wi-Fi↔LTE) или долгом
обрыве поток рвался, а переподключение СДАВАЛОСЬ навсегда после 10 попыток (~100с),
и не было реакции на возврат сети → радио не оживало, foreground-сервис отваливался,
процесс убивала система (это выглядело как «падение», хотя крэша не было).

- scheduleReconnect больше не сдаётся: переподключается, пока пользователь хочет
  играть (флаг intendedToPlay; пауза/стоп его снимают).
- Добавлен ConnectivityManager.registerDefaultNetworkCallback: при возврате/смене
  сети мгновенно re-prepare потока, не дожидаясь бэк-оффа.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-11 23:22:39 +03:00
parent 1771a5b975
commit 75d256eda5

View File

@@ -4,6 +4,8 @@ import android.content.Context
import android.media.AudioDeviceCallback import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo import android.media.AudioDeviceInfo
import android.media.AudioManager import android.media.AudioManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.Uri import android.net.Uri
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
@@ -90,6 +92,31 @@ class PlayerController @Inject constructor(
private var retryCount = 0 private var retryCount = 0
private var reconnectJob: Job? = null private var reconnectJob: Job? = null
// Намерение играть: пользователь включил станцию и не ставил паузу/стоп.
// По нему решаем, переподключаться ли после обрыва — НЕ сдаёмся навсегда.
@Volatile private var intendedToPlay = false
// Слушатель сети: возврат/смена сети (Wi-Fi↔LTE в машине, выход из туннеля)
// мгновенно переподключает поток, не дожидаясь бэк-оффа. Главная причина, по
// которой радио раньше «не оживало» после смены сети.
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
if (!intendedToPlay) return
// Колбэк — на системном потоке; ExoPlayer трогаем на main (timerScope).
timerScope.launch {
if (!exoPlayer.isPlaying) {
retryCount = 0
runCatching {
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
}
}
}
}
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 // переход радио → звук для сна (внутри аутро)
@@ -212,7 +239,10 @@ class PlayerController @Inject constructor(
*/ */
private fun scheduleReconnect() { private fun scheduleReconnect() {
reconnectJob?.cancel() reconnectJob?.cancel()
if (retryCount >= 10) return // Пока пользователь хочет играть — пробуем переподключаться бесконечно
// (с бэк-оффом до 15с). Раньше сдавались навсегда после 10 попыток (~100с) →
// длинный туннель/смена сети глушили радио до ручного перезапуска.
if (!intendedToPlay) return
val delayMs = (2_000L * (retryCount + 1)).coerceAtMost(15_000L) val delayMs = (2_000L * (retryCount + 1)).coerceAtMost(15_000L)
retryCount++ retryCount++
reconnectJob = timerScope.launch { reconnectJob = timerScope.launch {
@@ -244,6 +274,7 @@ class PlayerController @Inject constructor(
init { init {
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
runCatching { connectivityManager?.registerDefaultNetworkCallback(networkCallback) }
} }
fun play(url: String, stationPrefix: String, stationName: String, stationId: Int? = null) { fun play(url: String, stationPrefix: String, stationName: String, stationId: Int? = null) {
@@ -251,6 +282,7 @@ class PlayerController @Inject constructor(
// Новая станция — сбрасываем переподключение предыдущего потока. // Новая станция — сбрасываем переподключение предыдущего потока.
reconnectJob?.cancel() reconnectJob?.cancel()
retryCount = 0 retryCount = 0
intendedToPlay = true
_currentStationId.value = stationId _currentStationId.value = stationId
_icyTitle.value = null _icyTitle.value = null
val mediaItem = MediaItem.Builder() val mediaItem = MediaItem.Builder()
@@ -412,17 +444,19 @@ class PlayerController @Inject constructor(
} }
fun pause() { fun pause() {
// Пауза пользователем — отменяем отложенное переподключение, иначе оно // Пауза пользователем — больше не хотим играть, отменяем переподключение.
// позже само возобновит воспроизведение. intendedToPlay = false
reconnectJob?.cancel() reconnectJob?.cancel()
exoPlayer.pause() exoPlayer.pause()
} }
fun play() { fun play() {
intendedToPlay = true
exoPlayer.play() exoPlayer.play()
} }
fun stop() { fun stop() {
intendedToPlay = false
reconnectJob?.cancel() reconnectJob?.cancel()
exoPlayer.stop() exoPlayer.stop()
_currentStationPrefix.value = null _currentStationPrefix.value = null
@@ -432,6 +466,7 @@ class PlayerController @Inject constructor(
fun release() { fun release() {
timerScope.cancel() timerScope.cancel()
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
runCatching { connectivityManager?.unregisterNetworkCallback(networkCallback) }
sleepSoundPlayer.stop() sleepSoundPlayer.stop()
exoPlayer.release() exoPlayer.release()
} }