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:
tapframe 2026-03-11 23:30:12 +05:30
parent 143ab98a08
commit 0edea04d78
4 changed files with 928 additions and 866 deletions

View file

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

View file

@ -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')}"
}
}

View file

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

View file

@ -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')}"
}
}