Add sleep timer

This commit is contained in:
vfsfitvnm
2022-06-15 14:35:51 +02:00
parent 92963642a2
commit 3aeeb6c601
7 changed files with 567 additions and 38 deletions

View File

@@ -1,6 +1,5 @@
package it.vfsfitvnm.vimusic package it.vfsfitvnm.vimusic
import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@@ -28,14 +27,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp 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.accompanist.systemuicontroller.rememberSystemUiController
import com.google.common.util.concurrent.ListenableFuture
import com.valentinilk.shimmer.LocalShimmerTheme import com.valentinilk.shimmer.LocalShimmerTheme
import com.valentinilk.shimmer.defaultShimmerTheme import com.valentinilk.shimmer.defaultShimmerTheme
import it.vfsfitvnm.vimusic.enums.ColorPaletteMode 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.BottomSheetMenu
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState
@@ -50,16 +45,11 @@ import it.vfsfitvnm.vimusic.utils.*
@ExperimentalAnimationApi @ExperimentalAnimationApi
@ExperimentalFoundationApi @ExperimentalFoundationApi
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var mediaControllerFuture: ListenableFuture<MediaController>
private var uri by mutableStateOf<Uri?>(null, neverEqualPolicy()) private var uri by mutableStateOf<Uri?>(null, neverEqualPolicy())
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val sessionToken = SessionToken(this, ComponentName(this, PlayerService::class.java))
mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync()
uri = intent?.data uri = intent?.data
setContent { setContent {
@@ -122,7 +112,7 @@ class MainActivity : ComponentActivity() {
LocalColorPalette provides colorPalette, LocalColorPalette provides colorPalette,
LocalShimmerTheme provides shimmerTheme, LocalShimmerTheme provides shimmerTheme,
LocalTypography provides rememberTypography(colorPalette.text), LocalTypography provides rememberTypography(colorPalette.text),
LocalYoutubePlayer provides rememberYoutubePlayer(mediaControllerFuture), LocalYoutubePlayer provides rememberYoutubePlayer((application as MainApplication).mediaControllerFuture),
LocalMenuState provides rememberMenuState(), LocalMenuState provides rememberMenuState(),
LocalHapticFeedback provides rememberHapticFeedback() LocalHapticFeedback provides rememberHapticFeedback()
) { ) {
@@ -160,9 +150,4 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
uri = intent?.data uri = intent?.data
} }
override fun onDestroy() {
MediaController.releaseFuture(mediaControllerFuture)
super.onDestroy()
}
} }

View File

