init projects
This commit is contained in:
+213
@@ -0,0 +1,213 @@
|
||||
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
|
||||
|
||||
@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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user