add chat basics

This commit is contained in:
2026-07-28 20:45:30 +03:00
parent 1f90bf67ab
commit 644bcf9895
72 changed files with 323 additions and 14 deletions
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M240,880Q207,880 183.5,856.5Q160,833 160,800L160,400Q160,367 183.5,343.5Q207,320 240,320L280,320L280,240Q280,157 338.5,98.5Q397,40 480,40Q563,40 621.5,98.5Q680,157 680,240L680,320L720,320Q753,320 776.5,343.5Q800,367 800,400L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,400Q720,400 720,400Q720,400 720,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800ZM480,680Q513,680 536.5,656.5Q560,633 560,600Q560,567 536.5,543.5Q513,520 480,520Q447,520 423.5,543.5Q400,567 400,600Q400,633 423.5,656.5Q447,680 480,680ZM360,320L600,320L600,240Q600,190 565,155Q530,120 480,120Q430,120 395,155Q360,190 360,240L360,320ZM240,800Q240,800 240,800Q240,800 240,800L240,400Q240,400 240,400Q240,400 240,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M240,320L600,320L600,240Q600,190 565,155Q530,120 480,120Q430,120 395,155Q360,190 360,240L280,240Q280,157 338.5,98.5Q397,40 480,40Q563,40 621.5,98.5Q680,157 680,240L680,320L720,320Q753,320 776.5,343.5Q800,367 800,400L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880Q207,880 183.5,856.5Q160,833 160,800L160,400Q160,367 183.5,343.5Q207,320 240,320ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,400Q720,400 720,400Q720,400 720,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800ZM480,680Q513,680 536.5,656.5Q560,633 560,600Q560,567 536.5,543.5Q513,520 480,520Q447,520 423.5,543.5Q400,567 400,600Q400,633 423.5,656.5Q447,680 480,680ZM240,800Q240,800 240,800Q240,800 240,800L240,400Q240,400 240,400Q240,400 240,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M400,840Q334,840 287,793Q240,746 240,680Q240,614 287,567Q334,520 400,520Q423,520 442.5,525.5Q462,531 480,542L480,120L720,120L720,280L560,280L560,680Q560,746 513,793Q466,840 400,840Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M520,760L520,200L760,200L760,760L520,760ZM200,760L200,200L440,200L440,760L200,760ZM600,680L680,680L680,280L600,280L600,680ZM280,680L360,680L360,280L280,280L280,680ZM280,280L280,280L280,680L280,680L280,280ZM600,280L600,280L600,680L600,680L600,280Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M480,880Q405,880 339.5,851.5Q274,823 225.5,774.5Q177,726 148.5,660.5Q120,595 120,520L200,520Q200,637 281.5,718.5Q363,800 480,800Q597,800 678.5,718.5Q760,637 760,520Q760,403 678.5,321.5Q597,240 480,240L474,240L536,302L480,360L320,200L480,40L536,98L474,160L480,160Q555,160 620.5,188.5Q686,217 734.5,265.5Q783,314 811.5,379.5Q840,445 840,520Q840,595 811.5,660.5Q783,726 734.5,774.5Q686,823 620.5,851.5Q555,880 480,880Z" />
</vector>
@@ -0,0 +1,6 @@
<resources>
<string name="player_error_title">Ошибка плеера</string>
<string name="player_error_summary">Во время проигрывания видео произошла ошибка: %1$s</string>
<string name="player_retry">Повторить</string>
<string name="player_audio_track">Аудио %1$d</string>
</resources>
@@ -0,0 +1,9 @@
package ru.shadowsparky.videoplayer
data class AudioTrackInfo(
val id: String,
val groupIndex: Int,
val trackIndex: Int,
val description: String,
val isSelected: Boolean
)
@@ -0,0 +1,5 @@
package ru.shadowsparky.videoplayer
sealed class CanvasEvent {
data object HideControls : CanvasEvent()
}
@@ -0,0 +1,7 @@
package ru.shadowsparky.videoplayer
import androidx.compose.runtime.MutableState
class ControlsState(
val isLocked: MutableState<Boolean>
)
@@ -0,0 +1,5 @@
package ru.shadowsparky.videoplayer
interface ExternalPlayer {
fun play(url: String)
}
@@ -0,0 +1,19 @@
package ru.shadowsparky.videoplayer
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface PlayerController {
val state: StateFlow<PlayerState>
val events: Flow<PlayerEvent>
val audioTracks: StateFlow<List<AudioTrackInfo>>
suspend fun load(url: String, startPositionMs: Long = 0L)
suspend fun play()
suspend fun pause()
suspend fun seekTo(positionMs: Long)
suspend fun selectAudioTrack(id: String)
suspend fun release()
}
@@ -0,0 +1,8 @@
package ru.shadowsparky.videoplayer
sealed interface PlayerEvent {
data object Idle : PlayerEvent
data object Playing : PlayerEvent
data class Error(val message: String) : PlayerEvent
data object Ended : PlayerEvent
}
@@ -0,0 +1,18 @@
package ru.shadowsparky.videoplayer
data class PlayerState(
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val isEnded: Boolean = false,
val durationMs: Long = 0L,
val currentPositionMs: Long = 0L,
val bufferedPositionMs: Long = 0L,
val videoWidth: Int = 0,
val videoHeight: Int = 0,
val volume: Float = 1.0f,
val videoUrl: String? = null
)
@@ -0,0 +1,10 @@
package ru.shadowsparky.videoplayer
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
@Module
@Configuration
@ComponentScan
class VideoPlayerModule
@@ -0,0 +1,7 @@
package ru.shadowsparky.videoplayer
enum class VideoScaleType {
FIT,
FULL,
STRETCH
}
@@ -0,0 +1,403 @@
package ru.shadowsparky.videoplayer.ui
import androiddev.libs.video_player.generated.resources.Res
import androiddev.libs.video_player.generated.resources.lock_24px
import androiddev.libs.video_player.generated.resources.lock_open_24px
import androiddev.libs.video_player.generated.resources.music_note_24px
import androiddev.libs.video_player.generated.resources.pause_24px
import androiddev.libs.video_player.generated.resources.play_arrow_24px
import androiddev.libs.video_player.generated.resources.replay_24px
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import ru.shadowsparky.ui.components.isTv
import ru.shadowsparky.videoplayer.AudioTrackInfo
import ru.shadowsparky.videoplayer.ControlsState
import ru.shadowsparky.videoplayer.PlayerController
import ru.shadowsparky.videoplayer.PlayerState
import kotlin.time.Duration.Companion.milliseconds
private const val DEF_SEEK_TIME = 10L
@Composable
fun DefaultControls(
state: PlayerState,
controlsState: ControlsState,
controller: PlayerController,
modifier: Modifier = Modifier
) {
val isTv = isTv()
val scope = rememberCoroutineScope()
val audioTracksState by controller.audioTracks.collectAsState()
val isUiLocked = controlsState.isLocked
var isDragging by rememberSaveable { mutableStateOf(false) }
var sliderPosition by rememberSaveable { mutableStateOf(0f) }
val currentUiPosition = if (isDragging) sliderPosition else state.currentPositionMs.toFloat()
val totalDuration = state.durationMs.toFloat().coerceAtLeast(1f)
val focus = remember { FocusContainer() }
Box(
modifier = modifier
.fillMaxSize()
.background(if (isUiLocked.value) Color.Transparent else Color.Black.copy(alpha = 0.4f))
) {
SideDoubleTapSeekZones(controller)
Row(modifier = Modifier.align(Alignment.TopEnd).focusGroup().padding(8.dp)) {
if (audioTracksState.isNotEmpty() && !isUiLocked.value) {
AudioTracksButton(
focus,
tracks = audioTracksState,
onSelect = { id ->
scope.launch { controller.selectAudioTrack(id) }
}
)
}
if (!isTv) {
LockUiButton(focus, isUiLocked.value) { isUiLocked.value = it }
}
}
if (!isUiLocked.value) {
CenterControls(focus, state, controller)
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(16.dp)
.controlsBackground()
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = formatDuration(currentUiPosition.toLong()),
color = Color.White,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = formatDuration(state.durationMs),
color = Color.White,
style = MaterialTheme.typography.bodyMedium
)
}
Slider(
value = currentUiPosition,
valueRange = 0f..totalDuration,
onValueChange = { newPos ->
isDragging = true
sliderPosition = newPos
},
onValueChangeFinished = {
isDragging = false
scope.launch {
controller.seekTo(sliderPosition.toLong())
}
},
modifier = Modifier.fillMaxWidth()
.focusableWithBorder(focus.slider)
.onPreviewKeyEvent {
if (it.type == KeyEventType.KeyDown) {
when (it.key) {
Key.DirectionLeft -> {
scope.launch { controller.seek(true) }
}
Key.DirectionRight -> {
scope.launch { controller.seek(false) }
}
Key.DirectionUp -> focus.play.requestFocus()
Key.DirectionDown -> focus.audioTrack.requestFocus()
else -> return@onPreviewKeyEvent false
}
}
true
}
)
}
}
}
}
@Composable
private fun LockUiButton(
focusContainer: FocusContainer,
state: Boolean,
onChanged: (Boolean) -> Unit
) {
IconButton(
onClick = { onChanged(!state) },
modifier = Modifier.focusableWithBorder(focusContainer.lock)
) {
val icon = if (state) Res.drawable.lock_24px else Res.drawable.lock_open_24px
Icon(painterResource(icon), null, tint = Color.White)
}
}
@Composable
private fun AudioTracksButton(
focusContainer: FocusContainer,
tracks: List<AudioTrackInfo>,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
val tracks = tracks.map { track -> track to FocusRequester() }
IconButton(
onClick = { expanded = true },
modifier = Modifier.focusableWithBorder(focusContainer.audioTrack)
) {
Icon(
painter = painterResource(Res.drawable.music_note_24px),
contentDescription = "Audio tracks",
tint = Color.White
)
}
LaunchedEffect(expanded) {
if (expanded) {
delay(500.milliseconds)
tracks.firstOrNull()?.second?.requestFocus()
}
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.focusable()
) {
for (i in tracks.indices) {
val (track, requester) = tracks[i]
DropdownMenuItem(
text = {
Text(
text = if (track.isSelected)
"● ${track.description}"
else
track.description
)
},
onClick = {
expanded = false
onSelect(track.id)
},
modifier = Modifier.focusableWithBorder(requester)
)
}
}
}
}
@Composable
private fun BoxScope.SideDoubleTapSeekZones(controller: PlayerController, enabled: Boolean = true) {
val scope = rememberCoroutineScope()
if (!enabled) return
Row(modifier = Modifier.matchParentSize()) {
Box(
modifier = Modifier
.fillMaxHeight()
.weight(0.3f)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
scope.launch { controller.seek(true) }
}
)
}
)
Spacer(modifier = Modifier.fillMaxHeight().weight(0.3f))
Box(
modifier = Modifier
.fillMaxHeight()
.weight(0.3f)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
scope.launch {
controller.seek(false)
}
}
)
}
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun BoxScope.CenterControls(
focusContainer: FocusContainer,
state: PlayerState,
controller: PlayerController
) {
val scope = rememberCoroutineScope()
var canRequestFocus by remember { mutableStateOf(false) }
Box(
modifier = Modifier.align(Alignment.Center)
.focusGroup()
.controlsBackground()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
when {
state.isBuffering -> CircularWavyProgressIndicator()
state.isEnded -> {
IconButton(
onClick = {
scope.launch {
controller.seekTo(0)
controller.play()
}
}
) {
Icon(
painter = painterResource(Res.drawable.replay_24px),
contentDescription = "Replay",
modifier = Modifier.size(64.dp),
tint = Color.White
)
}
}
else -> {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
IconButton(
onClick = {
scope.launch {
if (state.isPlaying) controller.pause() else controller.play()
}
}, modifier = Modifier.focusableWithBorder(focusContainer.play)
) {
Icon(
painter = painterResource(
if (state.isPlaying)
Res.drawable.pause_24px
else
Res.drawable.play_arrow_24px
),
contentDescription = "Play/Pause",
modifier = Modifier.size(64.dp),
tint = Color.White
)
}
}
canRequestFocus = true
}
}
}
LaunchedEffect(state.videoUrl) { canRequestFocus = false }
LaunchedEffect(canRequestFocus) {
if (canRequestFocus) {
withFrameNanos {}
focusContainer.play.requestFocus()
}
}
}
private suspend fun PlayerController.seek(minus: Boolean) {
val state = state.value
val newPos = if (minus) {
(state.currentPositionMs - DEF_SEEK_TIME * 1_000)
.coerceAtLeast(0L)
} else {
(state.currentPositionMs + DEF_SEEK_TIME * 1_000)
.coerceAtMost(state.durationMs)
}
seekTo(newPos)
}
private fun Modifier.controlsBackground(): Modifier {
return background(Color.Black.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
}
private fun formatDuration(millis: Long): String {
if (millis < 0) return "00:00:00"
val totalSeconds = millis / 1000
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
val h = hours.toString().padStart(2, '0')
val m = minutes.toString().padStart(2, '0')
val s = seconds.toString().padStart(2, '0')
return "$h:$m:$s"
}
private class FocusContainer(
val audioTrack: FocusRequester = FocusRequester(),
val lock: FocusRequester = FocusRequester(),
val play: FocusRequester = FocusRequester(),
val slider: FocusRequester = FocusRequester()
)
@Composable
private fun Modifier.focusableWithBorder(requester: FocusRequester): Modifier {
var isFocused by remember { mutableStateOf(false) }
return this.focusRequester(requester)
.onFocusChanged { isFocused = it.isFocused }
.border(
width = 2.dp,
color = if (isFocused) {
MaterialTheme.colorScheme.primary
} else {
Color.Transparent
},
shape = RoundedCornerShape(24.dp)
)
}
@@ -0,0 +1,15 @@
package ru.shadowsparky.videoplayer.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import ru.shadowsparky.videoplayer.CanvasEvent
import ru.shadowsparky.videoplayer.PlayerController
import ru.shadowsparky.videoplayer.VideoScaleType
@Composable
expect fun VideoCanvas(
controller: PlayerController,
modifier: Modifier,
scaleType: VideoScaleType = VideoScaleType.FIT,
onCanvasEvent: (CanvasEvent) -> Unit
)
@@ -0,0 +1,183 @@
package ru.shadowsparky.videoplayer.ui
import androiddev.libs.video_player.generated.resources.Res
import androiddev.libs.video_player.generated.resources.player_error_summary
import androiddev.libs.video_player.generated.resources.player_error_title
import androiddev.libs.video_player.generated.resources.player_retry
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.components.isTv
import ru.shadowsparky.videoplayer.CanvasEvent
import ru.shadowsparky.videoplayer.ControlsState
import ru.shadowsparky.videoplayer.PlayerController
import ru.shadowsparky.videoplayer.PlayerEvent
import ru.shadowsparky.videoplayer.PlayerState
@Composable
fun VideoPlayer(
controller: PlayerController,
modifier: Modifier = Modifier,
overlayContent: @Composable (PlayerState, ControlsState) -> Unit = { player, controls ->
DefaultControls(player, controls, controller = controller)
}
) {
val isTv = isTv()
val state by controller.state.collectAsState()
val scope = rememberCoroutineScope()
val controlsState by remember {
mutableStateOf(ControlsState(mutableStateOf(false)))
}
var areControlsVisible by remember { mutableStateOf(true) }
var lastErrorMessage by remember { mutableStateOf<String?>(null) }
var interactionCount by remember { mutableStateOf(0) }
LaunchedEffect(controller) {
controller.events.collect { event ->
when (event) {
is PlayerEvent.Error -> {
lastErrorMessage = event.message
areControlsVisible = true
}
is PlayerEvent.Ended -> {
areControlsVisible = true
}
is PlayerEvent.Idle, is PlayerEvent.Playing -> {
lastErrorMessage = null
areControlsVisible = true
}
}
}
}
LaunchedEffect(areControlsVisible, state.isPlaying, lastErrorMessage, interactionCount) {
if (areControlsVisible && state.isPlaying && lastErrorMessage == null) {
delay(5000)
areControlsVisible = false
}
}
val rootModifier = if (isTv) {
modifier.focusable(enabled = !areControlsVisible).onKeyEvent {
if (it.type == KeyEventType.KeyUp) {
areControlsVisible = true
interactionCount++
}
false
}
} else {
modifier.clickable {
areControlsVisible = !areControlsVisible
}
}
Box(modifier = rootModifier.background(Color.Black)) {
VideoCanvas(
controller = controller,
modifier = rootModifier.fillMaxSize(),
onCanvasEvent = {
areControlsVisible = when (it) {
CanvasEvent.HideControls -> false
}
}
)
if (lastErrorMessage != null) {
PlaybackError(lastErrorMessage!!, scope, state, controller) {
lastErrorMessage = null
}
} else {
AnimatedVisibility(
visible = areControlsVisible || !state.isPlaying,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.matchParentSize()
) { overlayContent(state, controlsState) }
}
}
}
@Composable
private fun PlaybackError(
lastErrorMessage: String,
scope: CoroutineScope,
state: PlayerState,
controller: PlayerController,
onReset: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.8f))
.clickable(enabled = false) {},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(16.dp)
) {
Text(
text = stringResource(Res.string.player_error_title),
style = MaterialTheme.typography.titleSmall,
color = Color.White
)
Text(
text = stringResource(Res.string.player_error_summary, lastErrorMessage),
style = MaterialTheme.typography.bodyMedium,
color = Color.LightGray,
modifier = Modifier.padding(top = 4.dp)
)
Spacer(Modifier.height(16.dp))
Button(
onClick = {
onReset()
scope.launch {
val currentUrl = state.videoUrl
val currentPos = state.currentPositionMs
if (currentUrl != null) {
controller.load(currentUrl, currentPos)
controller.play()
}
}
},
) {
Text(stringResource(Res.string.player_retry))
}
}
}
}