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) {