Files
radiola-android/app/src/main/java/com/radiola/service/PlayerService.kt
nk f423344d13 perf(android): батарея и плавность — gate FFT, изоляция рекомпозиции, поллинг на паузе
- AudioSpectrumAnalyzer: FFT считается ТОЛЬКО когда открыт плеер (флаг active);
  раньше ~86 FFT/с молотили всегда при проигрывании (даже экран выкл) — главный
  пожиратель батареи. Включается из VisualizerHost через DisposableEffect.
- Спектр (45/с) собирается в leaf VisualizerHost, а не на верху PlayerBottomSheet —
  весь плеер больше не рекомпозится 45 раз/сек.
- now-playing поллинг (5с) останавливается на паузе (isPlaying.collectLatest) —
  раньше на паузе зря дёргали сеть каждые 5с.
- PlayerService.onDestroy отменяет serviceScope (singleton-плеер НЕ релизим).
- refreshStations (парс ~700 станций + сеть + Room) уведён на Dispatchers.IO с
  главного потока (jank/ANR на старте).
- Coil ImageLoader: память 25% + диск 100МБ (обложки не перекачиваются каждую сессию).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:33:00 +03:00

169 lines
6.3 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.radiola.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.radiola.MainActivity
import com.radiola.data.local.dao.AlarmDao
import com.radiola.data.local.dao.StationDao
import com.radiola.data.remote.LoveStreamResolver
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
@UnstableApi
class PlayerService : MediaSessionService() {
companion object {
const val ACTION_ALARM = "com.radiola.ALARM"
private const val CHANNEL_ALARM = "radiola_alarm"
private const val NOTIF_ID_ALARM = 9001
}
@Inject
lateinit var playerController: PlayerController
@Inject
lateinit var alarmDao: AlarmDao
@Inject
lateinit var stationDao: StationDao
@Inject
lateinit var loveStreamResolver: LoveStreamResolver
@Inject
lateinit var alarmScheduler: AlarmScheduler
private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var mediaSession: MediaSession? = null
override fun onCreate() {
super.onCreate()
mediaSession = MediaSession.Builder(this, playerController.player)
.setSessionActivity(
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
)
.build()
ensureAlarmChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_ALARM) {
val alarmId = intent.getIntExtra("alarm_id", -1)
if (alarmId >= 0) handleAlarm(alarmId)
}
return START_NOT_STICKY
}
private fun handleAlarm(alarmId: Int) {
// Немедленно поднимаем foreground-уведомление (Android требует ≤5 с после startForegroundService)
startForeground(NOTIF_ID_ALARM, buildAlarmNotification())
serviceScope.launch {
try {
val alarm = alarmDao.getById(alarmId)
if (alarm == null) {
Log.w("PlayerService", "Будильник #$alarmId не найден в БД")
stopForeground(STOP_FOREGROUND_REMOVE)
return@launch
}
val station = stationDao.getByIdOnce(alarm.stationId)
if (station == null) {
Log.w("PlayerService", "Станция #${alarm.stationId} не найдена, будильник #$alarmId")
stopForeground(STOP_FOREGROUND_REMOVE)
return@launch
}
val url = loveStreamResolver.resolve(station.streamUrl)
playerController.startAlarmPlayback(
url = url,
prefix = station.prefix,
name = station.name,
id = station.id,
fadeInMs = alarm.fadeInSec * 1000L
)
// Перепланируем или деактивируем будильник
if (alarm.daysMask != 0) {
// Повторяющийся — планируем следующее срабатывание
alarmScheduler.schedule(alarm)
} else {
// Разовый — отключаем
alarmDao.setEnabled(alarmId, false)
}
// MediaSession-уведомление возьмёт на себя отображение воспроизведения
stopForeground(STOP_FOREGROUND_DETACH)
} catch (e: Exception) {
Log.e("PlayerService", "Ошибка воспроизведения будильника #$alarmId", e)
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
}
private fun ensureAlarmChannel() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(CHANNEL_ALARM) == null) {
val channel = NotificationChannel(
CHANNEL_ALARM,
"Будильник",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Уведомления о срабатывании будильника"
}
nm.createNotificationChannel(channel)
}
}
private fun buildAlarmNotification(): Notification {
val mainIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ALARM)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle("Будильник")
.setContentText("Запуск радио…")
.setContentIntent(mainIntent)
.setOngoing(true)
.build()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession
override fun onTaskRemoved(rootIntent: Intent?) {
if (!playerController.isPlaying.value) {
stopSelf()
}
}
override fun onDestroy() {
mediaSession?.release()
mediaSession = null
// serviceScope — поле этого сервиса (пере-создаётся при рестарте), отменяем.
// playerController — @Singleton (переживает рестарт сервиса), его НЕ релизим:
// иначе новый PlayerService построит MediaSession на освобождённом плеере.
serviceScope.cancel()
super.onDestroy()
}
}