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:
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user