@@ -1,16 +1,31 @@
package it.vfsfitvnm.vimusic package it.vfsfitvnm.vimusic
import android.app.Application 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.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.disk.DiskCache import coil.disk.DiskCache
import com.google.common.util.concurrent.ListenableFuture
import it.vfsfitvnm.vimusic.services.PlayerService
import it.vfsfitvnm.vimusic.utils.preferences import it.vfsfitvnm.vimusic.utils.preferences
@ExperimentalAnimationApi
@ExperimentalFoundationApi
class MainApplication : Application(), ImageLoaderFactory { class MainApplication : Application(), ImageLoaderFactory {
lateinit var mediaControllerFuture: ListenableFuture<MediaController>
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
DatabaseInitializer() DatabaseInitializer()
val sessionToken = SessionToken(this, ComponentName(this, PlayerService::class.java))
mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync()
} }
override fun newImageLoader(): ImageLoader { override fun newImageLoader(): ImageLoader {

View File

@@ -10,6 +10,7 @@ import android.graphics.drawable.BitmapDrawable
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.SystemClock
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
@@ -46,6 +47,7 @@ import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome import it.vfsfitvnm.youtubemusic.Outcome
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.system.exitProcess
val StartRadioCommand = SessionCommand("StartRadioCommand", Bundle.EMPTY) val StartRadioCommand = SessionCommand("StartRadioCommand", Bundle.EMPTY)
@@ -60,6 +62,11 @@ val SetSkipSilenceCommand = SessionCommand("SetSkipSilenceCommand", Bundle.EMPTY
val GetAudioSessionIdCommand = SessionCommand("GetAudioSessionIdCommand", 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 @ExperimentalAnimationApi
@ExperimentalFoundationApi @ExperimentalFoundationApi
class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller, class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller,
@@ -70,6 +77,9 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller,
companion object { companion object {
private const val NotificationId = 1001 private const val NotificationId = 1001
private const val NotificationChannelId = "default_channel_id" 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 private lateinit var cache: SimpleCache
@@ -86,6 +96,9 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller,
private var radio: YoutubePlayer.Radio? = null private var radio: YoutubePlayer.Radio? = null
private var sleepTimerJob: Job? = null
private var sleepTimerRealtime: Long? = null
private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job() private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job()
override fun onCreate() { override fun onCreate() {
@@ -112,8 +125,6 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller,
) )
.build() .build()
player.repeatMode = preferences.repeatMode player.repeatMode = preferences.repeatMode
player.skipSilenceEnabled = preferences.skipSilence player.skipSilenceEnabled = preferences.skipSilence
player.playWhenReady = true player.playWhenReady = true
@@ -151,6 +162,9 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller,
.add(DeleteSongCacheCommand) .add(DeleteSongCacheCommand)
.add(SetSkipSilenceCommand) .add(SetSkipSilenceCommand)
.add(GetAudioSessionIdCommand) .add(GetAudioSessionIdCommand)
.add(SetSleepTimerCommand)
.add(GetSleepTimerMillisLeftCommand)
.add(CancelSleepTimerCommand)
.build() .build()
val playerCommands = Player.Commands.Builder().addAllCommands().build() val playerCommands = Player.Commands.Builder().addAllCommands().build()
return MediaSession.ConnectionResult.accept(sessionCommands, playerCommands) 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) return super.onCustomCommand(session, controller, customCommand, args)
@@ -345,14 +392,17 @@ class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller,
private fun createNotificationChannel() { private fun createNotificationChannel() {
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Util.SDK_INT >= 26 && notificationManager.getNotificationChannel(NotificationChannelId) == null) {
notificationManager.createNotificationChannel( if (Util.SDK_INT < 26) return
NotificationChannel(
NotificationChannelId, with(notificationManager) {
getString(R.string.default_notification_channel_name), if (getNotificationChannel(NotificationChannelId) == null) {
NotificationManager.IMPORTANCE_LOW 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))
}
} }
} }

View File

@@ -35,6 +35,7 @@ fun ChunkyButton(
@DrawableRes icon: Int? = null, @DrawableRes icon: Int? = null,
shape: Shape = RoundedCornerShape(16.dp), shape: Shape = RoundedCornerShape(16.dp),
colorFilter: ColorFilter = ColorFilter.tint(rippleColor), colorFilter: ColorFilter = ColorFilter.tint(rippleColor),
isEnabled: Boolean = true,
onMore: (() -> Unit)? = null onMore: (() -> Unit)? = null
) { ) {
Row( Row(
@@ -46,6 +47,7 @@ fun ChunkyButton(
.clickable( .clickable(
indication = rememberRipple(bounded = true, color = rippleColor), indication = rememberRipple(bounded = true, color = rippleColor),
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
enabled = isEnabled,
onClick = onClick onClick = onClick
) )
.padding(horizontal = 24.dp, vertical = 16.dp) .padding(horizontal = 24.dp, vertical = 16.dp)

View File

@@ -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<Int>())
}
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<Float>(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 <T> ItemPager(
items: List<T>,
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 <T> ItemPager(
items: List<T>,
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 <T : Enum<T>> 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)
}
}
}

View File

@@ -3,15 +3,16 @@ package it.vfsfitvnm.vimusic.ui.screens.settings
import android.content.Intent import android.content.Intent
import android.media.audiofx.AudioEffect import android.media.audiofx.AudioEffect
import android.os.Bundle import android.os.Bundle
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -19,18 +20,22 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.session.SessionResult
import it.vfsfitvnm.route.RouteHandler import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.services.GetAudioSessionIdCommand import it.vfsfitvnm.vimusic.services.*
import it.vfsfitvnm.vimusic.services.SetSkipSilenceCommand 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.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.screens.*
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.LocalPreferences import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer import kotlinx.coroutines.delay
import it.vfsfitvnm.vimusic.utils.semiBold
import kotlinx.coroutines.guava.await import kotlinx.coroutines.guava.await
import kotlinx.coroutines.isActive
@ExperimentalAnimationApi @ExperimentalAnimationApi
@@ -65,14 +70,12 @@ fun PlayerSettingsScreen() {
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { 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( val hasEqualizer = context.packageManager.resolveActivity(
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL), Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL),
0 0
) != null ) != null
println("hasEqualizer? $hasEqualizer")
if (hasEqualizer) { if (hasEqualizer) {
value = value =
mediaController?.sendCustomCommand(GetAudioSessionIdCommand, Bundle.EMPTY) mediaController?.sendCustomCommand(GetAudioSessionIdCommand, Bundle.EMPTY)
@@ -81,6 +84,138 @@ fun PlayerSettingsScreen() {
} }
} }
var sleepTimerMillisLeft by remember {
mutableStateOf<Long?>(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( Column(
modifier = Modifier modifier = Modifier
.background(colorPalette.background) .background(colorPalette.background)
@@ -144,12 +279,24 @@ fun PlayerSettingsScreen() {
activityResultLauncher.launch( activityResultLauncher.launch(
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply {
putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) 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 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
}
)
} }
} }
} }

View File

@@ -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()
}