diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index cfa1ee478..a85ee19b2 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -304,6 +304,7 @@ %1$d lines selected + Start OK Play Next song @@ -327,7 +328,13 @@ Cancel download Sleep timer Sleep timer - %1$s left + Sleep timer - %1$d songs left + Sleep timer - end of queue + Stop after %1$d songs + Stop at end of current queue Turn off timer + Time + Songs Playback speed Navigate up Logout diff --git a/composeApp/src/commonMain/kotlin/paige/navic/domain/manager/SleepTimerManager.kt b/composeApp/src/commonMain/kotlin/paige/navic/domain/manager/SleepTimerManager.kt index 03b852db5..da39089ec 100644 --- a/composeApp/src/commonMain/kotlin/paige/navic/domain/manager/SleepTimerManager.kt +++ b/composeApp/src/commonMain/kotlin/paige/navic/domain/manager/SleepTimerManager.kt @@ -6,38 +6,103 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import paige.navic.shared.MediaPlayerViewModel import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Instant +sealed interface SleepTimerMode { + data object Disabled : SleepTimerMode + data class Time(val endTime: Instant) : SleepTimerMode + data class Songs(val remaining: Int) : SleepTimerMode + data object EndOfQueue : SleepTimerMode +} + class SleepTimerManager( private val player: MediaPlayerViewModel ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var job: Job? = null - var endTimeStamp: Instant? = null - private set + private var observerJob: Job? = null + private var remainingSongs = 0 + + private val _mode = MutableStateFlow(SleepTimerMode.Disabled) + val mode = _mode.asStateFlow() + + val endTimeStamp: Instant? + get() = (mode.value as? SleepTimerMode.Time)?.endTime val timeLeft: Duration? get() = endTimeStamp?.let { it - Clock.System.now() } fun startTimer(duration: Duration) { - job?.cancel() + stopTimer() + val endTime = Clock.System.now() + duration + _mode.value = SleepTimerMode.Time(endTime) job = scope.launch { - endTimeStamp = Clock.System.now() + duration - delay(duration) player.pause() stopTimer() } } + fun startSongsTimer(count: Int) { + stopTimer() + remainingSongs = count + _mode.value = SleepTimerMode.Songs(count) + observeSongs() + } + + fun startEndOfQueueTimer() { + stopTimer() + val state = player.uiState.value + remainingSongs = if (state.currentIndex != -1) { + state.queue.size - state.currentIndex + } else { + 0 + } + + if (remainingSongs > 0) { + _mode.value = SleepTimerMode.EndOfQueue + observeSongs() + } + } + + private fun observeSongs() { + observerJob?.cancel() + observerJob = scope.launch { + player.uiState + .map { it.currentIndex } + .distinctUntilChanged() + .drop(1) + .collect { + remainingSongs-- + if (remainingSongs <= 0) { + player.pause() + stopTimer() + } else { + val currentMode = _mode.value + if (currentMode is SleepTimerMode.Songs) { + _mode.value = SleepTimerMode.Songs(remainingSongs) + } + } + } + } + } + fun stopTimer() { job?.cancel() job = null - endTimeStamp = null + observerJob?.cancel() + observerJob = null + remainingSongs = 0 + _mode.value = SleepTimerMode.Disabled } } diff --git a/composeApp/src/commonMain/kotlin/paige/navic/ui/components/common/OptionCard.kt b/composeApp/src/commonMain/kotlin/paige/navic/ui/components/common/OptionCard.kt new file mode 100644 index 000000000..e1738363b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/paige/navic/ui/components/common/OptionCard.kt @@ -0,0 +1,63 @@ +package paige.navic.ui.components.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun OptionCard( + label: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isActive: Boolean = false +) { + val containerColor = if (isActive) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + val contentColor = if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Surface( + modifier = modifier.clip(MaterialTheme.shapes.medium), + color = containerColor, + contentColor = contentColor, + shape = MaterialTheme.shapes.medium, + onClick = onClick + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + maxLines = 1 + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/paige/navic/ui/components/sheets/AccountSheet.kt b/composeApp/src/commonMain/kotlin/paige/navic/ui/components/sheets/AccountSheet.kt index 7446db008..6803a60d2 100644 --- a/composeApp/src/commonMain/kotlin/paige/navic/ui/components/sheets/AccountSheet.kt +++ b/composeApp/src/commonMain/kotlin/paige/navic/ui/components/sheets/AccountSheet.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.kyant.capsule.ContinuousCapsule import com.russhwolf.settings.Settings import kotlinx.coroutines.launch @@ -34,12 +35,15 @@ import navic.composeapp.generated.resources.Res import navic.composeapp.generated.resources.action_log_out import navic.composeapp.generated.resources.action_sleep_timer import navic.composeapp.generated.resources.action_sleep_timer_enabled +import navic.composeapp.generated.resources.action_sleep_timer_queue_enabled +import navic.composeapp.generated.resources.action_sleep_timer_songs_enabled import navic.composeapp.generated.resources.action_view_shares import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import paige.navic.LocalNavStack import paige.navic.domain.manager.LoginManager import paige.navic.domain.manager.SleepTimerManager +import paige.navic.domain.manager.SleepTimerMode import paige.navic.icons.Icons import paige.navic.icons.outlined.Bedtime import paige.navic.icons.outlined.Logout @@ -62,7 +66,7 @@ fun AccountSheet( var sleepTimerSheetOpen by rememberSaveable { mutableStateOf(false) } val sleepTimerManager = koinInject() - val sleepTimerLeft = sleepTimerManager.timeLeft + val sleepTimerMode by sleepTimerManager.mode.collectAsStateWithLifecycle() val sheetState = rememberBottomSheetState( initialValue = SheetValue.Hidden, @@ -159,17 +163,31 @@ fun AccountSheet( contentPadding = contentPadding, color = color ) { - if (sleepTimerLeft != null) { + val isEnabled = sleepTimerMode !is SleepTimerMode.Disabled + if (isEnabled) { Icon( imageVector = Icons.Outlined.Bedtime, contentDescription = null, tint = MaterialTheme.colorScheme.positive ) Text( - text = stringResource( - resource = Res.string.action_sleep_timer_enabled, - sleepTimerLeft.label() - ), + text = when (val mode = sleepTimerMode) { + is SleepTimerMode.Time -> stringResource( + Res.string.action_sleep_timer_enabled, + sleepTimerManager.timeLeft?.label() ?: "" + ) + + is SleepTimerMode.Songs -> stringResource( + Res.string.action_sleep_timer_songs_enabled, + mode.remaining + ) + + is SleepTimerMode.EndOfQueue -> stringResource( + Res.string.action_sleep_timer_queue_enabled + ) + + else -> "" + }, color = MaterialTheme.colorScheme.positive, modifier = Modifier.weight(1f) ) diff --git a/composeApp/src/commonMain/kotlin/paige/navic/ui/components/sheets/SleepTimerSheet.kt b/composeApp/src/commonMain/kotlin/paige/navic/ui/components/sheets/SleepTimerSheet.kt index bdaa00fa6..cb3c9be2f 100644 --- a/composeApp/src/commonMain/kotlin/paige/navic/ui/components/sheets/SleepTimerSheet.kt +++ b/composeApp/src/commonMain/kotlin/paige/navic/ui/components/sheets/SleepTimerSheet.kt @@ -2,9 +2,13 @@ package paige.navic.ui.components.sheets import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -14,23 +18,48 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.SheetValue +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import navic.composeapp.generated.resources.Res import navic.composeapp.generated.resources.action_disable_sleep_timer import navic.composeapp.generated.resources.action_sleep_timer +import navic.composeapp.generated.resources.action_sleep_timer_queue +import navic.composeapp.generated.resources.count_songs +import navic.composeapp.generated.resources.title_sleep_timer_songs +import navic.composeapp.generated.resources.title_sleep_timer_time +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import paige.navic.domain.manager.SleepTimerManager +import paige.navic.domain.manager.SleepTimerMode +import paige.navic.icons.Icons +import paige.navic.icons.outlined.Bedtime +import paige.navic.icons.outlined.Queue +import paige.navic.icons.outlined.QueuePlayNext +import paige.navic.ui.components.common.OptionCard import paige.navic.util.core.label import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes +enum class SleepTimerTab { + TIME, + SONGS +} + val durations = listOf( 5.minutes, 10.minutes, @@ -40,18 +69,24 @@ val durations = listOf( 1.hours, ) -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +val songCounts = listOf(1, 2, 3, 5, 10) + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class) @Composable fun SleepTimerSheet( onDismissRequest: (confirmed: Boolean) -> Unit ) { - val contentPadding = PaddingValues(horizontal = 16.dp) - val colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - trailingIconColor = MaterialTheme.colorScheme.onSurface, - headlineColor = MaterialTheme.colorScheme.onSurface - ) val sleepTimerManager = koinInject() + val currentMode by sleepTimerManager.mode.collectAsStateWithLifecycle() + + var selectedTab by rememberSaveable(currentMode) { + mutableStateOf( + when (currentMode) { + is SleepTimerMode.Songs, SleepTimerMode.EndOfQueue -> SleepTimerTab.SONGS + else -> SleepTimerTab.TIME + } + ) + } ModalBottomSheet( onDismissRequest = { onDismissRequest(false) }, @@ -69,8 +104,10 @@ fun SleepTimerSheet( } ) { Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = stringResource(Res.string.action_sleep_timer), @@ -78,33 +115,98 @@ fun SleepTimerSheet( modifier = Modifier.padding(horizontal = 16.dp) ) - durations.forEach { - ListItem( - content = { Text(it.label()) }, - onClick = { - sleepTimerManager.startTimer(it) - onDismissRequest(true) - }, - colors = colors, - contentPadding = contentPadding + PrimaryTabRow( + selectedTabIndex = selectedTab.ordinal, + containerColor = Color.Transparent, + divider = {} + ) { + Tab( + selected = selectedTab == SleepTimerTab.TIME, + onClick = { selectedTab = SleepTimerTab.TIME }, + text = { Text(stringResource(Res.string.title_sleep_timer_time)) } + ) + Tab( + selected = selectedTab == SleepTimerTab.SONGS, + onClick = { selectedTab = SleepTimerTab.SONGS }, + text = { Text(stringResource(Res.string.title_sleep_timer_songs)) } ) } - sleepTimerManager.endTimeStamp?.let { - ListItem( - content = { - Text( - stringResource(Res.string.action_disable_sleep_timer), - color = MaterialTheme.colorScheme.error + when (selectedTab) { + SleepTimerTab.TIME -> { + FlowRow( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + maxItemsInEachRow = 3 + ) { + durations.forEach { duration -> + OptionCard( + label = duration.label(), + icon = Icons.Outlined.Bedtime, + onClick = { + sleepTimerManager.startTimer(duration) + onDismissRequest(true) + }, + modifier = Modifier.weight(1f) + ) + } + } + } + SleepTimerTab.SONGS -> { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OptionCard( + label = stringResource(Res.string.action_sleep_timer_queue), + icon = Icons.Outlined.QueuePlayNext, + isActive = currentMode is SleepTimerMode.EndOfQueue, + onClick = { + sleepTimerManager.startEndOfQueueTimer() + onDismissRequest(true) + }, + modifier = Modifier.fillMaxWidth() ) - }, + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + maxItemsInEachRow = 3 + ) { + songCounts.forEach { count -> + OptionCard( + label = pluralStringResource(Res.plurals.count_songs, count, count), + icon = Icons.Outlined.Queue, + isActive = (currentMode as? SleepTimerMode.Songs)?.remaining == count, + onClick = { + sleepTimerManager.startSongsTimer(count) + onDismissRequest(true) + }, + modifier = Modifier.weight(1f) + ) + } + } + } + } + } + + if (currentMode !is SleepTimerMode.Disabled) { + Spacer(Modifier.height(8.dp)) + ListItem( onClick = { sleepTimerManager.stopTimer() onDismissRequest(true) }, - colors = colors, - contentPadding = contentPadding - ) + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) { + Text( + stringResource(Res.string.action_disable_sleep_timer), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/paige/navic/ui/components/sheets/SongSheet.kt b/composeApp/src/commonMain/kotlin/paige/navic/ui/components/sheets/SongSheet.kt index 53a76d791..8f6f544c5 100644 --- a/composeApp/src/commonMain/kotlin/paige/navic/ui/components/sheets/SongSheet.kt +++ b/composeApp/src/commonMain/kotlin/paige/navic/ui/components/sheets/SongSheet.kt @@ -23,10 +23,12 @@ import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessResumed import navic.composeapp.generated.resources.Res import navic.composeapp.generated.resources.action_add_to_another_playlist @@ -41,6 +43,8 @@ import navic.composeapp.generated.resources.action_remove_star import navic.composeapp.generated.resources.action_share import navic.composeapp.generated.resources.action_sleep_timer import navic.composeapp.generated.resources.action_sleep_timer_enabled +import navic.composeapp.generated.resources.action_sleep_timer_queue_enabled +import navic.composeapp.generated.resources.action_sleep_timer_songs_enabled import navic.composeapp.generated.resources.action_star import navic.composeapp.generated.resources.action_track_info import navic.composeapp.generated.resources.action_view_album @@ -54,6 +58,7 @@ import paige.navic.LocalNavStack import paige.navic.data.database.entities.DownloadStatus import paige.navic.domain.manager.PreferenceManager import paige.navic.domain.manager.SleepTimerManager +import paige.navic.domain.manager.SleepTimerMode import paige.navic.domain.models.DomainAlbum import paige.navic.domain.models.DomainExplicitStatus import paige.navic.domain.models.DomainSong @@ -117,7 +122,7 @@ fun SongSheet( val preferenceManager = koinInject() val sleepTimerManager = koinInject() - val sleepTimerLeft = sleepTimerManager.timeLeft + val sleepTimerMode by sleepTimerManager.mode.collectAsStateWithLifecycle() val contentPadding = PaddingValues(horizontal = 16.dp) val colorScheme = if (useSongTheme) rememberColorSchemeFromCoverArt(song.coverArtId) else null @@ -397,50 +402,43 @@ fun SongSheet( } if (showSleepTimer) { - if (sleepTimerLeft != null) { - ListItem( - content = { - Text( - stringResource( + val isEnabled = sleepTimerMode !is SleepTimerMode.Disabled + ListItem( + content = { + Text( + text = when (val mode = sleepTimerMode) { + is SleepTimerMode.Time -> stringResource( Res.string.action_sleep_timer_enabled, - sleepTimerLeft.label() - ), - color = MaterialTheme.colorScheme.positive - ) - }, - leadingContent = { - Icon( - Icons.Outlined.Bedtime, - null, - tint = MaterialTheme.colorScheme.positive - ) - }, - onClick = { - onSleepTimer?.invoke() - }, - colors = colors, - contentPadding = contentPadding - ) - } else { - ListItem( - content = { - Text( - stringResource(Res.string.action_sleep_timer) - ) - }, - leadingContent = { - Icon( - Icons.Outlined.Bedtime, - null - ) - }, - onClick = { - onSleepTimer?.invoke() - }, - colors = colors, - contentPadding = contentPadding - ) - } + sleepTimerManager.timeLeft?.label() ?: "" + ) + + is SleepTimerMode.Songs -> stringResource( + Res.string.action_sleep_timer_songs_enabled, + mode.remaining + ) + + is SleepTimerMode.EndOfQueue -> stringResource( + Res.string.action_sleep_timer_queue_enabled + ) + + else -> stringResource(Res.string.action_sleep_timer) + }, + color = if (isEnabled) MaterialTheme.colorScheme.positive else Color.Unspecified + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Bedtime, + contentDescription = null, + tint = if (isEnabled) MaterialTheme.colorScheme.positive else MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = { + onSleepTimer?.invoke() + }, + colors = colors, + contentPadding = contentPadding + ) } if (showPlaybackSpeed) {