Add sleep timer
This commit is contained in:
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
306
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Pager.kt
Normal file
306
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Pager.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user