Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers(
val onSeek = remember(playerViewModel) { playerViewModel::seekTo }
val onNext = remember(playerViewModel) { playerViewModel::nextSong }
val onPrevious = remember(playerViewModel) { playerViewModel::previousSong }
val onSeekForward = remember(playerViewModel) {
{ delta: Long -> playerViewModel.seekByDelta(delta) }
}
val onSeekBackward = remember(playerViewModel) {
{ delta: Long -> playerViewModel.seekByDelta(delta) }
}
val onCollapse = remember(playerViewModel) {
{ playerViewModel.collapsePlayerSheet() }
}
Expand Down Expand Up @@ -247,6 +253,8 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers(
onSeek = onSeek,
onNext = onNext,
onPrevious = onPrevious,
onSeekForward = onSeekForward,
onSeekBackward = onSeekBackward,
onCollapse = onCollapse,
onShowQueueClicked = onShowQueueClicked,
onQueueDragStart = onQueueDragStart,
Expand Down Expand Up @@ -313,6 +321,12 @@ internal fun UnifiedPlayerPrewarmLayer(
val onSeek = remember(playerViewModel) { playerViewModel::seekTo }
val onNext = remember(playerViewModel) { playerViewModel::nextSong }
val onPrevious = remember(playerViewModel) { playerViewModel::previousSong }
val onSeekForward = remember(playerViewModel) {
{ delta: Long -> playerViewModel.seekByDelta(delta) }
}
val onSeekBackward = remember(playerViewModel) {
{ delta: Long -> playerViewModel.seekByDelta(delta) }
}
val onShuffleToggle = remember(playerViewModel) { { playerViewModel.toggleShuffle() } }
val onRepeatToggle = remember(playerViewModel) { playerViewModel::cycleRepeatMode }
val onFavoriteToggle = remember(playerViewModel) { playerViewModel::toggleFavorite }
Expand Down Expand Up @@ -348,6 +362,8 @@ internal fun UnifiedPlayerPrewarmLayer(
onSeek = onSeek,
onNext = onNext,
onPrevious = onPrevious,
onSeekForward = onSeekForward,
onSeekBackward = onSeekBackward,
onCollapse = {},
onShowCastClicked = {},
onShuffleToggle = onShuffleToggle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,8 @@ fun ExternalPlayerOverlay(
onPrevious = playerViewModel::previousSong,
onPlayPause = playerViewModel::playPause,
onNext = playerViewModel::nextSong,
onSeekForward = { delta -> playerViewModel.seekByDelta(delta) },
onSeekBackward = { delta -> playerViewModel.seekByDelta(delta) },
height = 76.dp,
pressAnimationSpec = controlSpatialSpec,
colorOtherButtons = skipContainer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
Expand Down Expand Up @@ -40,23 +42,28 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.theveloper.pixelplay.presentation.components.LocalMaterialTheme
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape

private enum class PlaybackButtonType { NONE, PREVIOUS, PLAY_PAUSE, NEXT }

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@Composable
fun AnimatedPlaybackControls(
isPlayingProvider: () -> Boolean,
onPrevious: () -> Unit,
onPlayPause: () -> Unit,
onNext: () -> Unit,
modifier: Modifier = Modifier,
onSeekForward: ((Long) -> Unit)? = null,
onSeekBackward: ((Long) -> Unit)? = null,
height: Dp = 90.dp,
baseWeight: Float = 1f,
expansionWeight: Float = 1.1f,
Expand Down Expand Up @@ -147,20 +154,54 @@ fun AnimatedPlaybackControls(
animationSpec = pressAnimationSpec,
label = "prevWeight"
)
var seekBackwardJob by remember { mutableStateOf<Job?>(null) }
Box(
modifier = Modifier
.weight(prevWeight)
.fillMaxHeight()
.clip(CircleShape)
.background(colorPreviousButton)
.clickable {
lastClicked = PlaybackButtonType.PREVIOUS
clickTrigger++
coroutineScope.launch {
delay(180)
onPrevious()
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Final)
if (event.changes.all { !it.pressed }) {
seekBackwardJob?.cancel()
seekBackwardJob = null
}
}
}
},
}
.combinedClickable(
onClick = {
lastClicked = PlaybackButtonType.PREVIOUS
clickTrigger++
coroutineScope.launch {
delay(180)
onPrevious()
}
},
onLongClick = if (onSeekBackward != null) {
{
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
seekBackwardJob = coroutineScope.launch {
var elapsed = 0L
val tickRate = 100L
while (true) {
delay(tickRate)
elapsed += tickRate
val accelerated = elapsed >= 3000L
val skipAmount = if (accelerated) 1000L else 500L

if (elapsed % 400L == 0L) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
onSeekBackward(-skipAmount)
}
}
}
} else null
),
contentAlignment = Alignment.Center
) {
Icon(
Expand Down Expand Up @@ -220,20 +261,54 @@ fun AnimatedPlaybackControls(
animationSpec = pressAnimationSpec,
label = "nextWeight"
)
var seekForwardJob by remember { mutableStateOf<Job?>(null) }
Box(
modifier = Modifier
.weight(nextWeight)
.fillMaxHeight()
.clip(CircleShape)
.background(colorNextButton)
.clickable {
lastClicked = PlaybackButtonType.NEXT
clickTrigger++
coroutineScope.launch {
delay(180)
onNext()
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Final)
if (event.changes.all { !it.pressed }) {
seekForwardJob?.cancel()
seekForwardJob = null
}
}
}
},
}
.combinedClickable(
onClick = {
lastClicked = PlaybackButtonType.NEXT
clickTrigger++
coroutineScope.launch {
delay(180)
onNext()
}
},
onLongClick = if (onSeekForward != null) {
{
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
seekForwardJob = coroutineScope.launch {
var elapsed = 0L
val tickRate = 100L
while (true) {
delay(tickRate)
elapsed += tickRate
val accelerated = elapsed >= 3000L
val skipAmount = if (accelerated) 1000L else 500L

if (elapsed % 400L == 0L) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
onSeekForward(skipAmount)
}
}
}
} else null
),
contentAlignment = Alignment.Center
) {
Icon(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ fun FullPlayerContent(
onSeek: (Long) -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onSeekForward: ((Long) -> Unit)? = null,
onSeekBackward: ((Long) -> Unit)? = null,
onCollapse: () -> Unit,
onShowQueueClicked: () -> Unit,
onQueueDragStart: () -> Unit,
Expand Down Expand Up @@ -555,6 +557,8 @@ fun FullPlayerContent(
onPrevious = onPreviousWithOptimisticCarousel,
onPlayPause = onPlayPause,
onNext = onNextWithOptimisticCarousel,
onSeekForward = onSeekForward,
onSeekBackward = onSeekBackward,
transportPlayPauseColors = transportPlayPauseColors,
transportSkipColors = transportSkipButtonColors,
isShuffleEnabledProvider = isShuffleEnabledProvider,
Expand Down Expand Up @@ -1118,6 +1122,8 @@ private fun FullPlayerControlsSection(
onPrevious: () -> Unit,
onPlayPause: () -> Unit,
onNext: () -> Unit,
onSeekForward: ((Long) -> Unit)? = null,
onSeekBackward: ((Long) -> Unit)? = null,
transportPlayPauseColors: TransportButtonColors,
transportSkipColors: TransportButtonColors,
isShuffleEnabledProvider: () -> Boolean,
Expand Down Expand Up @@ -1162,6 +1168,8 @@ private fun FullPlayerControlsSection(
onPrevious = onPrevious,
onPlayPause = onPlayPause,
onNext = onNext,
onSeekForward = onSeekForward,
onSeekBackward = onSeekBackward,
height = 80.dp,
pressAnimationSpec = controlSpatialSpec,
releaseDelay = 220L,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,21 @@ class PlaybackStateHolder @Inject constructor(
}
}

/**
* Seeks relative to the current position by [deltaMs] milliseconds.
* Positive values seek forward, negative values seek backward.
* The resulting position is clamped to `[0, totalDuration]`.
*/
fun seekByDelta(deltaMs: Long) {
val current = _currentPosition.value
val duration = _stablePlayerState.value.totalDuration.takeIf { it > 0L }
?: _stablePlayerState.value.currentSong?.duration
?: return
val safeDuration = duration.coerceAtLeast(0L)
val target = (current + deltaMs).coerceAtMost(safeDuration).coerceAtLeast(0L)
seekTo(target)
}

fun previousSong() {
val castSession = castStateHolder.castSession.value
if (castSession != null && castSession.remoteMediaClient != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2455,6 +2455,10 @@ class PlayerViewModel @Inject constructor(
playbackStateHolder.previousSong()
}

fun seekByDelta(deltaMs: Long) {
playbackStateHolder.seekByDelta(deltaMs)
}

private fun startProgressUpdates() {
playbackStateHolder.startProgressUpdates()
}
Expand Down