feat: будильник с радиостанцией + выбор битрейта по умолчанию

Будильник (Settings → Будильник): несколько будильников, время, станция, дни недели,
fade-in пробуждения. AlarmManager.setAlarmClock (вне doze) + фолбэк, BootReceiver
перепланирует после перезагрузки, AlarmReceiver→PlayerService (foreground) →
PlayerController.startAlarmPlayback (нарастание громкости). Room: AlarmEntity/Dao, БД v7.
Выбор битрейта по умолчанию в Settings (Авто/Эконом/Стандарт/Высокое) → preferredBitrate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-06 15:25:42 +03:00
parent 4411d53a6c
commit 861b0e2b8f
17 changed files with 1014 additions and 3 deletions

View File

@@ -1,21 +1,54 @@
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.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() {
@@ -30,6 +63,88 @@ class PlayerService : MediaSessionService() {
)
)
.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