From 3aeeb6c601c54bf3dab5b81043d9770e9d2c1287 Mon Sep 17 00:00:00 2001 From: vfsfitvnm Date: Wed, 15 Jun 2022 14:35:51 +0200 Subject: [PATCH] Add sleep timer --- .../it/vfsfitvnm/vimusic/MainActivity.kt | 17 +- .../it/vfsfitvnm/vimusic/MainApplication.kt | 15 + .../vimusic/services/PlayerService.kt | 70 +++- .../vimusic/ui/components/ChunkyButton.kt | 2 + .../vfsfitvnm/vimusic/ui/components/Pager.kt | 306 ++++++++++++++++++ .../screens/settings/PlayerSettingsScreen.kt | 171 +++++++++- .../vimusic/utils/MediaController.kt | 24 ++ 7 files changed, 567 insertions(+), 38 deletions(-) create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Pager.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/MediaController.kt diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt index 90ee02c..483f43e 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt @@ -1,6 +1,5 @@ package it.vfsfitvnm.vimusic -import android.content.ComponentName import android.content.Intent import android.net.Uri import android.os.Bundle @@ -28,14 +27,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import androidx.media3.session.MediaController -import androidx.media3.session.SessionToken import com.google.accompanist.systemuicontroller.rememberSystemUiController -import com.google.common.util.concurrent.ListenableFuture import com.valentinilk.shimmer.LocalShimmerTheme import com.valentinilk.shimmer.defaultShimmerTheme import it.vfsfitvnm.vimusic.enums.ColorPaletteMode -import it.vfsfitvnm.vimusic.services.PlayerService import it.vfsfitvnm.vimusic.ui.components.BottomSheetMenu import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState @@ -50,16 +45,11 @@ import it.vfsfitvnm.vimusic.utils.* @ExperimentalAnimationApi @ExperimentalFoundationApi class MainActivity : ComponentActivity() { - private lateinit var mediaControllerFuture: ListenableFuture - private var uri by mutableStateOf(null, neverEqualPolicy()) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val sessionToken = SessionToken(this, ComponentName(this, PlayerService::class.java)) - mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync() - uri = intent?.data setContent { @@ -122,7 +112,7 @@ class MainActivity : ComponentActivity() { LocalColorPalette provides colorPalette, LocalShimmerTheme provides shimmerTheme, LocalTypography provides rememberTypography(colorPalette.text), - LocalYoutubePlayer provides rememberYoutubePlayer(mediaControllerFuture), + LocalYoutubePlayer provides rememberYoutubePlayer((application as MainApplication).mediaControllerFuture), LocalMenuState provides rememberMenuState(), LocalHapticFeedback provides rememberHapticFeedback() ) { @@ -160,9 +150,4 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) uri = intent?.data } - - override fun onDestroy() { - MediaController.releaseFuture(mediaControllerFuture) - super.onDestroy() - } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt index 84efd6d..93ca9f1 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt @@ -1,16 +1,31 @@ package it.vfsfitvnm.vimusic import android.app.Application +import android.content.ComponentName +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken import coil.ImageLoader import coil.ImageLoaderFactory import coil.disk.DiskCache +import com.google.common.util.concurrent.ListenableFuture +import it.vfsfitvnm.vimusic.services.PlayerService import it.vfsfitvnm.vimusic.utils.preferences +@ExperimentalAnimationApi +@ExperimentalFoundationApi class MainApplication : Application(), ImageLoaderFactory { + lateinit var mediaControllerFuture: ListenableFuture + override fun onCreate() { super.onCreate() + DatabaseInitializer() + + val sessionToken = SessionToken(this, ComponentName(this, PlayerService::class.java)) + mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync() } override fun newImageLoader(): ImageLoader { diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/services/PlayerService.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/services/PlayerService.kt index b0c14d0..9c18305 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/services/PlayerService.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/services/PlayerService.kt @@ -10,6 +10,7 @@ import android.graphics.drawable.BitmapDrawable import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.SystemClock import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.animation.ExperimentalAnimationApi @@ -46,6 +47,7 @@ import it.vfsfitvnm.vimusic.utils.* import it.vfsfitvnm.youtubemusic.Outcome import kotlinx.coroutines.* import kotlin.math.roundToInt +import kotlin.system.exitProcess val StartRadioCommand = SessionCommand("StartRadioCommand", Bundle.EMPTY) @@ -60,6 +62,11 @@ val SetSkipSilenceCommand = SessionCommand("SetSkipSilenceCommand", Bundle.EMPTY val GetAudioSessionIdCommand = SessionCommand("GetAudioSessionIdCommand", Bundle.EMPTY) +val SetSleepTimerCommand = SessionCommand("SetSleepTimerCommand", Bundle.EMPTY) +val GetSleepTimerMillisLeftCommand = SessionCommand("GetSleepTimerMillisLeftCommand", Bundle.EMPTY) +val CancelSleepTimerCommand = SessionCommand("CancelSleepTimerCommand", Bundle.EMPTY) + + @ExperimentalAnimationApi @ExperimentalFoundationApi class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller, @@ -70,6 +77,9 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller, companion object { private const val NotificationId = 1001 private const val NotificationChannelId = "default_channel_id" + + private const val SleepTimerNotificationId = 1002 + private const val SleepTimerNotificationChannelId = "sleep_timer_channel_id" } private lateinit var cache: SimpleCache @@ -86,6 +96,9 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller, private var radio: YoutubePlayer.Radio? = null + private var sleepTimerJob: Job? = null + private var sleepTimerRealtime: Long? = null + private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job() override fun onCreate() { @@ -112,8 +125,6 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller, ) .build() - - player.repeatMode = preferences.repeatMode player.skipSilenceEnabled = preferences.skipSilence player.playWhenReady = true @@ -151,6 +162,9 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller, .add(DeleteSongCacheCommand) .add(SetSkipSilenceCommand) .add(GetAudioSessionIdCommand) + .add(SetSleepTimerCommand) + .add(GetSleepTimerMillisLeftCommand) + .add(CancelSleepTimerCommand) .build() val playerCommands = Player.Commands.Builder().addAllCommands().build() return MediaSession.ConnectionResult.accept(sessionCommands, playerCommands) @@ -205,6 +219,39 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller, ) ) } + SetSleepTimerCommand -> { + val delayMillis = args.getLong("delayMillis", 2000) + + sleepTimerJob = coroutineScope.launch { + sleepTimerRealtime = SystemClock.elapsedRealtime() + delayMillis + delay(delayMillis) + + withContext(Dispatchers.Main) { + val notification = NotificationCompat.Builder(this@PlayerService, SleepTimerNotificationChannelId) + .setContentTitle("Sleep timer ended") + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setShowWhen(true) + .setSmallIcon(R.drawable.app_icon) + .build() + + notificationManager.notify(SleepTimerNotificationId, notification) + } + + exitProcess(0) + } + } + GetSleepTimerMillisLeftCommand -> { + return Futures.immediateFuture(sleepTimerRealtime?.let { + (SessionResult(SessionResult.RESULT_SUCCESS, bundleOf("millisLeft" to it - SystemClock.elapsedRealtime()))) + } ?: SessionResult(SessionResult.RESULT_ERROR_INVALID_STATE)) + } + CancelSleepTimerCommand -> { + sleepTimerJob?.cancel() + sleepTimerJob = null + sleepTimerRealtime = null + } } return super.onCustomCommand(session, controller, customCommand, args) @@ -345,14 +392,17 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller, private fun createNotificationChannel() { notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (Util.SDK_INT >= 26 && notificationManager.getNotificationChannel(NotificationChannelId) == null) { - notificationManager.createNotificationChannel( - NotificationChannel( - NotificationChannelId, - getString(R.string.default_notification_channel_name), - NotificationManager.IMPORTANCE_LOW - ) - ) + + if (Util.SDK_INT < 26) return + + with(notificationManager) { + if (getNotificationChannel(NotificationChannelId) == null) { + createNotificationChannel(NotificationChannel(NotificationChannelId, getString(R.string.default_notification_channel_name), NotificationManager.IMPORTANCE_LOW)) + } + + if (getNotificationChannel(SleepTimerNotificationChannelId) == null) { + createNotificationChannel(NotificationChannel(SleepTimerNotificationChannelId, "Sleep timer", NotificationManager.IMPORTANCE_DEFAULT)) + } } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyButton.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyButton.kt index 765c1c4..3e32c11 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyButton.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyButton.kt @@ -35,6 +35,7 @@ fun ChunkyButton( @DrawableRes icon: Int? = null, shape: Shape = RoundedCornerShape(16.dp), colorFilter: ColorFilter = ColorFilter.tint(rippleColor), + isEnabled: Boolean = true, onMore: (() -> Unit)? = null ) { Row( @@ -46,6 +47,7 @@ fun ChunkyButton( .clickable( indication = rememberRipple(bounded = true, color = rippleColor), interactionSource = remember { MutableInteractionSource() }, + enabled = isEnabled, onClick = onClick ) .padding(horizontal = 24.dp, vertical = 16.dp) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Pager.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Pager.kt new file mode 100644 index 0000000..c8e2d42 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Pager.kt @@ -0,0 +1,306 @@ +package it.vfsfitvnm.vimusic.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.calculateTargetValue +import androidx.compose.animation.splineBasedDecay +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.GraphicsLayerScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.center +import androidx.compose.ui.util.lerp +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue + + +@Composable +fun Pager( + selectedIndex: Int, + onSelectedIndex: (Int) -> Unit, + modifier: Modifier = Modifier, + orientation: Orientation = Orientation.Horizontal, + alignment: Alignment = Alignment.Center, + transformer: PagerTransformer = PagerTransformer.Default, + content: @Composable () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + + val velocityTracker = remember { + VelocityTracker() + } + + val state = remember { + Animatable(0f) + } + + var steps by remember { + mutableStateOf(emptyList()) + } + + Layout( + modifier = modifier + .clipToBounds() + .pointerInput(Unit) { + val function = when (orientation) { + Orientation.Vertical -> ::detectVerticalDragGestures + Orientation.Horizontal -> ::detectHorizontalDragGestures + } + + function( + {}, + { + val velocity = -velocityTracker.calculateVelocity().x + val initialTargetValue = splineBasedDecay(this).calculateTargetValue(state.value, velocity) + + velocityTracker.resetTracking() + + val (targetValue, newSelectedIndex) = run { + for (i in 1..steps.lastIndex) { + val current = steps[i] + val previous = steps[i - 1] + + val currentDelta = current - initialTargetValue + val previousDelta = initialTargetValue - previous + + return@run when { + currentDelta >= 0 && previousDelta > 0 -> if (currentDelta < previousDelta) { + current to i + } else { + previous to i - 1 + } + previousDelta <= 0 -> previous to i - 1 + else -> continue + } + } + + steps.last() to steps.lastIndex + } + + coroutineScope.launch { + state.animateTo( + targetValue = targetValue.toFloat(), + initialVelocity = velocity, + ) + } + + onSelectedIndex(newSelectedIndex) + }, + {}, + { change, dragAmount -> + coroutineScope.launch { + state.snapTo(state.value - dragAmount) + } + + velocityTracker.addPosition(change.uptimeMillis, change.position) + change.consume() + }, + ) + }, + content = content + ) { measurables, constraints -> + val childConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val placeables = measurables.map { + it.measure(childConstraints) + } + + var acc = 0 + steps = placeables.map { + val dim = when (orientation) { + Orientation.Horizontal -> it.width + Orientation.Vertical -> it.height + } + val step = acc + dim / 2 + acc += dim + step + }.also { + if (steps.isEmpty()) { + coroutineScope.launch { + state.animateTo(it[selectedIndex].toFloat()) + } + } + } + + state.updateBounds(lowerBound = steps.first().toFloat(), upperBound = steps.last().toFloat()) + + val layoutDimension = IntSize( + width = if (constraints.minWidth > 0 || placeables.isEmpty()) { + constraints.minWidth + } else { + placeables.maxOf { + it.width + } + }, + height = if (constraints.minHeight > 0 || placeables.isEmpty()) { + constraints.minHeight + } else { + placeables.maxOf { + it.height + } + } + ) + + val center = when (orientation) { + Orientation.Horizontal -> layoutDimension.center.x + Orientation.Vertical -> layoutDimension.center.y + } + + layout( + width = layoutDimension.width, + height = layoutDimension.height + ) { + var position = center - state.value.toInt() + + for (placeable in placeables) { + val otherPosition = alignment.align( + size = IntSize( + width = placeable.width, + height = placeable.height + ), + space = layoutDimension, + layoutDirection = layoutDirection + ).let { + when (orientation) { + Orientation.Horizontal -> it.y + Orientation.Vertical -> it.x + } + } + + val placeablePosition = when (orientation) { + Orientation.Horizontal -> IntOffset(position, otherPosition) + Orientation.Vertical -> IntOffset(otherPosition, position) + } + + placeable.placeWithLayer(position = placeablePosition) { + with(transformer) { + val size = when (orientation) { + Orientation.Horizontal -> placeable.width + Orientation.Vertical -> placeable.height + }.toFloat() + val offset = (center - (position + size / 2)).absoluteValue / size + apply(distance = offset) + } + } + + position += when (orientation) { + Orientation.Horizontal -> placeable.width + Orientation.Vertical -> placeable.height + } + } + } + } +} + +// Cannot inline: https://issuetracker.google.com/issues/204897513 +@Composable +fun ItemPager( + items: List, + selectedIndex: Int, + onSelectedIndex: (Int) -> Unit, + modifier: Modifier = Modifier, + orientation: Orientation = Orientation.Horizontal, + alignment: Alignment = Alignment.Center, + transformer: PagerTransformer = PagerTransformer.Default, + content: @Composable (item: T) -> Unit +) { + Pager( + modifier = modifier, + selectedIndex = selectedIndex, + onSelectedIndex = onSelectedIndex, + orientation = orientation, + alignment = alignment, + transformer = transformer, + ) { + for (item in items) { + content(item) + } + } +} + +// Cannot inline: https://issuetracker.google.com/issues/204897513 +@Composable +fun ItemPager( + items: List, + selectedValue: T, + onSelectedValue: (T) -> Unit, + modifier: Modifier = Modifier, + orientation: Orientation = Orientation.Horizontal, + alignment: Alignment = Alignment.Center, + transformer: PagerTransformer = PagerTransformer.Default, + content: @Composable (item: T) -> Unit +) { + Pager( + modifier = modifier, + selectedIndex = items.indexOf(selectedValue).coerceAtLeast(0), + onSelectedIndex = { + onSelectedValue(items[it]) + }, + orientation = orientation, + alignment = alignment, + transformer = transformer, + ) { + for (item in items) { + content(item) + } + } +} + +// Cannot inline: https://issuetracker.google.com/issues/204897513 +@Composable +fun > EnumPager( + value: T, + onSelectedValue: (T) -> Unit, + modifier: Modifier = Modifier, + orientation: Orientation = Orientation.Horizontal, + alignment: Alignment = Alignment.Center, + transformer: PagerTransformer = PagerTransformer.Default, + content: @Composable (item: T) -> Unit +) { + val items = remember { + value.declaringClass.enumConstants!! + } + + Pager( + modifier = modifier, + selectedIndex = value.ordinal, + onSelectedIndex = { + onSelectedValue(items[it]) + }, + orientation = orientation, + alignment = alignment, + transformer = transformer, + ) { + for (item in items) { + content(item) + } + } +} + +@Immutable +fun interface PagerTransformer { + fun GraphicsLayerScope.apply(distance: Float) + + companion object { + @Stable + val Empty = PagerTransformer {} + + @Stable + val Default = PagerTransformer { + val value = 1f - it.coerceIn(0f, 1f) + lerp(start = 0.85f, stop = 1f, fraction = value).also { scale -> + scaleX = scale + scaleY = scale + } + + alpha = lerp(start = 0.5f, stop = 1f, fraction = value) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsScreen.kt index 2cf7417..3a86dac 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsScreen.kt @@ -3,15 +3,16 @@ package it.vfsfitvnm.vimusic.ui.screens.settings import android.content.Intent import android.media.audiofx.AudioEffect import android.os.Bundle +import android.text.format.DateUtils import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext @@ -19,18 +20,22 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.media3.common.C +import androidx.media3.session.SessionResult import it.vfsfitvnm.route.RouteHandler import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.services.GetAudioSessionIdCommand -import it.vfsfitvnm.vimusic.services.SetSkipSilenceCommand +import it.vfsfitvnm.vimusic.services.* +import it.vfsfitvnm.vimusic.ui.components.ChunkyButton +import it.vfsfitvnm.vimusic.ui.components.Pager import it.vfsfitvnm.vimusic.ui.components.TopAppBar +import it.vfsfitvnm.vimusic.ui.components.themed.ConfirmationDialog +import it.vfsfitvnm.vimusic.ui.components.themed.DefaultDialog import it.vfsfitvnm.vimusic.ui.screens.* import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette import it.vfsfitvnm.vimusic.ui.styling.LocalTypography -import it.vfsfitvnm.vimusic.utils.LocalPreferences -import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer -import it.vfsfitvnm.vimusic.utils.semiBold +import it.vfsfitvnm.vimusic.utils.* +import kotlinx.coroutines.delay import kotlinx.coroutines.guava.await +import kotlinx.coroutines.isActive @ExperimentalAnimationApi @@ -65,14 +70,12 @@ fun PlayerSettingsScreen() { rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } - val audioSessionId by produceState(initialValue = C.AUDIO_SESSION_ID_UNSET) { + val audioSessionId by produceState(initialValue = C.AUDIO_SESSION_ID_UNSET, mediaController) { val hasEqualizer = context.packageManager.resolveActivity( Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL), 0 ) != null - println("hasEqualizer? $hasEqualizer") - if (hasEqualizer) { value = mediaController?.sendCustomCommand(GetAudioSessionIdCommand, Bundle.EMPTY) @@ -81,6 +84,138 @@ fun PlayerSettingsScreen() { } } + var sleepTimerMillisLeft by remember { + mutableStateOf(null) + } + + LaunchedEffect(mediaController) { + while (isActive) { + println("mediaController: $mediaController") + sleepTimerMillisLeft = + mediaController?.syncCommand(GetSleepTimerMillisLeftCommand) + ?.takeIf { it.resultCode == SessionResult.RESULT_SUCCESS } + ?.extras?.getLong("millisLeft") + delay(1000) + } + } + + var isShowingSleepTimerDialog by remember { + mutableStateOf(false) + } + + if (isShowingSleepTimerDialog) { + if (sleepTimerMillisLeft != null) { + ConfirmationDialog( + text = "Do you want to stop the sleep timer?", + cancelText = "No", + confirmText = "Stop", + onDismiss = { + isShowingSleepTimerDialog = false + }, + onConfirm = { + mediaController?.syncCommand(CancelSleepTimerCommand) + sleepTimerMillisLeft = null + } + ) + } else { + DefaultDialog( + onDismiss = { + isShowingSleepTimerDialog = false + }, + modifier = Modifier + ) { + var hours by remember { + mutableStateOf(0) + } + + var minutes by remember { + mutableStateOf(0) + } + + BasicText( + text = "Set sleep timer", + style = typography.s.semiBold, + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 24.dp) + ) + + Row( + modifier = Modifier + .padding(vertical = 16.dp) + ) { + Pager( + selectedIndex = hours, + onSelectedIndex = { + hours = it + }, + orientation = Orientation.Vertical, + modifier = Modifier + .padding(horizontal = 8.dp) + .height(72.dp) + ) { + repeat(12) { + BasicText( + text = "$it h", + style = typography.xs.semiBold + ) + } + } + + Pager( + selectedIndex = minutes, + onSelectedIndex = { + minutes = it + }, + orientation = Orientation.Vertical, + modifier = Modifier + .padding(horizontal = 8.dp) + .height(72.dp) + ) { + repeat(4) { + BasicText( + text = "${it * 15} m", + style = typography.xs.semiBold + ) + } + } + } + + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxWidth() + ) { + ChunkyButton( + backgroundColor = colorPalette.lightBackground, + text = "Cancel", + textStyle = typography.xs.semiBold, + shape = RoundedCornerShape(36.dp), + onClick = { isShowingSleepTimerDialog = false } + ) + + ChunkyButton( + backgroundColor = colorPalette.primaryContainer, + text = "Set", + textStyle = typography.xs.semiBold.color(colorPalette.onPrimaryContainer), + shape = RoundedCornerShape(36.dp), + isEnabled = hours > 0 || minutes > 0, + onClick = { + mediaController?.syncCommand( + SetSleepTimerCommand, + bundleOf("delayMillis" to (hours * 60 + minutes * 15) * 60 * 1000L) + ) + sleepTimerMillisLeft = + mediaController?.syncCommand(GetSleepTimerMillisLeftCommand)?.extras?.getLong( + "millisLeft" + ) + isShowingSleepTimerDialog = false + } + ) + } + } + } + } + Column( modifier = Modifier .background(colorPalette.background) @@ -144,12 +279,24 @@ fun PlayerSettingsScreen() { activityResultLauncher.launch( Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) - putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + putExtra( + AudioEffect.EXTRA_CONTENT_TYPE, + AudioEffect.CONTENT_TYPE_MUSIC + ) } ) }, isEnabled = audioSessionId != C.AUDIO_SESSION_ID_UNSET && audioSessionId != AudioEffect.ERROR_BAD_VALUE ) + + SettingsEntry( + title = "Sleep timer", + text = sleepTimerMillisLeft?.let { "${DateUtils.formatElapsedTime(it / 1000)} left" } + ?: "Stop the music after a period of time", + onClick = { + isShowingSleepTimerDialog = true + } + ) } } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/MediaController.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/MediaController.kt new file mode 100644 index 0000000..a4ae335 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/MediaController.kt @@ -0,0 +1,24 @@ +package it.vfsfitvnm.vimusic.utils + +import android.os.Bundle +import androidx.media3.session.MediaController +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.util.concurrent.MoreExecutors +import kotlinx.coroutines.guava.await + + +suspend fun MediaController.send(command: SessionCommand, args: Bundle = Bundle.EMPTY): SessionResult { + return sendCustomCommand(command, args).await() +} + +fun MediaController.command(command: SessionCommand, args: Bundle = Bundle.EMPTY, listener: ((SessionResult) -> Unit)? = null) { + val future = sendCustomCommand(command, args) + listener?.let { + future.addListener({ it(future.get()) }, MoreExecutors.directExecutor()) + } +} + +fun MediaController.syncCommand(command: SessionCommand, args: Bundle = Bundle.EMPTY): SessionResult { + return sendCustomCommand(command, args).get() +} \ No newline at end of file