add chat basics
This commit is contained in:
@@ -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>
|
||||
+9
@@ -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()
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.videoplayer
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
|
||||
class ControlsState(
|
||||
val isLocked: MutableState<Boolean>
|
||||
)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.videoplayer
|
||||
|
||||
interface ExternalPlayer {
|
||||
fun play(url: String)
|
||||
}
|
||||
+19
@@ -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
|
||||
)
|
||||
+10
@@ -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
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.videoplayer
|
||||
|
||||
enum class VideoScaleType {
|
||||
FIT,
|
||||
FULL,
|
||||
STRETCH
|
||||
}
|
||||
+403
@@ -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)
|
||||
)
|
||||
}
|
||||
+15
@@ -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
|
||||
)
|
||||
+183
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user