Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d9af369
add video player feature with Picture-in-Picture support
AhmedKaram2 May 1, 2025
137af5d
full screen and pip with VideoPlayerActivity
AhmedKaram2 May 1, 2025
05a521d
enhance VideoPlayer.android.kt to remove duplicate
AhmedKaram2 May 1, 2025
8733447
resume video in case back from home screen
AhmedKaram2 May 1, 2025
cf2c0dc
implement auto pip and auto pip size based on video size
AhmedKaram2 May 1, 2025
17694ab
handle video continue between pip and fullscreen modes
AhmedKaram2 May 2, 2025
3dd1f49
remove unwanted event from Events.kt
AhmedKaram2 May 2, 2025
bf28425
remove pip icon in case pip mode and show again in full screen mode
AhmedKaram2 May 2, 2025
73cb624
keep launch mode as singleInstance instead of single top
AhmedKaram2 May 2, 2025
41b4d0a
try to handle multi instance of activity creating when move to pip mo…
AhmedKaram2 May 2, 2025
9517541
Adding cast manager to start use it in video player manager
AhmedKaram2 May 2, 2025
dea1818
add cast provider using default key and modify classes to handle cast…
AhmedKaram2 May 2, 2025
e1be955
Change the cast icon and give it padding and enhance the implementati…
AhmedKaram2 May 5, 2025
bd3768f
Change the cast icon and give it padding and enhance the implementati…
AhmedKaram2 May 5, 2025
326e9b8
Implement subtitle and handle it's status between normal , pip and fu…
AhmedKaram2 May 7, 2025
456bc60
try to prevent multi app instance from creation and also handle pause…
AhmedKaram2 May 7, 2025
d53b7a2
fix problem of transition between video modes pip, normal and full sc…
AhmedKaram2 May 8, 2025
68717cc
Fix the problem of playing multi videos with pip option
AhmedKaram2 May 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions buildSrc/src/main/kotlin/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ object Dependencies {
const val HLS = "androidx.media3:media3-exoplayer-hls:${Versions.EXO_PLAYER}"
const val UI = "androidx.media3:media3-ui:${Versions.EXO_PLAYER}"
const val SESSION = "androidx.media3:media3-session:${Versions.EXO_PLAYER}"
const val MEDIA3_CAST = "androidx.media3:media3-cast:${Versions.EXO_PLAYER}"
}

