- 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>
169 lines
6.3 KiB
Kotlin
169 lines
6.3 KiB
Kotlin
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()
|
||
}
|
||
}
|