Compare commits

..

2 Commits

Author SHA1 Message Date
nk
91777fc459 feat(background): подсказка ColorOS «Разрешить работу в фоне» + bump 8/1.7
На Oppo/OnePlus/Realme стандартного исключения из оптимизации батареи мало —
система схлопывает приложение в фоне. Один раз показываем пояснение и открываем
настройки приложения, чтобы юзер включил «Разрешить работу в фоновом режиме».
2026-06-11 14:46:06 +03:00
nk
7a00f53b20 fix(now-playing): мгновенный рефреш эфира при возврате из фона + восстановление сессии
Опрос now-playing был привязан к play()/жизни ViewModel — при заморозке ColorOS
в фоне или пересоздании ViewModel трек «застывал». Теперь: startNowPlaying()
с мгновенным refresh, восстановление привязки к играющей станции из
PlayerController.currentStationId, и onAppForeground() на ON_RESUME.
2026-06-10 18:05:32 +03:00
3 changed files with 108 additions and 12 deletions

View File

@@ -23,8 +23,8 @@ android {
applicationId = "com.radiola"
minSdk = 26
targetSdk = 34
versionCode = 7
versionName = "1.6"
versionCode = 8
versionName = "1.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@@ -109,6 +109,19 @@ class MainActivity : ComponentActivity() {
val isRecording by playerViewModel.isRecording.collectAsState()
val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false)
// Возврат на передний план → мгновенно освежаем now-playing
// (фоновая заморозка ColorOS может останавливать опрос эфира).
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val obs = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
playerViewModel.onAppForeground()
}
}
lifecycleOwner.lifecycle.addObserver(obs)
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
}
// --- Авто-обновление: проверяем версию на старте, показываем диалог ---
var pendingUpdate by remember { mutableStateOf<com.radiola.update.VersionInfo?>(null) }
var updateState by remember { mutableStateOf(com.radiola.ui.update.UpdateState.IDLE) }
@@ -357,10 +370,11 @@ class MainActivity : ComponentActivity() {
private fun maybeRequestBatteryExemption() {
val pm = getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
if (pm.isIgnoringBatteryOptimizations(packageName)) return
// Спрашиваем один раз на установку, чтобы не надоедать.
val prefs = getSharedPreferences("radiola_prefs", MODE_PRIVATE)
if (prefs.getBoolean("battery_opt_asked", false)) return
// Спрашиваем один раз на установку, чтобы не надоедать.
if (!pm.isIgnoringBatteryOptimizations(packageName) &&
!prefs.getBoolean("battery_opt_asked", false)
) {
prefs.edit().putBoolean("battery_opt_asked", true).apply()
runCatching {
startActivity(
@@ -371,4 +385,49 @@ class MainActivity : ComponentActivity() {
)
}
}
maybeGuideColorOsBackground()
}
/**
* ColorOS/OxygenOS (Oppo, OnePlus, Realme) агрессивно «схлопывают» приложение в
* фоне при выключённом экране — стандартного исключения из оптимизации батареи
* мало. Реально помогает галочка «Разрешить работу в фоновом режиме» в разделе
* «Использование батареи» приложения. Прямого API для неё нет, поэтому один раз
* показываем пояснение и открываем экран настроек приложения, чтобы юзер включил.
*/
private fun maybeGuideColorOsBackground() {
val m = android.os.Build.MANUFACTURER.lowercase()
val isColorOs = m.contains("oppo") || m.contains("oneplus") || m.contains("realme")
if (!isColorOs) return
val prefs = getSharedPreferences("radiola_prefs", MODE_PRIVATE)
if (prefs.getBoolean("bg_activity_guided", false)) return
prefs.edit().putBoolean("bg_activity_guided", true).apply()
runCatching {
android.app.AlertDialog.Builder(this)
.setTitle("Фоновое воспроизведение")
.setMessage(
"На вашем устройстве система может выгружать приложение при " +
"выключённом экране, и радио прерывается.\n\n" +
"Чтобы этого не происходило, откройте «Использование батареи» " +
"и включите «Разрешить работу в фоновом режиме»."
)
.setPositiveButton("Открыть настройки") { _, _ -> openAppBatterySettings() }
.setNegativeButton("Позже", null)
.show()
}
}
/** Экран «Использование батареи» приложения; фолбэк — страница «О приложении». */
private fun openAppBatterySettings() {
val uri = android.net.Uri.parse("package:$packageName")
val candidates = listOf(
// На части ColorOS открывает прямо управление батареей приложения.
Intent("android.settings.APP_BATTERY_SETTINGS").apply { data = uri },
// Универсальный фолбэк — «О приложении», оттуда «Использование батареи».
Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri)
)
for (intent in candidates) {
if (runCatching { startActivity(intent); true }.getOrDefault(false)) return
}
}
}

View File

@@ -118,6 +118,20 @@ class PlayerViewModel @Inject constructor(
viewModelScope.launch {
settingsRepository.getPreferredBitrate().collect { preferredBitrate = it }
}
// Восстановление сессии: если процесс/Activity пересоздались, а станция уже
// играет в фоновом сервисе (PlayerController помнит id) — заново привязываемся
// и запускаем опрос now-playing. Иначе мини-плеер/эфир «застывают».
viewModelScope.launch {
combine(playerController.currentStationId, _stations) { id, list ->
id?.let { sid -> list.firstOrNull { it.id == sid } }
}.collect { station ->
if (station != null && _currentStation.value == null) {
_currentStation.value = station
_playlist.value = _stations.value
startNowPlaying(station)
}
}
}
viewModelScope.launch {
_currentTrack
.filterNotNull()
@@ -150,8 +164,20 @@ class PlayerViewModel @Inject constructor(
playerController.play(url, station.prefix, station.name, station.id)
}
viewModelScope.launch { pushHistoryUseCase(station.id) }
startNowPlaying(station)
}
/**
* Запускает опрос now-playing для станции: мгновенный рефреш + цикл раз в 5с
* (пока играем) + сбор трека из API (приоритет) и ICY (фолбэк). Вынесено из
* play(), чтобы переиспользовать при восстановлении сессии (возврат из фона /
* пересоздание ViewModel) — иначе эфир «застывает» на последнем значении.
*/
private fun startNowPlaying(station: Station) {
nowPlayingJob?.cancel()
nowPlayingJob = viewModelScope.launch {
// Сразу тянем свежий эфир — не ждём первые 5с цикла.
launch { nowPlayingRepository.refreshNowPlaying() }
// Поллинг now-playing — ТОЛЬКО пока играем. collectLatest отменяет
// внутренний цикл при паузе (иначе на паузе радио зря дёргали сеть
// каждые 5с → батарея + лишняя нагрузка на бэкенд).
@@ -208,6 +234,17 @@ class PlayerViewModel @Inject constructor(
}
}
/**
* Возврат приложения на передний план: мгновенно освежаем эфир (чтобы юзер не
* видел залипший трек после фоновой заморозки) и, если опрос почему-то не идёт,
* перезапускаем его для текущей станции.
*/
fun onAppForeground() {
val station = _currentStation.value ?: return
viewModelScope.launch { nowPlayingRepository.refreshNowPlaying() }
if (nowPlayingJob?.isActive != true) startNowPlaying(station)
}
/** Стартовое качество станции с учётом предпочтения пользователя. */
private fun pickInitialQuality(station: Station): StreamQuality? {
val list = station.qualities