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,215 @@
package ru.shadowsparky.videoplayer
import android.content.Context
import androiddev.libs.video_player.generated.resources.Res
import androiddev.libs.video_player.generated.resources.player_audio_track
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Factory
import kotlin.time.Duration.Companion.milliseconds
@Factory
class AndroidPlayerController(context: Context) : PlayerController {
private val exoPlayer by lazy { ExoPlayer.Builder(context).build() }
private val _state = MutableStateFlow(PlayerState())
override val state: StateFlow<PlayerState> = _state.asStateFlow()
private val _events = Channel<PlayerEvent>(Channel.BUFFERED)
override val events: Flow<PlayerEvent> = _events.receiveAsFlow()
override val audioTracks = MutableStateFlow<List<AudioTrackInfo>>(emptyList())
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var progressJob: Job? = null
private var isListenerBound = false
private val listener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
updateState()
if (playbackState == Player.STATE_ENDED) {
_events.trySend(PlayerEvent.Ended)
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
updateState()
if (isPlaying) startProgressWatcher() else stopProgressWatcher()
}
private var trackJob: Job? = null
override fun onEvents(player: Player, events: Player.Events) {
updateState()
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
trackJob?.cancel()
trackJob = scope.launch { updateAudioTracks() }
}
}
override fun onPlayerError(error: PlaybackException) {
_events.trySend(PlayerEvent.Error(error.message ?: "Unknown ExoPlayer error"))
}
override fun onVideoSizeChanged(videoSize: VideoSize) {
_state.update {
it.copy(videoWidth = videoSize.width, videoHeight = videoSize.height)
}
}
}
init {
bindListenerIfNeeded(false)
}
private fun bindListenerIfNeeded(allowFail: Boolean) {
try {
if (isListenerBound) return
exoPlayer.addListener(listener)
isListenerBound = true
} catch (t: Throwable) {
if (allowFail) throw t
}
}
private var isTrackInitialized = false
@OptIn(UnstableApi::class)
private suspend fun updateAudioTracks() {
val player = exoPlayer
val tracks = player.currentTracks
val list = tracks.groups.mapIndexedNotNull { groupIndex, group ->
if (group.type != C.TRACK_TYPE_AUDIO) return@mapIndexedNotNull null
val trackGroup = group.mediaTrackGroup
if (trackGroup.length == 0) return@mapIndexedNotNull null
(0 until trackGroup.length).map { trackIndex ->
val desc = getString(Res.string.player_audio_track, groupIndex)
AudioTrackInfo(
id = "$groupIndex:$trackIndex",
groupIndex = groupIndex,
trackIndex = trackIndex,
description = desc.trim(),
isSelected = group.isTrackSelected(trackIndex)
)
}
}.flatten()
audioTracks.value = list
if (!isTrackInitialized && list.isNotEmpty()) {
isTrackInitialized = true
list.firstOrNull()?.let { selectAudioTrack(it.id) }
}
}
@OptIn(UnstableApi::class)
override suspend fun selectAudioTrack(id: String) = withContext(Dispatchers.Main) {
val parts = id.split(":")
if (parts.size != 2) return@withContext
val groupIndex = parts.first().toIntOrNull() ?: return@withContext
val trackIndex = parts.last().toIntOrNull() ?: return@withContext
val tracks = exoPlayer.currentTracks
val group = tracks.groups.getOrNull(groupIndex) ?: return@withContext
if (group.type != C.TRACK_TYPE_AUDIO) return@withContext
val trackGroup = group.mediaTrackGroup
if (trackIndex !in 0 until trackGroup.length) return@withContext
val override = TrackSelectionOverride(trackGroup, listOf(trackIndex))
exoPlayer.trackSelectionParameters =
exoPlayer.trackSelectionParameters
.buildUpon()
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
.addOverride(override)
.build()
updateAudioTracks()
}
override suspend fun load(url: String, startPositionMs: Long): Unit =
withContext(Dispatchers.Main) {
isTrackInitialized = false
bindListenerIfNeeded(true)
val mediaItem = MediaItem.fromUri(url)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
if (startPositionMs > 0) exoPlayer.seekTo(startPositionMs)
_state.update { it.copy(videoUrl = url) }
_events.trySend(PlayerEvent.Playing)
}
override suspend fun play() = withContext(Dispatchers.Main) {
exoPlayer.play()
}
override suspend fun pause() = withContext(Dispatchers.Main) {
exoPlayer.pause()
}
override suspend fun seekTo(positionMs: Long) = withContext(Dispatchers.Main) {
exoPlayer.seekTo(positionMs)
updateState()
}
override suspend fun release() = withContext(Dispatchers.Main) {
stopProgressWatcher()
exoPlayer.removeListener(listener)
exoPlayer.release()
scope.cancel()
}
private fun updateState() {
_state.update { s ->
s.copy(
isPlaying = exoPlayer.isPlaying,
isBuffering = exoPlayer.playbackState == Player.STATE_BUFFERING,
isEnded = exoPlayer.playbackState == Player.STATE_ENDED,
durationMs = exoPlayer.duration.coerceAtLeast(0),
currentPositionMs = exoPlayer.currentPosition.coerceAtLeast(0),
bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0),
volume = exoPlayer.volume
)
}
}
private fun startProgressWatcher() {
if (progressJob?.isActive == true) return
progressJob = scope.launch {
while (isActive) {
updateState()
delay(200.milliseconds)
}
}
}
private fun stopProgressWatcher() {
progressJob?.cancel()
progressJob = null
}
internal fun getPlayerInstance(): ExoPlayer = exoPlayer
}
@@ -0,0 +1,34 @@
package ru.shadowsparky.videoplayer
import android.app.Application
import android.content.Intent
import androidx.core.net.toUri
import org.koin.core.annotation.Factory
@Factory
class AndroidExternalPlayer(private val app: Application) : ExternalPlayer {
override fun play(url: String) {
if (!launchInternal(url, true)) {
if (!launchInternal(url, false)) {
error("Unable to launch video")
}
}
}
private fun launchInternal(uri: String, withType: Boolean): Boolean {
val intent = Intent(Intent.ACTION_VIEW)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (withType) {
intent.setDataAndType(uri.toUri(), "video/*")
} else {
intent.data = uri.toUri()
}
try {
app.startActivity(intent)
} catch (_: Exception) {
return false
}
return true
}
}
@@ -0,0 +1,57 @@
package ru.shadowsparky.videoplayer.ui
import android.app.PictureInPictureParams
import android.content.Context
import android.graphics.Rect
import android.os.Build
import android.util.Rational
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.util.Consumer
internal fun Context.updatePipAutoEnter(playerView: View, enabled: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
val w = if (playerView.width > 0) playerView.width else 16
val h = if (playerView.height > 0) playerView.height else 9
val rect = Rect()
playerView.getGlobalVisibleRect(rect)
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(w, h))
.setSourceRectHint(rect)
.setAutoEnterEnabled(enabled)
.build()
findActivity()?.setPictureInPictureParams(params)
}
@Composable
internal fun rememberPipMode(): State<Boolean> {
val ctx = LocalContext.current
val activity = ctx.findActivity() as? ComponentActivity
val state = remember { mutableStateOf(activity?.isInPictureInPictureMode == true) }
DisposableEffect(activity) {
if (activity == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return@DisposableEffect onDispose { }
}
val cb = Consumer<PictureInPictureModeChangedInfo> { info ->
state.value = info.isInPictureInPictureMode
}
activity.addOnPictureInPictureModeChangedListener(cb)
onDispose { activity.removeOnPictureInPictureModeChangedListener(cb) }
}
return state
}
@@ -0,0 +1,115 @@
package ru.shadowsparky.videoplayer.ui
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import ru.shadowsparky.videoplayer.AndroidPlayerController
import ru.shadowsparky.videoplayer.CanvasEvent
import ru.shadowsparky.videoplayer.PlayerController
import ru.shadowsparky.videoplayer.VideoScaleType
@OptIn(UnstableApi::class)
@Composable
actual fun VideoCanvas(
controller: PlayerController,
modifier: Modifier,
scaleType: VideoScaleType,
onCanvasEvent: (CanvasEvent) -> Unit
) {
val androidController = controller as? AndroidPlayerController
?: throw IllegalArgumentException("Controller must be AndroidPlayerController")
val context = LocalContext.current
val pipMode by rememberPipMode()
LaunchedEffect(pipMode) {
if (pipMode) onCanvasEvent(CanvasEvent.HideControls)
}
DisposableEffect(Unit) {
context.enterFullscreen()
onDispose {
context.exitFullscreen()
}
}
AndroidView(
modifier = modifier,
factory = { ctx ->
PlayerView(ctx).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
useController = false
resizeMode = when (scaleType) {
VideoScaleType.FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
VideoScaleType.FULL -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
VideoScaleType.STRETCH -> AspectRatioFrameLayout.RESIZE_MODE_FILL
}
keepScreenOn = true
context.updatePipAutoEnter(this, true)
}
},
update = { playerView ->
if (playerView.player != androidController.getPlayerInstance()) {
playerView.player = androidController.getPlayerInstance()
}
},
onRelease = { playerView ->
context.updatePipAutoEnter(playerView, false)
playerView.player = null
}
)
}
internal fun Context.findActivity(): Activity? {
var ctx = this
while (ctx is android.content.ContextWrapper) {
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
return null
}
private fun Context.enterFullscreen() {
val activity = findActivity() ?: return
val window = activity.window ?: return
WindowCompat.setDecorFitsSystemWindows(window, false)
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.hide(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
private fun Context.exitFullscreen() {
val activity = findActivity() ?: return
val window = activity.window ?: return
WindowCompat.setDecorFitsSystemWindows(window, true)
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.show(WindowInsetsCompat.Type.systemBars())
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
@@ -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))
}
}
}
}
@@ -0,0 +1,10 @@
package ru.shadowsparky.videoplayer
import org.koin.core.annotation.Factory
@Factory
class DesktopExternalPlayer : ExternalPlayer {
override fun play(url: String) {
throw NotImplementedError()
}
}
@@ -0,0 +1,17 @@
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
actual fun VideoCanvas(
controller: PlayerController,
modifier: Modifier,
scaleType: VideoScaleType,
onCanvasEvent: (CanvasEvent) -> Unit
) {
throw NotImplementedError()
}
@@ -0,0 +1,11 @@
package ru.shadowsparky.videoplayer
import kotlinx.browser.window
import org.koin.core.annotation.Factory
@Factory
class WasmExternalPlayer : ExternalPlayer {
override fun play(url: String) {
window.open(url, "_blank", "noreferrer")
}
}
@@ -0,0 +1,139 @@
package ru.shadowsparky.videoplayer
import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.await
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import org.koin.core.annotation.Factory
import org.w3c.dom.HTMLVideoElement
@Factory
class WasmPlayerController : PlayerController {
internal val videoElement = (document.createElement("video") as HTMLVideoElement).apply {
style.apply {
setProperty("position", "absolute")
setProperty("left", "0")
setProperty("top", "0")
setProperty("width", "100%")
setProperty("height", "100%")
setProperty("background-color", "black")
}
setAttribute("controls", "")
setAttribute("preload", "auto")
}
private val _state = MutableStateFlow(PlayerState())
override val state: StateFlow<PlayerState> = _state.asStateFlow()
private val _events = Channel<PlayerEvent>(Channel.BUFFERED)
override val events: Flow<PlayerEvent> = _events.receiveAsFlow()
override val audioTracks: StateFlow<List<AudioTrackInfo>> = MutableStateFlow(emptyList())
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
init {
setupListeners()
}
private fun setupListeners() {
videoElement.onplay = { updateState() }
videoElement.onpause = { updateState() }
videoElement.ontimeupdate = { updateState() }
videoElement.onwaiting = {
_state.update { it.copy(isBuffering = true) }
}
videoElement.onplaying = {
_state.update { it.copy(isBuffering = false) }
}
videoElement.onended = {
updateState()
_events.trySend(PlayerEvent.Ended)
}
videoElement.addEventListener("error") {
val err = videoElement.error?.toString() ?: "Unknown HTML5 Video Error"
_events.trySend(PlayerEvent.Error(err))
}
videoElement.onloadedmetadata = {
updateState()
_state.update {
it.copy(
videoWidth = videoElement.videoWidth,
videoHeight = videoElement.videoHeight
)
}
}
}
override suspend fun load(url: String, startPositionMs: Long) {
println("load $url called")
videoElement.src = url
videoElement.load()
if (startPositionMs > 0) {
videoElement.currentTime = startPositionMs / 1000.0
}
_state.update { it.copy(videoUrl = url) }
_events.trySend(PlayerEvent.Playing)
println("load $url end")
}
@OptIn(ExperimentalWasmJsInterop::class)
override suspend fun play() {
try {
println("play called")
videoElement.play().await<JsAny?>()
println("play end")
} catch (e: Throwable) {
println("Play failed (autoplay policy?): $e. src: ${videoElement?.src?.toString()}")
}
}
override suspend fun pause() {
videoElement.pause()
}
override suspend fun seekTo(positionMs: Long) {
videoElement.currentTime = positionMs / 1000.0
updateState()
}
override suspend fun release() {
videoElement.pause()
videoElement.src = ""
videoElement.remove()
scope.cancel()
}
private fun updateState() {
_state.update { s ->
s.copy(
isPlaying = !videoElement.paused && !videoElement.ended,
isBuffering = false,
isEnded = videoElement.ended,
durationMs = (videoElement.duration * 1000).toLong().coerceAtLeast(0),
currentPositionMs = (videoElement.currentTime * 1000).toLong().coerceAtLeast(0),
bufferedPositionMs = (videoElement.currentTime * 1000).toLong(),
volume = videoElement.volume.toFloat()
)
}
}
override suspend fun selectAudioTrack(id: String) {
}
}
@@ -0,0 +1,78 @@
package ru.shadowsparky.videoplayer.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import kotlinx.browser.document
import org.w3c.dom.HTMLElement
import ru.shadowsparky.videoplayer.CanvasEvent
import ru.shadowsparky.videoplayer.PlayerController
import ru.shadowsparky.videoplayer.VideoScaleType
import ru.shadowsparky.videoplayer.WasmPlayerController
private const val VIDEO_LAYER = "video-layer"
private const val ROOT_LAYER = "compose-root"
@Composable
actual fun VideoCanvas(
controller: PlayerController,
modifier: Modifier,
scaleType: VideoScaleType,
onCanvasEvent: (CanvasEvent) -> Unit
) {
val wasmController = controller as? WasmPlayerController ?: return
DisposableEffect(Unit) {
showVideoLayer(wasmController, scaleType)
onDispose {
hideVideoLayer(wasmController)
}
}
}
private fun showVideoLayer(
wasmController: WasmPlayerController,
scaleType: VideoScaleType
) {
setVideoLayer(true)
val videoLayer = document.getElementById(VIDEO_LAYER) as HTMLElement
val video = wasmController.videoElement
videoLayer.appendChild(video)
video.style.apply {
val fit = when (scaleType) {
VideoScaleType.FIT -> "contain"
VideoScaleType.FULL -> "cover"
VideoScaleType.STRETCH -> "fill"
}
setProperty("object-fit", fit)
}
}
private fun hideVideoLayer(wasmController: WasmPlayerController) {
val video = wasmController.videoElement
video.pause()
video.removeAttribute("src")
video.load()
setVideoLayer(false)
}
private fun setVideoLayer(isVideoEnabled: Boolean) {
val videoLayer = document.getElementById(VIDEO_LAYER) as HTMLElement
val composeRoot = document.getElementById(ROOT_LAYER) as HTMLElement
videoLayer.innerHTML = ""
videoLayer.setVisibility(isVideoEnabled)
composeRoot.setVisibility(!isVideoEnabled)
}
private fun HTMLElement.setVisibility(isVisible: Boolean) {
if (isVisible) {
style.setProperty("visibility", "visible")
style.removeProperty("pointer-events")
} else {
style.setProperty("visibility", "hidden")
style.setProperty("pointer-events", "none")
}
}