object YoutubePlayer {
Expand Down Expand Up @@ -115,4 +116,6 @@ object Dependencies {
const val SHIMMER = "com.valentinilk.shimmer:compose-shimmer:${Versions.SHIMMER}"
const val KMP_NOTIFIER = "io.github.mirzemehdi:kmpnotifier:${Versions.KMP_NOTIFIER}"
const val STATELY_COMMON = "co.touchlab:stately-common:2.0.5"
const val MEDIA_ROUTER = "androidx.mediarouter:mediarouter:${Versions.MEDIA_ROUTER_VERSION}"
const val CAST_FRAMEWORK = "com.google.android.gms:play-services-cast-framework:${Versions.PLAY_SERVICES_CAST_VERSION}"
}
2 changes: 2 additions & 0 deletions buildSrc/src/main/kotlin/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ object Versions {
const val COMPOTTIE = "1.1.0"
const val WEBVIEW = "1.9.40-alpha04"
const val EXO_PLAYER = "1.3.1"
const val MEDIA_ROUTER_VERSION = "1.4.0"
const val PLAY_SERVICES_CAST_VERSION = "22.0.0"
const val GIT_LIVE = "1.10.4"
const val YOUTUBE_PLAYER = "12.1.0"
const val SHIMMER = "1.3.1"
Expand Down
5 changes: 5 additions & 0 deletions coreapp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ kotlin {
api(Dependencies.ExoPlayer.HLS)
api(Dependencies.ExoPlayer.UI)
api(Dependencies.ExoPlayer.SESSION)
api(Dependencies.ExoPlayer.MEDIA3_CAST)

// Cast dependencies
api(Dependencies.MEDIA_ROUTER)
api(Dependencies.CAST_FRAMEWORK)

// Youtube Player
api(Dependencies.YoutubePlayer.CORE)
Expand Down
17 changes: 15 additions & 2 deletions coreapp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
Expand All @@ -16,6 +17,15 @@
android:name="com.canhub.cropper.CropImageActivity"
android:theme="@style/Base.Theme.AppCompat" />

<activity
android:name="com.metacto.core.presentation.components.videoPlayer.VideoPlayerActivity"
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
android:launchMode="singleInstance"
android:screenOrientation="landscape"
android:supportsPictureInPicture="true"
android:theme="@style/VideoPlayer"
tools:ignore="DiscouragedApi" />

<receiver android:name="com.metacto.core.utils.notification.NotificationsReceiver" />

<provider
Expand All @@ -29,6 +39,9 @@
android:resource="@xml/file_paths" />
</provider>

</application>
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.metacto.core.presentation.components.videoPlayer.CastOptionsProvider" />

</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import com.metacto.strapikmm.repos.AppConfigurationRepository
import com.mmk.kmpnotifier.notification.NotifierManager
import com.mmk.kmpnotifier.notification.configuration.NotificationPlatformConfiguration
import dev.gitlive.firebase.Firebase
import com.metacto.core.presentation.components.videoPlayer.VideoPlayerEventBroadcaster
import dev.gitlive.firebase.remoteconfig.remoteConfig
import io.michaelrocks.libphonenumber.kotlin.MetadataLoader
import io.michaelrocks.libphonenumber.kotlin.metadata.source.AssetsMetadataLoader
Expand Down Expand Up @@ -73,6 +74,10 @@ actual fun <T : SerializableNetworkError> corePlatformModule(
EventBroadcaster
}

single {
VideoPlayerEventBroadcaster
}

single {
Firebase.remoteConfig
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package com.metacto.core.presentation.components.videoPlayer

import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.cast.CastPlayer
import androidx.media3.cast.SessionAvailabilityListener
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastSession
import com.google.android.gms.cast.framework.CastState
import com.google.android.gms.cast.framework.SessionManagerListener
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.Executors

@OptIn(UnstableApi::class)
class CastManager(private val context: Context) {

private var castPlayer: CastPlayer? = null
private var castContext: CastContext? = null
private var currentPlayer: Player? = null
private var localExoPlayer: ExoPlayer? = null

private var currentMediaItem: MediaItem? = null
private var playbackPosition: Long = 0
private var wasPlaying: Boolean = false

private val _castAvailable = MutableStateFlow(false)
val castAvailable: StateFlow<Boolean> = _castAvailable.asStateFlow()

private val _isCasting = MutableStateFlow(false)
val isCasting: StateFlow<Boolean> = _isCasting.asStateFlow()


private val sessionManagerListener = object : SessionManagerListener<CastSession> {
override fun onSessionStarting(session: CastSession) {
// Session is starting
}

override fun onSessionStarted(session: CastSession, sessionId: String) {
onCastSessionStarted()
}

override fun onSessionStartFailed(session: CastSession, error: Int) {
_isCasting.value = false
}

override fun onSessionEnding(session: CastSession) {
// Session is ending
}

override fun onSessionEnded(session: CastSession, error: Int) {
onCastSessionEnded()
}

override fun onSessionResuming(session: CastSession, sessionId: String) {
// Session is resuming
}

override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
onCastSessionStarted()
}

override fun onSessionResumeFailed(session: CastSession, error: Int) {
_isCasting.value = false
}

override fun onSessionSuspended(session: CastSession, reason: Int) {
_isCasting.value = false
}
}

init {
if (isGooglePlayServicesAvailable()) {
initializeCast()
} else {
_castAvailable.value = false
}
}

private fun initializeCast() {
val executor = Executors.newSingleThreadExecutor()
CastContext.getSharedInstance(context, executor)
.addOnSuccessListener { ctx ->
castContext = ctx

// Add listener for cast state changes
ctx.addCastStateListener { state ->
_castAvailable.value = state != CastState.NO_DEVICES_AVAILABLE
}

// Create cast player
castPlayer = CastPlayer(ctx).apply {
setSessionAvailabilityListener(object : SessionAvailabilityListener {
override fun onCastSessionAvailable() {
_isCasting.value = true
}

override fun onCastSessionUnavailable() {
_isCasting.value = false
}
})
}

// Add cast session manager listener
ctx.sessionManager.addSessionManagerListener(
sessionManagerListener,
CastSession::class.java
)

// Check initial cast state
_castAvailable.value = ctx.castState != CastState.NO_DEVICES_AVAILABLE

// Check if already casting
val castSession = ctx.sessionManager.currentCastSession
if (castSession != null && castSession.isConnected) {
onCastSessionStarted()
}
}
.addOnFailureListener {
_castAvailable.value = false
_isCasting.value = false
}
}

fun setupCastButton(mediaRouteButton: androidx.mediarouter.app.MediaRouteButton) {
castContext?.let {
CastButtonFactory.setUpMediaRouteButton(context, mediaRouteButton)
}
}

fun setLocalPlayer(exoPlayer: ExoPlayer) {
localExoPlayer = exoPlayer
// If we're not casting, set the current player to the local player
if (!_isCasting.value) {
currentPlayer = exoPlayer
}
}

private fun onCastSessionStarted() {
// Save the local player state
localExoPlayer?.let { exoPlayer ->
playbackPosition = exoPlayer.currentPosition
wasPlaying = exoPlayer.isPlaying
currentMediaItem = currentMediaItem ?: exoPlayer.currentMediaItem

// Pause local playback
exoPlayer.pause()
}

// Set current player to cast player
currentPlayer = castPlayer
_isCasting.value = true

// Transfer playback if media item is available
currentMediaItem?.let { mediaItem ->
castPlayer?.setMediaItem(mediaItem, playbackPosition)
castPlayer?.prepare()
if (wasPlaying) {
castPlayer?.play()
}
}
}

private fun onCastSessionEnded() {
// Set current player back to local player
currentPlayer = localExoPlayer
_isCasting.value = false

// Resume local playback
localExoPlayer?.let { exoPlayer ->
currentMediaItem?.let { mediaItem ->
exoPlayer.setMediaItem(mediaItem, playbackPosition)
exoPlayer.prepare()
if (wasPlaying) {
exoPlayer.play()
}
}
}
}

fun getCurrentPlayer(): Player? = currentPlayer

private fun isGooglePlayServicesAvailable(): Boolean {
val googleApiAvailability = GoogleApiAvailability.getInstance()
val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(context)
return resultCode == ConnectionResult.SUCCESS
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.metacto.core.presentation.components.videoPlayer

import android.content.Context
import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider
import com.google.android.gms.cast.framework.media.CastMediaOptions
import com.google.android.gms.cast.framework.media.NotificationOptions

class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
// Using the proper constant for Default Media Receiver
return CastOptions.Builder()
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
.setCastMediaOptions(
CastMediaOptions.Builder()
.setNotificationOptions(
NotificationOptions.Builder()
.build()
)
.build()
)
.build()
}

override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {
return null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.metacto.core.presentation.components.videoPlayer

import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch

internal sealed class VideoPlayerEvent {
data class StoppedPip(val playerId: String) : VideoPlayerEvent()
data class StartedPip(val playerId: String) : VideoPlayerEvent()
data class ActivityFinished(val playerId: String) : VideoPlayerEvent()
}

internal object VideoPlayerEventBroadcaster {
// Changed from private to internal to allow access from VideoPlayerActivity
internal val events = MutableSharedFlow<VideoPlayerEvent>()
private var pipActivePlayerId: String? = null

fun getPipActivePlayerId(): String? = pipActivePlayerId
fun setPipActivePlayerId(id: String?) { pipActivePlayerId = id }

@OptIn(DelicateCoroutinesApi::class)
fun emit(event: VideoPlayerEvent) {
GlobalScope.launch { events.emit(event) }
}

@SuppressLint("ComposableNaming")
@Composable
inline fun <reified T : VideoPlayerEvent> collectInCompose(crossinline onReceived: (T) -> Unit) {
LaunchedEffect(Unit) {
events.filter { it is T }.collectLatest { onReceived(it as T) }
}
}
}
Loading