add chat basics
This commit is contained in:
+215
@@ -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
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
}
|
||||
+57
@@ -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
|
||||
}
|
||||
+115
@@ -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>
|
||||
+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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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()
|
||||
}
|
||||
}
|
||||
+17
@@ -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()
|
||||
}
|
||||
+11
@@ -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")
|
||||
}
|
||||
}
|
||||
+139
@@ -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) {
|
||||
}
|
||||
}
|
||||
+78
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user