mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-30 16:19:25 +00:00
Add player layout and overlay components for enhanced media playback experience
- Introduced PlayerLayout.kt to define layout metrics based on screen width. - Created PlayerOverlays.kt for various overlay components including OpeningOverlay, GestureFeedbackPill, PauseMetadataOverlay, and ErrorModal. - Refactored PlayerScreen.kt to utilize new overlay and layout components, improving code organization and readability.
This commit is contained in:
parent
143ab98a08
commit
0edea04d78
4 changed files with 928 additions and 866 deletions
|
|
@ -0,0 +1,466 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeContent
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.AspectRatio
|
||||
import androidx.compose.material.icons.rounded.Forward10
|
||||
import androidx.compose.material.icons.rounded.Pause
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Replay10
|
||||
import androidx.compose.material.icons.rounded.Speed
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.nuvioTypeScale
|
||||
|
||||
@Composable
|
||||
internal fun PlayerControlsShell(
|
||||
title: String,
|
||||
streamTitle: String,
|
||||
providerName: String,
|
||||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
episodeTitle: String?,
|
||||
playbackSnapshot: PlayerPlaybackSnapshot,
|
||||
displayedPositionMs: Long,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
resizeMode: PlayerResizeMode,
|
||||
onBack: () -> Unit,
|
||||
onTogglePlayback: () -> Unit,
|
||||
onSeekBack: () -> Unit,
|
||||
onSeekForward: () -> Unit,
|
||||
onResizeModeClick: () -> Unit,
|
||||
onSpeedClick: () -> Unit,
|
||||
onScrubChange: (Long) -> Unit,
|
||||
onScrubFinished: (Long) -> Unit,
|
||||
horizontalSafePadding: androidx.compose.ui.unit.Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(160.dp)
|
||||
.align(Alignment.TopCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.7f),
|
||||
Color.Transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.7f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = horizontalSafePadding),
|
||||
) {
|
||||
PlayerHeader(
|
||||
title = title,
|
||||
streamTitle = streamTitle,
|
||||
providerName = providerName,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
backendLabel = "Android · Media3 ExoPlayer",
|
||||
metrics = metrics,
|
||||
onBack = onBack,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))
|
||||
.padding(horizontal = metrics.horizontalPadding, vertical = metrics.verticalPadding),
|
||||
)
|
||||
|
||||
CenterControls(
|
||||
snapshot = playbackSnapshot,
|
||||
metrics = metrics,
|
||||
onSeekBack = onSeekBack,
|
||||
onSeekForward = onSeekForward,
|
||||
onTogglePlayback = onTogglePlayback,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(bottom = metrics.centerLift),
|
||||
)
|
||||
|
||||
ProgressControls(
|
||||
playbackSnapshot = playbackSnapshot,
|
||||
displayedPositionMs = displayedPositionMs,
|
||||
metrics = metrics,
|
||||
resizeMode = resizeMode,
|
||||
onScrubChange = onScrubChange,
|
||||
onScrubFinished = onScrubFinished,
|
||||
onResizeModeClick = onResizeModeClick,
|
||||
onSpeedClick = onSpeedClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = metrics.horizontalPadding)
|
||||
.padding(bottom = metrics.sliderBottomOffset),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerHeader(
|
||||
title: String,
|
||||
streamTitle: String,
|
||||
providerName: String,
|
||||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
episodeTitle: String?,
|
||||
backendLabel: String,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val typeScale = MaterialTheme.nuvioTypeScale
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = typeScale.titleLg.copy(
|
||||
fontSize = metrics.titleSize,
|
||||
lineHeight = metrics.titleSize * 1.16f,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = "S${seasonNumber}E${episodeNumber} • $episodeTitle",
|
||||
style = typeScale.bodyMd.copy(
|
||||
fontSize = metrics.episodeInfoSize,
|
||||
lineHeight = metrics.episodeInfoSize * 1.3f,
|
||||
),
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = streamTitle,
|
||||
style = typeScale.labelSm.copy(
|
||||
fontSize = metrics.metadataSize,
|
||||
lineHeight = metrics.metadataSize * 1.25f,
|
||||
),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = providerName,
|
||||
style = typeScale.labelSm.copy(
|
||||
fontSize = metrics.metadataSize,
|
||||
lineHeight = metrics.metadataSize * 1.25f,
|
||||
fontStyle = FontStyle.Italic,
|
||||
),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = backendLabel,
|
||||
style = typeScale.labelXs,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.35f))
|
||||
.clickable(onClick = onBack)
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = "Close player",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(metrics.headerIconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterControls(
|
||||
snapshot: PlayerPlaybackSnapshot,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
onSeekBack: () -> Unit,
|
||||
onSeekForward: () -> Unit,
|
||||
onTogglePlayback: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(metrics.centerGap),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SideControlButton(
|
||||
icon = Icons.Rounded.Replay10,
|
||||
contentDescription = "Seek backward 10 seconds",
|
||||
metrics = metrics,
|
||||
onClick = onSeekBack,
|
||||
)
|
||||
PlayPauseControlButton(
|
||||
isPlaying = snapshot.isPlaying,
|
||||
isBuffering = snapshot.isLoading,
|
||||
metrics = metrics,
|
||||
onClick = onTogglePlayback,
|
||||
)
|
||||
SideControlButton(
|
||||
icon = Icons.Rounded.Forward10,
|
||||
contentDescription = "Seek forward 10 seconds",
|
||||
metrics = metrics,
|
||||
onClick = onSeekForward,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SideControlButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
contentDescription: String,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.28f))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(metrics.sideButtonPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(metrics.sideIconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayPauseControlButton(
|
||||
isPlaying: Boolean,
|
||||
isBuffering: Boolean,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.28f))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(metrics.playButtonPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isBuffering) {
|
||||
CircularProgressIndicator(
|
||||
color = Color.White,
|
||||
strokeWidth = 3.dp,
|
||||
modifier = Modifier.size(metrics.playIconSize),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(metrics.playIconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressControls(
|
||||
playbackSnapshot: PlayerPlaybackSnapshot,
|
||||
displayedPositionMs: Long,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
resizeMode: PlayerResizeMode,
|
||||
onScrubChange: (Long) -> Unit,
|
||||
onScrubFinished: (Long) -> Unit,
|
||||
onResizeModeClick: () -> Unit,
|
||||
onSpeedClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
|
||||
Column(modifier = modifier) {
|
||||
Slider(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(metrics.sliderTouchHeight)
|
||||
.graphicsLayer(scaleY = metrics.sliderScaleY),
|
||||
value = displayedPositionMs.coerceIn(0L, durationMs).toFloat(),
|
||||
onValueChange = { value -> onScrubChange(value.toLong()) },
|
||||
onValueChangeFinished = { onScrubFinished(displayedPositionMs.coerceIn(0L, durationMs)) },
|
||||
valueRange = 0f..durationMs.toFloat(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp)
|
||||
.padding(top = 4.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TimePill(text = formatPlaybackTime(displayedPositionMs), fontSize = metrics.timeSize)
|
||||
TimePill(text = formatPlaybackTime(durationMs), fontSize = metrics.timeSize)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Surface(
|
||||
color = Color.Black.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
modifier = Modifier.border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PlayerActionPillButton(
|
||||
icon = Icons.Rounded.AspectRatio,
|
||||
label = resizeMode.label,
|
||||
onClick = onResizeModeClick,
|
||||
)
|
||||
PlayerActionPillButton(
|
||||
icon = Icons.Rounded.Speed,
|
||||
label = "${playbackSnapshot.playbackSpeed}x",
|
||||
onClick = onSpeedClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimePill(
|
||||
text: String,
|
||||
fontSize: androidx.compose.ui.unit.TextUnit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.2f), RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.nuvioTypeScale.labelSm.copy(
|
||||
fontSize = fontSize,
|
||||
lineHeight = fontSize * 1.25f,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerActionPillButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.nuvioTypeScale.labelSm,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.safeContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.math.max
|
||||
|
||||
internal data class PlayerLayoutMetrics(
|
||||
val horizontalPadding: Dp,
|
||||
val verticalPadding: Dp,
|
||||
val titleSize: TextUnit,
|
||||
val episodeInfoSize: TextUnit,
|
||||
val metadataSize: TextUnit,
|
||||
val centerGap: Dp,
|
||||
val centerLift: Dp,
|
||||
val sliderBottomOffset: Dp,
|
||||
val sliderTouchHeight: Dp,
|
||||
val sliderScaleY: Float,
|
||||
val timeSize: TextUnit,
|
||||
val headerIconSize: Dp,
|
||||
val sideButtonPadding: Dp,
|
||||
val sideIconSize: Dp,
|
||||
val playButtonPadding: Dp,
|
||||
val playIconSize: Dp,
|
||||
) {
|
||||
companion object {
|
||||
fun fromWidth(width: Dp): PlayerLayoutMetrics =
|
||||
when {
|
||||
width >= 1440.dp -> PlayerLayoutMetrics(
|
||||
horizontalPadding = 28.dp,
|
||||
verticalPadding = 24.dp,
|
||||
titleSize = 28.dp.value.sp,
|
||||
episodeInfoSize = 16.dp.value.sp,
|
||||
metadataSize = 14.dp.value.sp,
|
||||
centerGap = 112.dp,
|
||||
centerLift = 24.dp,
|
||||
sliderBottomOffset = 28.dp,
|
||||
sliderTouchHeight = 28.dp,
|
||||
sliderScaleY = 0.72f,
|
||||
timeSize = 14.dp.value.sp,
|
||||
headerIconSize = 24.dp,
|
||||
sideButtonPadding = 14.dp,
|
||||
sideIconSize = 34.dp,
|
||||
playButtonPadding = 18.dp,
|
||||
playIconSize = 44.dp,
|
||||
)
|
||||
width >= 1024.dp -> PlayerLayoutMetrics(
|
||||
horizontalPadding = 24.dp,
|
||||
verticalPadding = 20.dp,
|
||||
titleSize = 24.dp.value.sp,
|
||||
episodeInfoSize = 15.dp.value.sp,
|
||||
metadataSize = 13.dp.value.sp,
|
||||
centerGap = 88.dp,
|
||||
centerLift = 18.dp,
|
||||
sliderBottomOffset = 24.dp,
|
||||
sliderTouchHeight = 26.dp,
|
||||
sliderScaleY = 0.74f,
|
||||
timeSize = 13.dp.value.sp,
|
||||
headerIconSize = 22.dp,
|
||||
sideButtonPadding = 13.dp,
|
||||
sideIconSize = 32.dp,
|
||||
playButtonPadding = 16.dp,
|
||||
playIconSize = 42.dp,
|
||||
)
|
||||
width >= 768.dp -> PlayerLayoutMetrics(
|
||||
horizontalPadding = 20.dp,
|
||||
verticalPadding = 16.dp,
|
||||
titleSize = 22.dp.value.sp,
|
||||
episodeInfoSize = 14.dp.value.sp,
|
||||
metadataSize = 12.dp.value.sp,
|
||||
centerGap = 72.dp,
|
||||
centerLift = 14.dp,
|
||||
sliderBottomOffset = 20.dp,
|
||||
sliderTouchHeight = 24.dp,
|
||||
sliderScaleY = 0.78f,
|
||||
timeSize = 12.dp.value.sp,
|
||||
headerIconSize = 20.dp,
|
||||
sideButtonPadding = 12.dp,
|
||||
sideIconSize = 30.dp,
|
||||
playButtonPadding = 15.dp,
|
||||
playIconSize = 38.dp,
|
||||
)
|
||||
else -> PlayerLayoutMetrics(
|
||||
horizontalPadding = 20.dp,
|
||||
verticalPadding = 16.dp,
|
||||
titleSize = 18.dp.value.sp,
|
||||
episodeInfoSize = 14.dp.value.sp,
|
||||
metadataSize = 12.dp.value.sp,
|
||||
centerGap = 56.dp,
|
||||
centerLift = 10.dp,
|
||||
sliderBottomOffset = 16.dp,
|
||||
sliderTouchHeight = 22.dp,
|
||||
sliderScaleY = 0.82f,
|
||||
timeSize = 12.dp.value.sp,
|
||||
headerIconSize = 20.dp,
|
||||
sideButtonPadding = 10.dp,
|
||||
sideIconSize = 26.dp,
|
||||
playButtonPadding = 13.dp,
|
||||
playIconSize = 34.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun playerHorizontalSafePadding(): Dp {
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val safePadding = WindowInsets.safeContent.asPaddingValues()
|
||||
val left = safePadding.calculateLeftPadding(layoutDirection)
|
||||
val right = safePadding.calculateRightPadding(layoutDirection)
|
||||
return if (left > right) left else right
|
||||
}
|
||||
|
||||
internal fun PlayerResizeMode.next(): PlayerResizeMode =
|
||||
when (this) {
|
||||
PlayerResizeMode.Fit -> PlayerResizeMode.Fill
|
||||
PlayerResizeMode.Fill -> PlayerResizeMode.Zoom
|
||||
PlayerResizeMode.Zoom -> PlayerResizeMode.Fit
|
||||
}
|
||||
|
||||
internal val PlayerResizeMode.label: String
|
||||
get() = when (this) {
|
||||
PlayerResizeMode.Fit -> "Fit"
|
||||
PlayerResizeMode.Fill -> "Fill"
|
||||
PlayerResizeMode.Zoom -> "Zoom"
|
||||
}
|
||||
|
||||
internal fun formatPlaybackTime(positionMs: Long): String {
|
||||
val totalSeconds = (positionMs / 1000L).coerceAtLeast(0L)
|
||||
val seconds = totalSeconds % 60
|
||||
val minutes = (totalSeconds / 60) % 60
|
||||
val hours = totalSeconds / 3600
|
||||
return if (hours > 0) {
|
||||
"${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}"
|
||||
} else {
|
||||
"${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeContent
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||
import androidx.compose.material.icons.rounded.Speed
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.nuvioTypeScale
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
internal fun OpeningOverlay(
|
||||
artwork: String?,
|
||||
logo: String?,
|
||||
onBack: () -> Unit,
|
||||
horizontalSafePadding: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.85f)),
|
||||
) {
|
||||
if (artwork != null) {
|
||||
AsyncImage(
|
||||
model = artwork,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.3f),
|
||||
Color.Black.copy(alpha = 0.6f),
|
||||
Color.Black.copy(alpha = 0.8f),
|
||||
Color.Black.copy(alpha = 0.9f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))
|
||||
.padding(top = 20.dp, start = horizontalSafePadding, end = horizontalSafePadding + 20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.3f))
|
||||
.clickable(onClick = onBack)
|
||||
.padding(10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = "Close player",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (logo != null) {
|
||||
AsyncImage(
|
||||
model = logo,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(180.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
color = Color(0xFFE50914),
|
||||
strokeWidth = 3.dp,
|
||||
modifier = Modifier.size(54.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GestureFeedbackPill(
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color.Black.copy(alpha = 0.75f))
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.White.copy(alpha = 0.15f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Speed,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.nuvioTypeScale.bodyLg.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PauseMetadataOverlay(
|
||||
title: String,
|
||||
isEpisode: Boolean,
|
||||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
episodeTitle: String?,
|
||||
streamSubtitle: String?,
|
||||
providerName: String,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
horizontalSafePadding: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.85f),
|
||||
Color.Black.copy(alpha = 0.45f),
|
||||
Color.Transparent,
|
||||
),
|
||||
),
|
||||
)
|
||||
.windowInsetsPadding(WindowInsets.safeContent)
|
||||
.padding(
|
||||
start = 24.dp + horizontalSafePadding,
|
||||
end = 24.dp + horizontalSafePadding,
|
||||
top = 24.dp,
|
||||
bottom = 24.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
) {
|
||||
Text(
|
||||
text = "You're watching",
|
||||
style = MaterialTheme.nuvioTypeScale.bodyLg,
|
||||
color = Color(0xFFB8B8B8),
|
||||
)
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.nuvioTypeScale.displayMd.copy(
|
||||
fontSize = max(metrics.titleSize.value * 1.8f, 32f).sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
),
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(8.dp))
|
||||
val episodeInfo = if (isEpisode && seasonNumber != null && episodeNumber != null) {
|
||||
buildString {
|
||||
append("S")
|
||||
append(seasonNumber)
|
||||
append(" • E")
|
||||
append(episodeNumber)
|
||||
if (!episodeTitle.isNullOrBlank()) {
|
||||
append(" • ")
|
||||
append(episodeTitle)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
providerName
|
||||
}
|
||||
Text(
|
||||
text = episodeInfo,
|
||||
style = MaterialTheme.nuvioTypeScale.bodyLg,
|
||||
color = Color(0xFFCCCCCC),
|
||||
)
|
||||
if (!streamSubtitle.isNullOrBlank()) {
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = streamSubtitle,
|
||||
style = MaterialTheme.nuvioTypeScale.bodyLg.copy(lineHeight = 24.sp),
|
||||
color = Color(0xFFD6D6D6),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ErrorModal(
|
||||
message: String,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.7f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(0.8f)
|
||||
.widthIn(max = 400.dp),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = Color(0xFF1A1A1A),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFFEF4444).copy(alpha = 0.1f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ErrorOutline,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFEF4444),
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Playback error",
|
||||
style = MaterialTheme.nuvioTypeScale.titleMd.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.White,
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.nuvioTypeScale.bodyLg.copy(lineHeight = 22.sp),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onDismiss),
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Dismiss",
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
style = MaterialTheme.nuvioTypeScale.bodyLg.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.Black,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,45 +4,16 @@ import androidx.compose.animation.AnimatedVisibility
|
|||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeContent
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.AspectRatio
|
||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||
import androidx.compose.material.icons.rounded.Forward10
|
||||
import androidx.compose.material.icons.rounded.Pause
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Replay10
|
||||
import androidx.compose.material.icons.rounded.Speed
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -53,29 +24,14 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.nuvioTypeScale
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
|
|
@ -331,825 +287,3 @@ fun PlayerScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerControlsShell(
|
||||
title: String,
|
||||
streamTitle: String,
|
||||
providerName: String,
|
||||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
episodeTitle: String?,
|
||||
playbackSnapshot: PlayerPlaybackSnapshot,
|
||||
displayedPositionMs: Long,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
resizeMode: PlayerResizeMode,
|
||||
onBack: () -> Unit,
|
||||
onTogglePlayback: () -> Unit,
|
||||
onSeekBack: () -> Unit,
|
||||
onSeekForward: () -> Unit,
|
||||
onResizeModeClick: () -> Unit,
|
||||
onSpeedClick: () -> Unit,
|
||||
onScrubChange: (Long) -> Unit,
|
||||
onScrubFinished: (Long) -> Unit,
|
||||
horizontalSafePadding: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(160.dp)
|
||||
.align(Alignment.TopCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.7f),
|
||||
Color.Transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.7f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = horizontalSafePadding),
|
||||
) {
|
||||
PlayerHeader(
|
||||
title = title,
|
||||
streamTitle = streamTitle,
|
||||
providerName = providerName,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
backendLabel = "Android · Media3 ExoPlayer",
|
||||
metrics = metrics,
|
||||
onBack = onBack,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))
|
||||
.padding(horizontal = metrics.horizontalPadding, vertical = metrics.verticalPadding),
|
||||
)
|
||||
|
||||
CenterControls(
|
||||
snapshot = playbackSnapshot,
|
||||
metrics = metrics,
|
||||
onSeekBack = onSeekBack,
|
||||
onSeekForward = onSeekForward,
|
||||
onTogglePlayback = onTogglePlayback,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(bottom = metrics.centerLift),
|
||||
)
|
||||
|
||||
ProgressControls(
|
||||
playbackSnapshot = playbackSnapshot,
|
||||
displayedPositionMs = displayedPositionMs,
|
||||
metrics = metrics,
|
||||
resizeMode = resizeMode,
|
||||
onScrubChange = onScrubChange,
|
||||
onScrubFinished = onScrubFinished,
|
||||
onResizeModeClick = onResizeModeClick,
|
||||
onSpeedClick = onSpeedClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = metrics.horizontalPadding)
|
||||
.padding(bottom = metrics.sliderBottomOffset),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerHeader(
|
||||
title: String,
|
||||
streamTitle: String,
|
||||
providerName: String,
|
||||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
episodeTitle: String?,
|
||||
backendLabel: String,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val typeScale = MaterialTheme.nuvioTypeScale
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = typeScale.titleLg.copy(
|
||||
fontSize = metrics.titleSize,
|
||||
lineHeight = metrics.titleSize * 1.16f,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = "S${seasonNumber}E${episodeNumber} • $episodeTitle",
|
||||
style = typeScale.bodyMd.copy(
|
||||
fontSize = metrics.episodeInfoSize,
|
||||
lineHeight = metrics.episodeInfoSize * 1.3f,
|
||||
),
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = streamTitle,
|
||||
style = typeScale.labelSm.copy(
|
||||
fontSize = metrics.metadataSize,
|
||||
lineHeight = metrics.metadataSize * 1.25f,
|
||||
),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = providerName,
|
||||
style = typeScale.labelSm.copy(
|
||||
fontSize = metrics.metadataSize,
|
||||
lineHeight = metrics.metadataSize * 1.25f,
|
||||
fontStyle = FontStyle.Italic,
|
||||
),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = backendLabel,
|
||||
style = typeScale.labelXs,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.35f))
|
||||
.clickable(onClick = onBack)
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = "Close player",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(metrics.headerIconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterControls(
|
||||
snapshot: PlayerPlaybackSnapshot,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
onSeekBack: () -> Unit,
|
||||
onSeekForward: () -> Unit,
|
||||
onTogglePlayback: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(metrics.centerGap),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SideControlButton(
|
||||
icon = Icons.Rounded.Replay10,
|
||||
contentDescription = "Seek backward 10 seconds",
|
||||
metrics = metrics,
|
||||
onClick = onSeekBack,
|
||||
)
|
||||
PlayPauseControlButton(
|
||||
isPlaying = snapshot.isPlaying,
|
||||
isBuffering = snapshot.isLoading,
|
||||
metrics = metrics,
|
||||
onClick = onTogglePlayback,
|
||||
)
|
||||
SideControlButton(
|
||||
icon = Icons.Rounded.Forward10,
|
||||
contentDescription = "Seek forward 10 seconds",
|
||||
metrics = metrics,
|
||||
onClick = onSeekForward,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SideControlButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
contentDescription: String,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.28f))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(metrics.sideButtonPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(metrics.sideIconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayPauseControlButton(
|
||||
isPlaying: Boolean,
|
||||
isBuffering: Boolean,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.28f))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(metrics.playButtonPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isBuffering) {
|
||||
CircularProgressIndicator(
|
||||
color = Color.White,
|
||||
strokeWidth = 3.dp,
|
||||
modifier = Modifier.size(metrics.playIconSize),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(metrics.playIconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressControls(
|
||||
playbackSnapshot: PlayerPlaybackSnapshot,
|
||||
displayedPositionMs: Long,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
resizeMode: PlayerResizeMode,
|
||||
onScrubChange: (Long) -> Unit,
|
||||
onScrubFinished: (Long) -> Unit,
|
||||
onResizeModeClick: () -> Unit,
|
||||
onSpeedClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
|
||||
Column(modifier = modifier) {
|
||||
Slider(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(metrics.sliderTouchHeight)
|
||||
.graphicsLayer(scaleY = metrics.sliderScaleY),
|
||||
value = displayedPositionMs.coerceIn(0L, durationMs).toFloat(),
|
||||
onValueChange = { value -> onScrubChange(value.toLong()) },
|
||||
onValueChangeFinished = { onScrubFinished(displayedPositionMs.coerceIn(0L, durationMs)) },
|
||||
valueRange = 0f..durationMs.toFloat(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp)
|
||||
.padding(top = 4.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TimePill(text = formatPlaybackTime(displayedPositionMs), fontSize = metrics.timeSize)
|
||||
TimePill(text = formatPlaybackTime(durationMs), fontSize = metrics.timeSize)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Surface(
|
||||
color = Color.Black.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
modifier = Modifier.border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PlayerActionPillButton(
|
||||
icon = Icons.Rounded.AspectRatio,
|
||||
label = resizeMode.label,
|
||||
onClick = onResizeModeClick,
|
||||
)
|
||||
PlayerActionPillButton(
|
||||
icon = Icons.Rounded.Speed,
|
||||
label = "${playbackSnapshot.playbackSpeed}x",
|
||||
onClick = onSpeedClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimePill(
|
||||
text: String,
|
||||
fontSize: androidx.compose.ui.unit.TextUnit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.2f), RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.nuvioTypeScale.labelSm.copy(
|
||||
fontSize = fontSize,
|
||||
lineHeight = fontSize * 1.25f,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerActionPillButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.nuvioTypeScale.labelSm,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OpeningOverlay(
|
||||
artwork: String?,
|
||||
logo: String?,
|
||||
onBack: () -> Unit,
|
||||
horizontalSafePadding: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.85f)),
|
||||
) {
|
||||
if (artwork != null) {
|
||||
AsyncImage(
|
||||
model = artwork,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.3f),
|
||||
Color.Black.copy(alpha = 0.6f),
|
||||
Color.Black.copy(alpha = 0.8f),
|
||||
Color.Black.copy(alpha = 0.9f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))
|
||||
.padding(top = 20.dp, start = horizontalSafePadding, end = horizontalSafePadding + 20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.3f))
|
||||
.clickable(onClick = onBack)
|
||||
.padding(10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = "Close player",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (logo != null) {
|
||||
AsyncImage(
|
||||
model = logo,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(180.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
color = Color(0xFFE50914),
|
||||
strokeWidth = 3.dp,
|
||||
modifier = Modifier.size(54.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GestureFeedbackPill(
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color.Black.copy(alpha = 0.75f))
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.White.copy(alpha = 0.15f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Speed,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.nuvioTypeScale.bodyLg.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PauseMetadataOverlay(
|
||||
title: String,
|
||||
isEpisode: Boolean,
|
||||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
episodeTitle: String?,
|
||||
streamSubtitle: String?,
|
||||
providerName: String,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
horizontalSafePadding: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.85f),
|
||||
Color.Black.copy(alpha = 0.45f),
|
||||
Color.Transparent,
|
||||
),
|
||||
),
|
||||
)
|
||||
.windowInsetsPadding(WindowInsets.safeContent)
|
||||
.padding(
|
||||
start = 24.dp + horizontalSafePadding,
|
||||
end = 24.dp + horizontalSafePadding,
|
||||
top = 24.dp,
|
||||
bottom = 24.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
) {
|
||||
Text(
|
||||
text = "You're watching",
|
||||
style = MaterialTheme.nuvioTypeScale.bodyLg,
|
||||
color = Color(0xFFB8B8B8),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.nuvioTypeScale.displayMd.copy(
|
||||
fontSize = max(metrics.titleSize.value * 1.8f, 32f).sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
),
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val episodeInfo = if (isEpisode && seasonNumber != null && episodeNumber != null) {
|
||||
buildString {
|
||||
append("S")
|
||||
append(seasonNumber)
|
||||
append(" • E")
|
||||
append(episodeNumber)
|
||||
if (!episodeTitle.isNullOrBlank()) {
|
||||
append(" • ")
|
||||
append(episodeTitle)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
providerName
|
||||
}
|
||||
Text(
|
||||
text = episodeInfo,
|
||||
style = MaterialTheme.nuvioTypeScale.bodyLg,
|
||||
color = Color(0xFFCCCCCC),
|
||||
)
|
||||
if (!streamSubtitle.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = streamSubtitle,
|
||||
style = MaterialTheme.nuvioTypeScale.bodyLg.copy(lineHeight = 24.sp),
|
||||
color = Color(0xFFD6D6D6),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun playerHorizontalSafePadding(): Dp {
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val safePadding = WindowInsets.safeContent.asPaddingValues()
|
||||
val left = safePadding.calculateLeftPadding(layoutDirection)
|
||||
val right = safePadding.calculateRightPadding(layoutDirection)
|
||||
return if (left > right) left else right
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorModal(
|
||||
message: String,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.7f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(0.8f)
|
||||
.widthIn(max = 400.dp),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = Color(0xFF1A1A1A),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFFEF4444).copy(alpha = 0.1f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ErrorOutline,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFEF4444),
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Playback error",
|
||||
style = MaterialTheme.nuvioTypeScale.titleMd.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.White,
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.nuvioTypeScale.bodyLg.copy(lineHeight = 22.sp),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onDismiss),
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Dismiss",
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
style = MaterialTheme.nuvioTypeScale.bodyLg.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.Black,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class PlayerLayoutMetrics(
|
||||
val horizontalPadding: Dp,
|
||||
val verticalPadding: Dp,
|
||||
val titleSize: TextUnit,
|
||||
val episodeInfoSize: TextUnit,
|
||||
val metadataSize: TextUnit,
|
||||
val centerGap: Dp,
|
||||
val centerLift: Dp,
|
||||
val sliderBottomOffset: Dp,
|
||||
val sliderTouchHeight: Dp,
|
||||
val sliderScaleY: Float,
|
||||
val timeSize: TextUnit,
|
||||
val headerIconSize: Dp,
|
||||
val sideButtonPadding: Dp,
|
||||
val sideIconSize: Dp,
|
||||
val playButtonPadding: Dp,
|
||||
val playIconSize: Dp,
|
||||
) {
|
||||
companion object {
|
||||
fun fromWidth(width: Dp): PlayerLayoutMetrics =
|
||||
when {
|
||||
width >= 1440.dp -> PlayerLayoutMetrics(
|
||||
horizontalPadding = 28.dp,
|
||||
verticalPadding = 24.dp,
|
||||
titleSize = 28.dp.value.sp,
|
||||
episodeInfoSize = 16.dp.value.sp,
|
||||
metadataSize = 14.dp.value.sp,
|
||||
centerGap = 112.dp,
|
||||
centerLift = 24.dp,
|
||||
sliderBottomOffset = 28.dp,
|
||||
sliderTouchHeight = 28.dp,
|
||||
sliderScaleY = 0.72f,
|
||||
timeSize = 14.dp.value.sp,
|
||||
headerIconSize = 24.dp,
|
||||
sideButtonPadding = 14.dp,
|
||||
sideIconSize = 34.dp,
|
||||
playButtonPadding = 18.dp,
|
||||
playIconSize = 44.dp,
|
||||
)
|
||||
width >= 1024.dp -> PlayerLayoutMetrics(
|
||||
horizontalPadding = 24.dp,
|
||||
verticalPadding = 20.dp,
|
||||
titleSize = 24.dp.value.sp,
|
||||
episodeInfoSize = 15.dp.value.sp,
|
||||
metadataSize = 13.dp.value.sp,
|
||||
centerGap = 88.dp,
|
||||
centerLift = 18.dp,
|
||||
sliderBottomOffset = 24.dp,
|
||||
sliderTouchHeight = 26.dp,
|
||||
sliderScaleY = 0.74f,
|
||||
timeSize = 13.dp.value.sp,
|
||||
headerIconSize = 22.dp,
|
||||
sideButtonPadding = 13.dp,
|
||||
sideIconSize = 32.dp,
|
||||
playButtonPadding = 16.dp,
|
||||
playIconSize = 42.dp,
|
||||
)
|
||||
width >= 768.dp -> PlayerLayoutMetrics(
|
||||
horizontalPadding = 20.dp,
|
||||
verticalPadding = 16.dp,
|
||||
titleSize = 22.dp.value.sp,
|
||||
episodeInfoSize = 14.dp.value.sp,
|
||||
metadataSize = 12.dp.value.sp,
|
||||
centerGap = 72.dp,
|
||||
centerLift = 14.dp,
|
||||
sliderBottomOffset = 20.dp,
|
||||
sliderTouchHeight = 24.dp,
|
||||
sliderScaleY = 0.78f,
|
||||
timeSize = 12.dp.value.sp,
|
||||
headerIconSize = 20.dp,
|
||||
sideButtonPadding = 12.dp,
|
||||
sideIconSize = 30.dp,
|
||||
playButtonPadding = 15.dp,
|
||||
playIconSize = 38.dp,
|
||||
)
|
||||
else -> PlayerLayoutMetrics(
|
||||
horizontalPadding = 20.dp,
|
||||
verticalPadding = 16.dp,
|
||||
titleSize = 18.dp.value.sp,
|
||||
episodeInfoSize = 14.dp.value.sp,
|
||||
metadataSize = 12.dp.value.sp,
|
||||
centerGap = 56.dp,
|
||||
centerLift = 10.dp,
|
||||
sliderBottomOffset = 16.dp,
|
||||
sliderTouchHeight = 22.dp,
|
||||
sliderScaleY = 0.82f,
|
||||
timeSize = 12.dp.value.sp,
|
||||
headerIconSize = 20.dp,
|
||||
sideButtonPadding = 10.dp,
|
||||
sideIconSize = 26.dp,
|
||||
playButtonPadding = 13.dp,
|
||||
playIconSize = 34.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PlayerResizeMode.next(): PlayerResizeMode =
|
||||
when (this) {
|
||||
PlayerResizeMode.Fit -> PlayerResizeMode.Fill
|
||||
PlayerResizeMode.Fill -> PlayerResizeMode.Zoom
|
||||
PlayerResizeMode.Zoom -> PlayerResizeMode.Fit
|
||||
}
|
||||
|
||||
private val PlayerResizeMode.label: String
|
||||
get() = when (this) {
|
||||
PlayerResizeMode.Fit -> "Fit"
|
||||
PlayerResizeMode.Fill -> "Fill"
|
||||
PlayerResizeMode.Zoom -> "Zoom"
|
||||
}
|
||||
|
||||
private fun formatPlaybackTime(positionMs: Long): String {
|
||||
val totalSeconds = (positionMs / 1000L).coerceAtLeast(0L)
|
||||
val seconds = totalSeconds % 60
|
||||
val minutes = (totalSeconds / 60) % 60
|
||||
val hours = totalSeconds / 3600
|
||||
return if (hours > 0) {
|
||||
"${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}"
|
||||
} else {
|
||||
"${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue