init projects

This commit is contained in:
2026-06-17 18:58:41 +03:00
parent 8543c4e241
commit 21a768edd5
505 changed files with 19998 additions and 93 deletions
@@ -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
}
@@ -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
}