diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f073ab6..f22d796 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,9 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/radiola/MainActivity.kt b/app/src/main/java/com/radiola/MainActivity.kt
index 894ceca..fb7202a 100644
--- a/app/src/main/java/com/radiola/MainActivity.kt
+++ b/app/src/main/java/com/radiola/MainActivity.kt
@@ -31,6 +31,7 @@ import com.radiola.ui.player.PlayerViewModel
import com.radiola.ui.recordings.RecordingsScreen
import com.radiola.ui.settings.SettingsScreen
import com.radiola.ui.stations.StationsScreen
+import com.radiola.ui.alarms.AlarmsScreen
import com.radiola.ui.stations.StationsViewModel
import com.radiola.service.PlayerService
import com.radiola.ui.theme.RadiolaTheme
@@ -156,9 +157,17 @@ class MainActivity : ComponentActivity() {
SettingsScreen(
onNavigateToAuth = {
navController.navigate(NavDestinations.Auth.route)
+ },
+ onNavigateToAlarms = {
+ navController.navigate(NavDestinations.Alarms.route)
}
)
}
+ composable(NavDestinations.Alarms.route) {
+ AlarmsScreen(
+ onNavigateBack = { navController.popBackStack() }
+ )
+ }
composable(NavDestinations.Auth.route) {
AuthScreen(
onAuthSuccess = {
diff --git a/app/src/main/java/com/radiola/data/local/AppDatabase.kt b/app/src/main/java/com/radiola/data/local/AppDatabase.kt
index 96750b8..5d1ce93 100644
--- a/app/src/main/java/com/radiola/data/local/AppDatabase.kt
+++ b/app/src/main/java/com/radiola/data/local/AppDatabase.kt
@@ -4,10 +4,12 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
+import com.radiola.data.local.dao.AlarmDao
import com.radiola.data.local.dao.RecordingDao
import com.radiola.data.local.dao.StationDao
import com.radiola.data.local.dao.TagDao
import com.radiola.data.local.dao.TrackHistoryDao
+import com.radiola.data.local.entity.AlarmEntity
import com.radiola.data.local.entity.RecordingEntity
import com.radiola.data.local.entity.StationEntity
import com.radiola.data.local.entity.TagEntity
@@ -56,13 +58,34 @@ val MIGRATION_5_6 = object : Migration(5, 6) {
}
}
+// Добавляем таблицу будильников
+val MIGRATION_6_7 = object : Migration(6, 7) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS alarms (
+ id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+ hour INTEGER NOT NULL,
+ minute INTEGER NOT NULL,
+ daysMask INTEGER NOT NULL,
+ stationId INTEGER NOT NULL,
+ stationName TEXT NOT NULL,
+ enabled INTEGER NOT NULL,
+ fadeInSec INTEGER NOT NULL
+ )
+ """.trimIndent()
+ )
+ }
+}
+
@Database(
- entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class],
- version = 6
+ entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class, AlarmEntity::class],
+ version = 7
)
abstract class AppDatabase : RoomDatabase() {
abstract fun stationDao(): StationDao
abstract fun trackHistoryDao(): TrackHistoryDao
abstract fun tagDao(): TagDao
abstract fun recordingDao(): RecordingDao
+ abstract fun alarmDao(): AlarmDao
}
diff --git a/app/src/main/java/com/radiola/data/local/dao/AlarmDao.kt b/app/src/main/java/com/radiola/data/local/dao/AlarmDao.kt
new file mode 100644
index 0000000..cc8fc2c
--- /dev/null
+++ b/app/src/main/java/com/radiola/data/local/dao/AlarmDao.kt
@@ -0,0 +1,30 @@
+package com.radiola.data.local.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.radiola.data.local.entity.AlarmEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface AlarmDao {
+
+ @Query("SELECT * FROM alarms ORDER BY hour ASC, minute ASC")
+ fun getAll(): Flow>
+
+ @Query("SELECT * FROM alarms ORDER BY hour ASC, minute ASC")
+ suspend fun getAllOnce(): List
+
+ @Query("SELECT * FROM alarms WHERE id = :id")
+ suspend fun getById(id: Int): AlarmEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(alarm: AlarmEntity): Long
+
+ @Query("DELETE FROM alarms WHERE id = :id")
+ suspend fun delete(id: Int)
+
+ @Query("UPDATE alarms SET enabled = :enabled WHERE id = :id")
+ suspend fun setEnabled(id: Int, enabled: Boolean)
+}
diff --git a/app/src/main/java/com/radiola/data/local/dao/StationDao.kt b/app/src/main/java/com/radiola/data/local/dao/StationDao.kt
index 3364a71..8b90359 100644
--- a/app/src/main/java/com/radiola/data/local/dao/StationDao.kt
+++ b/app/src/main/java/com/radiola/data/local/dao/StationDao.kt
@@ -42,4 +42,8 @@ interface StationDao {
// не потерять отметки «избранное» (insertAll = REPLACE затирает строки).
@Query("SELECT id FROM stations WHERE isFavorite = 1")
suspend fun getFavoriteIdsOnce(): List
+
+ // Разовое чтение станции по id — используется в сервисе будильника.
+ @Query("SELECT * FROM stations WHERE id = :id")
+ suspend fun getByIdOnce(id: Int): StationEntity?
}
diff --git a/app/src/main/java/com/radiola/data/local/entity/AlarmEntity.kt b/app/src/main/java/com/radiola/data/local/entity/AlarmEntity.kt
new file mode 100644
index 0000000..84edef0
--- /dev/null
+++ b/app/src/main/java/com/radiola/data/local/entity/AlarmEntity.kt
@@ -0,0 +1,20 @@
+package com.radiola.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * Будильник: время, дни недели, станция, fade-in.
+ * daysMask — битовая маска Пн..Вс (биты 0..6); 0 = разовый (следующее совпадение).
+ */
+@Entity(tableName = "alarms")
+data class AlarmEntity(
+ @PrimaryKey(autoGenerate = true) val id: Int = 0,
+ val hour: Int,
+ val minute: Int,
+ val daysMask: Int, // 0 = разовый; бит 0=Пн, ..., бит 6=Вс
+ val stationId: Int,
+ val stationName: String,
+ val enabled: Boolean = true,
+ val fadeInSec: Int = 60
+)
diff --git a/app/src/main/java/com/radiola/di/AppModule.kt b/app/src/main/java/com/radiola/di/AppModule.kt
index 5933dd3..afed78e 100644
--- a/app/src/main/java/com/radiola/di/AppModule.kt
+++ b/app/src/main/java/com/radiola/di/AppModule.kt
@@ -9,6 +9,8 @@ import com.radiola.data.local.MIGRATION_2_3
import com.radiola.data.local.MIGRATION_3_4
import com.radiola.data.local.MIGRATION_4_5
import com.radiola.data.local.MIGRATION_5_6
+import com.radiola.data.local.MIGRATION_6_7
+import com.radiola.data.local.dao.AlarmDao
import com.radiola.data.remote.AuthInterceptor
import com.radiola.data.remote.LrcLibApi
import com.radiola.data.remote.LoveApi
@@ -154,9 +156,17 @@ object AppModule {
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "radiola.db")
- .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
+ .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7)
.build()
+ @Provides
+ @Singleton
+ fun provideAlarmDao(db: AppDatabase): AlarmDao = db.alarmDao()
+
+ @Provides
+ @Singleton
+ fun provideStationDao(db: AppDatabase): com.radiola.data.local.dao.StationDao = db.stationDao()
+
@Provides
@Singleton
fun provideLocalStationDataSource(
diff --git a/app/src/main/java/com/radiola/service/AlarmReceiver.kt b/app/src/main/java/com/radiola/service/AlarmReceiver.kt
new file mode 100644
index 0000000..0a8e4af
--- /dev/null
+++ b/app/src/main/java/com/radiola/service/AlarmReceiver.kt
@@ -0,0 +1,23 @@
+package com.radiola.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.ContextCompat
+
+/**
+ * BroadcastReceiver-триггер будильника.
+ * Не Hilt-инжектируемый — намеренно простой: только передаёт id в PlayerService.
+ */
+class AlarmReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val alarmId = intent.getIntExtra("alarm_id", -1)
+ if (alarmId < 0) return
+
+ val serviceIntent = Intent(context, PlayerService::class.java).apply {
+ action = PlayerService.ACTION_ALARM
+ putExtra("alarm_id", alarmId)
+ }
+ ContextCompat.startForegroundService(context, serviceIntent)
+ }
+}
diff --git a/app/src/main/java/com/radiola/service/AlarmScheduler.kt b/app/src/main/java/com/radiola/service/AlarmScheduler.kt
new file mode 100644
index 0000000..c629b7e
--- /dev/null
+++ b/app/src/main/java/com/radiola/service/AlarmScheduler.kt
@@ -0,0 +1,147 @@
+package com.radiola.service
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.util.Log
+import com.radiola.MainActivity
+import com.radiola.data.local.dao.AlarmDao
+import com.radiola.data.local.entity.AlarmEntity
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.util.Calendar
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AlarmScheduler @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+
+ /**
+ * Запланировать следующее срабатывание будильника.
+ * Если daysMask == 0 — разовый: берём ближайшее сегодня/завтра совпадение по времени.
+ * Если daysMask != 0 — повторяющийся: ищем ближайший день недели из маски.
+ */
+ fun schedule(alarm: AlarmEntity) {
+ val triggerMs = nextTriggerMillis(alarm)
+ val operation = buildPendingIntent(alarm.id)
+ val showIntent = PendingIntent.getActivity(
+ context,
+ 0,
+ Intent(context, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (alarmManager.canScheduleExactAlarms()) {
+ alarmManager.setAlarmClock(
+ AlarmManager.AlarmClockInfo(triggerMs, showIntent),
+ operation
+ )
+ } else {
+ // Нет разрешения на точные будильники — используем менее точный метод
+ alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerMs, operation)
+ }
+ } else {
+ alarmManager.setAlarmClock(
+ AlarmManager.AlarmClockInfo(triggerMs, showIntent),
+ operation
+ )
+ }
+ Log.d("AlarmScheduler", "Будильник #${alarm.id} запланирован на $triggerMs")
+ } catch (e: SecurityException) {
+ Log.w("AlarmScheduler", "SecurityException при setAlarmClock — фолбэк на setAndAllowWhileIdle", e)
+ alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerMs, operation)
+ }
+ }
+
+ /** Отменить будильник по id. */
+ fun cancel(alarmId: Int) {
+ alarmManager.cancel(buildPendingIntent(alarmId))
+ Log.d("AlarmScheduler", "Будильник #$alarmId отменён")
+ }
+
+ /** Пересчитать расписание всех будильников из базы. */
+ suspend fun rescheduleAll(alarmDao: AlarmDao) {
+ val alarms = alarmDao.getAllOnce()
+ alarms.forEach { alarm ->
+ cancel(alarm.id)
+ if (alarm.enabled) schedule(alarm)
+ }
+ Log.d("AlarmScheduler", "Перепланировано ${alarms.size} будильников")
+ }
+
+ // ──────────────────────────────────────────────
+
+ private fun buildPendingIntent(alarmId: Int): PendingIntent {
+ val intent = Intent(context, AlarmReceiver::class.java).apply {
+ putExtra("alarm_id", alarmId)
+ }
+ return PendingIntent.getBroadcast(
+ context,
+ alarmId,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+ }
+
+ /**
+ * Вычислить эпоху (мс) следующего срабатывания будильника.
+ * Calendar.MONDAY=2 .. Calendar.SUNDAY=1 — маппим в биты 0..6 (Пн..Вс).
+ */
+ private fun nextTriggerMillis(alarm: AlarmEntity): Long {
+ val cal = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, alarm.hour)
+ set(Calendar.MINUTE, alarm.minute)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+
+ return if (alarm.daysMask == 0) {
+ // Разовый: если время уже прошло сегодня — сдвигаем на завтра
+ if (cal.timeInMillis <= System.currentTimeMillis()) {
+ cal.add(Calendar.DAY_OF_YEAR, 1)
+ }
+ cal.timeInMillis
+ } else {
+ // Повторяющийся: ищем ближайший день из маски
+ var found = false
+ repeat(7) { offset ->
+ if (!found) {
+ val checkCal = cal.clone() as Calendar
+ if (offset > 0) checkCal.add(Calendar.DAY_OF_YEAR, offset)
+ val bit = calDayToBit(checkCal.get(Calendar.DAY_OF_WEEK))
+ if (alarm.daysMask and (1 shl bit) != 0) {
+ // Тот же день, но время уже прошло → пропускаем
+ if (offset == 0 && checkCal.timeInMillis <= System.currentTimeMillis()) return@repeat
+ cal.timeInMillis = checkCal.timeInMillis
+ found = true
+ }
+ }
+ }
+ if (!found) {
+ // Крайний случай: все биты проверены — ждём ещё 7 дней (не должно случиться)
+ cal.add(Calendar.DAY_OF_YEAR, 7)
+ }
+ cal.timeInMillis
+ }
+ }
+
+ /**
+ * Перевод Calendar.DAY_OF_WEEK → бит в daysMask (0=Пн, 6=Вс).
+ * Calendar: SUNDAY=1, MONDAY=2, ..., SATURDAY=7
+ */
+ private fun calDayToBit(calDay: Int): Int = when (calDay) {
+ Calendar.MONDAY -> 0
+ Calendar.TUESDAY -> 1
+ Calendar.WEDNESDAY -> 2
+ Calendar.THURSDAY -> 3
+ Calendar.FRIDAY -> 4
+ Calendar.SATURDAY -> 5
+ Calendar.SUNDAY -> 6
+ else -> 0
+ }
+}
diff --git a/app/src/main/java/com/radiola/service/BootReceiver.kt b/app/src/main/java/com/radiola/service/BootReceiver.kt
new file mode 100644
index 0000000..6484ee1
--- /dev/null
+++ b/app/src/main/java/com/radiola/service/BootReceiver.kt
@@ -0,0 +1,36 @@
+package com.radiola.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.radiola.data.local.dao.AlarmDao
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Перепланирует будильники после перезагрузки устройства.
+ */
+@AndroidEntryPoint
+class BootReceiver : BroadcastReceiver() {
+
+ @Inject
+ lateinit var alarmDao: AlarmDao
+
+ @Inject
+ lateinit var alarmScheduler: AlarmScheduler
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
+ val pending = goAsync()
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ alarmScheduler.rescheduleAll(alarmDao)
+ } finally {
+ pending.finish()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/radiola/service/PlayerController.kt b/app/src/main/java/com/radiola/service/PlayerController.kt
index 722ec4d..5f1ade1 100644
--- a/app/src/main/java/com/radiola/service/PlayerController.kt
+++ b/app/src/main/java/com/radiola/service/PlayerController.kt
@@ -309,6 +309,34 @@ class PlayerController @Inject constructor(
}
}
+ /**
+ * Запуск воспроизведения станции по будильнику: играет [url] и плавно нарастает
+ * громкость 0 → 1 за [fadeInMs] (мягкое пробуждение).
+ */
+ fun startAlarmPlayback(
+ url: String,
+ prefix: String,
+ name: String,
+ id: Int?,
+ fadeInMs: Long = 60_000L,
+ ) {
+ cancelSleepTimer()
+ play(url, prefix, name, id)
+ exoPlayer.volume = 0f
+ sleepJob?.cancel()
+ sleepJob = timerScope.launch {
+ val start = SystemClock.elapsedRealtime()
+ while (true) {
+ val elapsed = SystemClock.elapsedRealtime() - start
+ if (elapsed >= fadeInMs) break
+ exoPlayer.volume = (elapsed.toFloat() / fadeInMs).coerceIn(0f, 1f)
+ delay(200)
+ }
+ exoPlayer.volume = 1f
+ sleepJob = null
+ }
+ }
+
/** Отменить таймер сна, вернуть громкость и заглушить звук сна. */
fun cancelSleepTimer() {
sleepJob?.cancel()
diff --git a/app/src/main/java/com/radiola/service/PlayerService.kt b/app/src/main/java/com/radiola/service/PlayerService.kt
index 0946bd9..f8ac843 100644
--- a/app/src/main/java/com/radiola/service/PlayerService.kt
+++ b/app/src/main/java/com/radiola/service/PlayerService.kt
@@ -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
diff --git a/app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt b/app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt
new file mode 100644
index 0000000..497a07f
--- /dev/null
+++ b/app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt
@@ -0,0 +1,386 @@
+package com.radiola.ui.alarms
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.composables.icons.lucide.AlarmClock
+import com.composables.icons.lucide.ArrowLeft
+import com.composables.icons.lucide.Lucide
+import com.composables.icons.lucide.Plus
+import com.composables.icons.lucide.Trash2
+import com.radiola.data.local.entity.AlarmEntity
+import com.radiola.domain.model.Station
+import com.radiola.ui.theme.RadiolaTheme
+
+/** Экран управления будильниками. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AlarmsScreen(
+ onNavigateBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: AlarmsViewModel = hiltViewModel()
+) {
+ val colors = RadiolaTheme.colors
+ val alarms by viewModel.alarms.collectAsState()
+ val stations by viewModel.stations.collectAsState()
+
+ // Состояние диалога добавления/редактирования
+ var editingAlarm by remember { mutableStateOf(null) }
+ var showEditor by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(colors.bgBase)
+ ) {
+ // Шапка с кнопкой «Назад»
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .statusBarsPadding()
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Lucide.ArrowLeft, contentDescription = "Назад", tint = colors.textPrimary)
+ }
+ Text(
+ text = "Будильник",
+ style = MaterialTheme.typography.headlineMedium,
+ color = colors.textPrimary,
+ modifier = Modifier.weight(1f).padding(start = 4.dp)
+ )
+ IconButton(onClick = {
+ editingAlarm = null
+ showEditor = true
+ }) {
+ Icon(Lucide.Plus, contentDescription = "Добавить будильник", tint = colors.accent)
+ }
+ }
+
+ if (alarms.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Icon(Lucide.AlarmClock, contentDescription = null, tint = colors.textMuted, modifier = Modifier.size(48.dp))
+ Text("Нет будильников", color = colors.textMuted, style = MaterialTheme.typography.bodyLarge)
+ Text(
+ "Нажмите «+» чтобы добавить",
+ color = colors.textMuted,
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(horizontal = 20.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(alarms, key = { it.id }) { alarm ->
+ AlarmCard(
+ alarm = alarm,
+ onToggle = { viewModel.toggle(alarm) },
+ onEdit = {
+ editingAlarm = alarm
+ showEditor = true
+ },
+ onDelete = { viewModel.delete(alarm) }
+ )
+ }
+ }
+ }
+ }
+
+ // Диалог добавления / редактирования
+ if (showEditor) {
+ AlarmEditorSheet(
+ initial = editingAlarm,
+ stations = stations,
+ onSave = { alarm ->
+ viewModel.addOrUpdate(alarm)
+ showEditor = false
+ },
+ onDismiss = { showEditor = false }
+ )
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+@Composable
+private fun AlarmCard(
+ alarm: AlarmEntity,
+ onToggle: () -> Unit,
+ onEdit: () -> Unit,
+ onDelete: () -> Unit
+) {
+ val colors = RadiolaTheme.colors
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(colors.surface)
+ .border(1.dp, colors.border, RoundedCornerShape(16.dp))
+ .clickable(onClick = onEdit)
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Время
+ Text(
+ text = "%02d:%02d".format(alarm.hour, alarm.minute),
+ style = MaterialTheme.typography.displaySmall,
+ fontWeight = FontWeight.Bold,
+ color = if (alarm.enabled) colors.textPrimary else colors.textMuted
+ )
+ Spacer(Modifier.width(16.dp))
+ // Станция + дни
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = alarm.stationName,
+ style = MaterialTheme.typography.titleMedium,
+ color = if (alarm.enabled) colors.textPrimary else colors.textMuted,
+ maxLines = 1
+ )
+ Text(
+ text = daysSummary(alarm.daysMask),
+ style = MaterialTheme.typography.labelMedium,
+ color = colors.textSecondary
+ )
+ }
+ // Удалить
+ IconButton(onClick = onDelete) {
+ Icon(Lucide.Trash2, contentDescription = "Удалить", tint = colors.textMuted, modifier = Modifier.size(18.dp))
+ }
+ // Вкл/выкл
+ Switch(
+ checked = alarm.enabled,
+ onCheckedChange = { onToggle() },
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = colors.bgBase,
+ checkedTrackColor = colors.accent,
+ uncheckedThumbColor = colors.textMuted,
+ uncheckedTrackColor = colors.surface2
+ )
+ )
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+private val DAY_LABELS = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
+
+private fun daysSummary(mask: Int): String {
+ if (mask == 0) return "Один раз"
+ val all = (1 shl 7) - 1
+ if (mask == all) return "Каждый день"
+ val weekdays = 0b0011111 // Пн-Пт
+ if (mask == weekdays) return "По будням"
+ val weekend = 0b1100000 // Сб-Вс
+ if (mask == weekend) return "По выходным"
+ return DAY_LABELS.filterIndexed { i, _ -> mask and (1 shl i) != 0 }.joinToString(" ")
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Нижний лист редактирования / создания будильника.
+ * Использует Material3 TimePicker + выбор станции + чипы дней.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AlarmEditorSheet(
+ initial: AlarmEntity?,
+ stations: List,
+ onSave: (AlarmEntity) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val colors = RadiolaTheme.colors
+
+ // Начальные значения
+ val initHour = initial?.hour ?: 7
+ val initMinute = initial?.minute ?: 0
+ var selectedHour by remember { mutableStateOf(initHour) }
+ var selectedMinute by remember { mutableStateOf(initMinute) }
+ var daysMask by remember { mutableStateOf(initial?.daysMask ?: 0) }
+ var selectedStation by remember {
+ mutableStateOf(stations.firstOrNull { it.id == initial?.stationId } ?: stations.firstOrNull())
+ }
+ var fadeInSec by remember { mutableStateOf(initial?.fadeInSec ?: 60) }
+ var stationDropdownExpanded by remember { mutableStateOf(false) }
+
+ // Обновим станцию если список подгрузился после открытия
+ LaunchedEffect(stations) {
+ if (selectedStation == null) selectedStation = stations.firstOrNull()
+ }
+
+ val timePickerState = rememberTimePickerState(
+ initialHour = initHour,
+ initialMinute = initMinute,
+ is24Hour = true
+ )
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ containerColor = colors.elevated
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp)
+ ) {
+ Text(
+ text = if (initial == null) "Новый будильник" else "Изменить будильник",
+ style = MaterialTheme.typography.titleLarge,
+ color = colors.textPrimary,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ // Выбор времени
+ TimePicker(
+ state = timePickerState,
+ colors = TimePickerDefaults.colors(
+ clockDialColor = colors.surface2,
+ selectorColor = colors.accent,
+ timeSelectorSelectedContainerColor = colors.accent,
+ timeSelectorUnselectedContainerColor = colors.surface,
+ timeSelectorSelectedContentColor = colors.bgBase,
+ timeSelectorUnselectedContentColor = colors.textPrimary,
+ periodSelectorBorderColor = colors.border,
+ clockDialSelectedContentColor = colors.bgBase,
+ clockDialUnselectedContentColor = colors.textPrimary
+ ),
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ )
+
+ // Выбор станции
+ Column {
+ Text("Станция", style = MaterialTheme.typography.labelSmall, color = colors.textMuted, letterSpacing = 1.sp)
+ Spacer(Modifier.height(6.dp))
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(colors.surface)
+ .border(1.dp, colors.border, RoundedCornerShape(12.dp))
+ .clickable { stationDropdownExpanded = true }
+ .padding(horizontal = 16.dp, vertical = 14.dp)
+ ) {
+ Text(
+ text = selectedStation?.name ?: "Выберите станцию",
+ color = if (selectedStation != null) colors.textPrimary else colors.textMuted,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ DropdownMenu(
+ expanded = stationDropdownExpanded,
+ onDismissRequest = { stationDropdownExpanded = false },
+ modifier = Modifier.background(colors.elevated).heightIn(max = 300.dp)
+ ) {
+ stations.forEach { station ->
+ DropdownMenuItem(
+ text = { Text(station.name, color = colors.textPrimary) },
+ onClick = {
+ selectedStation = station
+ stationDropdownExpanded = false
+ }
+ )
+ }
+ }
+ }
+
+ // Дни недели
+ Column {
+ Text("Повтор", style = MaterialTheme.typography.labelSmall, color = colors.textMuted, letterSpacing = 1.sp)
+ Spacer(Modifier.height(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ DAY_LABELS.forEachIndexed { i, label ->
+ val selected = daysMask and (1 shl i) != 0
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(8.dp))
+ .background(if (selected) colors.accent else colors.surface)
+ .border(1.dp, if (selected) colors.accent else colors.border, RoundedCornerShape(8.dp))
+ .clickable {
+ daysMask = daysMask xor (1 shl i)
+ }
+ .padding(vertical = 8.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = if (selected) colors.bgBase else colors.textSecondary,
+ fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
+ )
+ }
+ }
+ }
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = if (daysMask == 0) "Один раз (ближайшее совпадение)" else daysSummary(daysMask),
+ style = MaterialTheme.typography.labelSmall,
+ color = colors.textSecondary
+ )
+ }
+
+ // Кнопки
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = onDismiss,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textSecondary),
+ border = androidx.compose.foundation.BorderStroke(1.dp, colors.border)
+ ) {
+ Text("Отмена")
+ }
+ Button(
+ onClick = {
+ val station = selectedStation ?: return@Button
+ onSave(
+ AlarmEntity(
+ id = initial?.id ?: 0,
+ hour = timePickerState.hour,
+ minute = timePickerState.minute,
+ daysMask = daysMask,
+ stationId = station.id,
+ stationName = station.name,
+ enabled = initial?.enabled ?: true,
+ fadeInSec = fadeInSec
+ )
+ )
+ },
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = colors.accent,
+ contentColor = colors.bgBase
+ ),
+ shape = RoundedCornerShape(10.dp)
+ ) {
+ Text("Сохранить", fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/radiola/ui/alarms/AlarmsViewModel.kt b/app/src/main/java/com/radiola/ui/alarms/AlarmsViewModel.kt
new file mode 100644
index 0000000..63a8eee
--- /dev/null
+++ b/app/src/main/java/com/radiola/ui/alarms/AlarmsViewModel.kt
@@ -0,0 +1,57 @@
+package com.radiola.ui.alarms
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.radiola.data.local.dao.AlarmDao
+import com.radiola.data.local.entity.AlarmEntity
+import com.radiola.domain.model.Station
+import com.radiola.domain.usecase.GetStationsUseCase
+import com.radiola.service.AlarmScheduler
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class AlarmsViewModel @Inject constructor(
+ private val alarmDao: AlarmDao,
+ private val alarmScheduler: AlarmScheduler,
+ getStationsUseCase: GetStationsUseCase
+) : ViewModel() {
+
+ val alarms: StateFlow> = alarmDao.getAll()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ val stations: StateFlow> = getStationsUseCase()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ /** Добавить или обновить будильник; если включён — запланировать. */
+ fun addOrUpdate(alarm: AlarmEntity) {
+ viewModelScope.launch {
+ val id = alarmDao.upsert(alarm).toInt()
+ val saved = alarm.copy(id = if (alarm.id == 0) id else alarm.id)
+ if (saved.enabled) alarmScheduler.schedule(saved)
+ else alarmScheduler.cancel(saved.id)
+ }
+ }
+
+ /** Переключить включён/выключен. */
+ fun toggle(alarm: AlarmEntity) {
+ viewModelScope.launch {
+ val newEnabled = !alarm.enabled
+ alarmDao.setEnabled(alarm.id, newEnabled)
+ if (newEnabled) alarmScheduler.schedule(alarm.copy(enabled = true))
+ else alarmScheduler.cancel(alarm.id)
+ }
+ }
+
+ /** Удалить будильник и отменить планировщик. */
+ fun delete(alarm: AlarmEntity) {
+ viewModelScope.launch {
+ alarmScheduler.cancel(alarm.id)
+ alarmDao.delete(alarm.id)
+ }
+ }
+}
diff --git a/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt b/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt
index 03e7820..a2fcefc 100644
--- a/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt
+++ b/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt
@@ -1,6 +1,7 @@
package com.radiola.ui.navigation
import androidx.compose.ui.graphics.vector.ImageVector
+import com.composables.icons.lucide.AlarmClock
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.History
@@ -22,6 +23,7 @@ sealed class NavDestinations(
data object Recordings : NavDestinations("recordings", "Записи", Lucide.Mic)
data object Settings : NavDestinations("settings", "Настройки", Lucide.Settings)
data object Auth : NavDestinations("auth", "Вход", Lucide.Settings, showInBottomBar = false)
+ data object Alarms : NavDestinations("alarms", "Будильник", Lucide.AlarmClock, showInBottomBar = false)
companion object {
val items = listOf(Stations, Charts, Favorites, History, Recordings, Settings)
diff --git a/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt b/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt
index 7307827..2e13733 100644
--- a/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt
@@ -24,6 +24,8 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
+import com.composables.icons.lucide.AlarmClock
+import com.composables.icons.lucide.ChevronRight
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.User
import com.radiola.domain.model.DeeplinkService
@@ -35,6 +37,7 @@ import com.radiola.ui.theme.RadiolaTheme
@Composable
fun SettingsScreen(
onNavigateToAuth: () -> Unit,
+ onNavigateToAlarms: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel()
) {
@@ -43,6 +46,7 @@ fun SettingsScreen(
val equalizerPreset by viewModel.equalizerPreset.collectAsState()
val visualizerStyle by viewModel.visualizerStyle.collectAsState()
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
+ val preferredBitrate by viewModel.preferredBitrate.collectAsState()
val isTesting by viewModel.isTesting.collectAsState()
val testProgress by viewModel.testProgress.collectAsState()
val testTotal by viewModel.testTotal.collectAsState()
@@ -144,6 +148,48 @@ fun SettingsScreen(
}
}
+ // --- Будильник ---
+ item {
+ SectionLabel("БУДИЛЬНИК")
+ Spacer(Modifier.height(8.dp))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(colors.surface)
+ .border(1.dp, colors.border, RoundedCornerShape(16.dp))
+ .clickable { onNavigateToAlarms() }
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(14.dp)
+ ) {
+ Icon(
+ Lucide.AlarmClock,
+ contentDescription = null,
+ tint = colors.accent,
+ modifier = Modifier.size(22.dp)
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Будильники",
+ style = MaterialTheme.typography.titleMedium,
+ color = colors.textPrimary
+ )
+ Text(
+ text = "Просыпайтесь под любимое радио",
+ style = MaterialTheme.typography.labelMedium,
+ color = colors.textSecondary
+ )
+ }
+ Icon(
+ Lucide.ChevronRight,
+ contentDescription = null,
+ tint = colors.textMuted,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+
// --- Таймер сна ---
item {
SectionLabel("ТАЙМЕР СНА")
@@ -187,6 +233,58 @@ fun SettingsScreen(
}
}
+ // --- Качество звука по умолчанию ---
+ item {
+ SectionLabel("КАЧЕСТВО ЗВУКА")
+ Spacer(Modifier.height(8.dp))
+ val options = listOf(0 to "Авто", 64 to "Эконом", 128 to "Стандарт", 320 to "Высокое")
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(colors.surface)
+ .border(1.dp, colors.border, RoundedCornerShape(16.dp))
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ options.forEach { (bitrate, label) ->
+ val selected = preferredBitrate == bitrate
+ val bgColor by animateColorAsState(
+ targetValue = if (selected) colors.accent else colors.surface2,
+ animationSpec = tween(Motion.Medium),
+ label = "qSegment"
+ )
+ val textColor by animateColorAsState(
+ targetValue = if (selected) colors.bgBase else colors.textSecondary,
+ animationSpec = tween(Motion.Medium),
+ label = "qText"
+ )
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(10.dp))
+ .background(bgColor)
+ .clickable { viewModel.setPreferredBitrate(bitrate) }
+ .padding(vertical = 10.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelLarge,
+ color = textColor,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+ Text(
+ text = "Применяется к станциям с несколькими потоками. «Авто» — выбор станции.",
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.textMuted,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+
// --- Эквалайзер ---
item {
SectionLabel("ЭКВАЛАЙЗЕР")
diff --git a/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt
index ae0d723..83b9291 100644
--- a/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt
@@ -38,6 +38,10 @@ class SettingsViewModel @Inject constructor(
val isRecordingEnabled: StateFlow = settingsRepository.isRecordingEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
+ // Предпочитаемый битрейт по умолчанию (0 = авто/станция сама выбирает).
+ val preferredBitrate: StateFlow = settingsRepository.getPreferredBitrate()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
+
val isLoggedIn: StateFlow = getAuthStateUseCase()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@@ -60,6 +64,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.setSleepTimerMinutes(minutes) }
}
+ fun setPreferredBitrate(bitrate: Int) {
+ viewModelScope.launch { settingsRepository.setPreferredBitrate(bitrate) }
+ }
+
fun toggleService(serviceId: String, enabled: Boolean) {
viewModelScope.launch {
val current = enabledServices.value.toMutableSet()