Created
October 26, 2025 14:54
-
-
Save ShivamKumarJha/3c8398b47053ae05112d2a8f8b5de531 to your computer and use it in GitHub Desktop.
ExoPlayer media3 powered by Cronet
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package com.shivamkumarjha.media_common.model | |
| import com.shivamkumarjha.network_common.HttpRequestData | |
| import com.shivamkumarjha.network_common.ellipsize | |
| import com.shivamkumarjha.network_common.removeDuplicateWordsIgnoreCase | |
| import kotlinx.serialization.Serializable | |
| @Serializable | |
| data class Media( | |
| val httpRequestData: HttpRequestData, | |
| val label: String, | |
| val mediaType: MediaType = MediaType.AUTO, | |
| val hlsExtraction: Boolean = true, | |
| val description: String = "", | |
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package com.shivamkumarjha.streamvault.media3 | |
| import android.content.Context | |
| import android.net.Uri | |
| import android.util.Log | |
| import androidx.core.net.toUri | |
| import androidx.media3.common.C | |
| import androidx.media3.common.MediaItem | |
| import androidx.media3.common.MimeTypes | |
| import androidx.media3.common.util.UnstableApi | |
| import androidx.media3.common.util.Util | |
| import androidx.media3.datasource.DataSource | |
| import androidx.media3.datasource.cache.CacheDataSink | |
| import androidx.media3.datasource.cache.CacheDataSource | |
| import androidx.media3.datasource.cronet.CronetDataSource | |
| import androidx.media3.exoplayer.dash.DashMediaSource | |
| import androidx.media3.exoplayer.hls.HlsMediaSource | |
| import androidx.media3.exoplayer.rtsp.RtspMediaSource | |
| import androidx.media3.exoplayer.smoothstreaming.SsMediaSource | |
| import androidx.media3.exoplayer.source.MediaSource | |
| import androidx.media3.exoplayer.source.ProgressiveMediaSource | |
| import com.shivamkumarjha.media_common.model.Media | |
| import com.shivamkumarjha.media_common.model.MediaType | |
| import com.shivamkumarjha.network_client.USER_AGENT | |
| import com.shivamkumarjha.network_client.getHost | |
| import com.shivamkumarjha.streamvault.di.CACHE_SIZE | |
| import com.shivamkumarjha.streamvault.di.PlatformComponent | |
| import org.chromium.net.CronetEngine | |
| import java.io.File | |
| import java.util.concurrent.Executor | |
| import java.util.concurrent.Executors | |
| @UnstableApi | |
| class Media3Helper( | |
| private val context: Context, | |
| ) { | |
| private companion object { | |
| private const val TAG = "Media3Helper" | |
| private const val TIMEOUT = 15_000 | |
| } | |
| fun createMediaSource( | |
| media: Media, | |
| ): MediaSource { | |
| Log.d(TAG, "createMediaSource $media") | |
| // Media Item | |
| val mediaUri: Uri = media.httpRequestData.url.toUri() | |
| val mediaItem = MediaItem.Builder() | |
| .setUri(mediaUri) | |
| .setMimeType( | |
| if (media.mediaType == MediaType.MPD) | |
| MimeTypes.APPLICATION_MPD | |
| else | |
| null | |
| ) | |
| .build() | |
| val dataSourceFactory = createDataSource( | |
| url = media.httpRequestData.url, | |
| headers = media.httpRequestData.headers, | |
| ) | |
| // Some files have incorrect content type hence requiring this. | |
| if (media.mediaType == MediaType.HLS) { | |
| Log.d(TAG, "createMediaSource -> HLS forced") | |
| return HlsMediaSource.Factory(dataSourceFactory) | |
| .createMediaSource(mediaItem) | |
| } | |
| val contentType = Util.inferContentType(mediaUri) | |
| Log.d(TAG, "createMediaSource -> $contentType") | |
| return when (contentType) { | |
| C.CONTENT_TYPE_HLS -> HlsMediaSource.Factory(dataSourceFactory) | |
| .createMediaSource(mediaItem) | |
| C.CONTENT_TYPE_SS -> SsMediaSource.Factory(dataSourceFactory) | |
| .createMediaSource(mediaItem) | |
| C.CONTENT_TYPE_DASH -> DashMediaSource.Factory(dataSourceFactory) | |
| .createMediaSource(mediaItem) | |
| C.CONTENT_TYPE_RTSP -> RtspMediaSource.Factory() | |
| .createMediaSource(mediaItem) | |
| else -> ProgressiveMediaSource.Factory(dataSourceFactory) | |
| .createMediaSource(mediaItem) | |
| } | |
| } | |
| private fun createDataSource( | |
| url: String, | |
| headers: Map<String, String>, | |
| ): DataSource.Factory { | |
| Log.d(TAG, "createDataSource $headers") | |
| // Cronet | |
| val cronetEngine = createCronetEngine(url) | |
| val executor: Executor = Executors.newSingleThreadExecutor() | |
| val cronetDataSourceFactory = CronetDataSource.Factory(cronetEngine, executor) | |
| .setDefaultRequestProperties(headers) | |
| .setUserAgent(USER_AGENT) | |
| .setConnectionTimeoutMs(TIMEOUT) | |
| .setReadTimeoutMs(TIMEOUT) | |
| .setResetTimeoutOnRedirects(true) | |
| .setHandleSetCookieRequests(true) | |
| // Caching | |
| val cache = PlatformComponent.simpleCache | |
| val streamDataSinkFactory = CacheDataSink.Factory().setCache(cache) | |
| return CacheDataSource.Factory() | |
| .setCache(cache) | |
| .setUpstreamDataSourceFactory(cronetDataSourceFactory) | |
| .setCacheWriteDataSinkFactory(streamDataSinkFactory) | |
| .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) | |
| } | |
| private fun createCronetEngine(url: String): CronetEngine { | |
| Log.d(TAG, "createCronetEngine") | |
| val childName = "cronet_cache-${url.getHost()}-${System.currentTimeMillis()}" | |
| val cacheDir = File(context.cacheDir, childName) | |
| if (!cacheDir.exists()) { | |
| cacheDir.mkdirs() | |
| } | |
| return CronetEngine.Builder(context) | |
| .enableQuic(true) | |
| .setStoragePath(cacheDir.absolutePath) | |
| .setLibraryLoader(null) | |
| .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE) | |
| .build() | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package com.shivamkumarjha.media_common.model | |
| import kotlinx.serialization.Serializable | |
| @Serializable | |
| enum class MediaType { | |
| AUTO, | |
| HLS, | |
| VIDEO, | |
| AUDIO, | |
| MPD, | |
| LIVE, | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package com.shivamkumarjha.streamvault.di | |
| import androidx.media3.common.util.UnstableApi | |
| import androidx.media3.datasource.cache.SimpleCache | |
| import org.koin.core.component.KoinComponent | |
| import org.koin.core.scope.Scope | |
| @UnstableApi | |
| object PlatformComponent : KoinComponent { | |
| val simpleCache: SimpleCache by lazy { scope.get() } | |
| private lateinit var scope: Scope | |
| fun startScope() { | |
| scope = getKoin().createScope<SimpleCache>() | |
| } | |
| fun closeScope() { | |
| scope.close() | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package com.shivamkumarjha.streamvault.presentation.source.media | |
| import android.app.PictureInPictureParams | |
| import android.content.pm.PackageManager | |
| import android.graphics.Color | |
| import android.os.Build | |
| import android.util.Rational | |
| import android.widget.Toast | |
| import androidx.annotation.OptIn | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.DisposableEffect | |
| import androidx.compose.runtime.LaunchedEffect | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.graphics.toAndroidRectF | |
| import androidx.compose.ui.layout.boundsInWindow | |
| import androidx.compose.ui.layout.onGloballyPositioned | |
| import androidx.compose.ui.platform.LocalContext | |
| import androidx.compose.ui.viewinterop.AndroidView | |
| import androidx.core.app.PictureInPictureModeChangedInfo | |
| import androidx.core.graphics.toRect | |
| import androidx.core.util.Consumer | |
| import androidx.core.view.WindowCompat | |
| import androidx.core.view.WindowInsetsCompat | |
| import androidx.core.view.WindowInsetsControllerCompat | |
| import androidx.media3.common.C | |
| import androidx.media3.common.Player | |
| import androidx.media3.common.VideoSize | |
| import androidx.media3.common.util.UnstableApi | |
| import androidx.media3.exoplayer.ExoPlayer | |
| import androidx.media3.ui.AspectRatioFrameLayout | |
| import androidx.media3.ui.PlayerView | |
| import com.shivamkumarjha.media_common.model.Media | |
| import com.shivamkumarjha.presentation_theme.findActivity | |
| import com.shivamkumarjha.streamvault.MainActivity | |
| import com.shivamkumarjha.streamvault.di.PlatformComponent | |
| import com.shivamkumarjha.streamvault.media3.Media3Helper | |
| import com.shivamkumarjha.streamvault.presentation.source.media.model.PlaybackState | |
| @Composable | |
| actual fun supportsPip(): Boolean { | |
| return LocalContext.current.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) | |
| } | |
| @Composable | |
| actual fun rememberIsInPipMode(): Boolean { | |
| if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O) return false | |
| val activity = LocalContext.current.findActivity() | |
| var pipMode by remember { mutableStateOf(activity.isInPictureInPictureMode) } | |
| DisposableEffect(activity) { | |
| val observer = Consumer<PictureInPictureModeChangedInfo> { info -> | |
| pipMode = info.isInPictureInPictureMode | |
| } | |
| activity.addOnPictureInPictureModeChangedListener(observer) | |
| onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) } | |
| } | |
| return pipMode | |
| } | |
| @OptIn(UnstableApi::class) | |
| @Composable | |
| actual fun PlayerNative( | |
| sourceId: Int, | |
| requestJson: String, | |
| modifier: Modifier, | |
| ) { | |
| LaunchedEffect(sourceId, requestJson) { | |
| PlatformComponent.startScope() | |
| } | |
| val context = LocalContext.current | |
| val media3Helper = remember { Media3Helper(context) } | |
| val exoPlayer = remember { | |
| ExoPlayer.Builder(context).build().apply { | |
| playWhenReady = true | |
| repeatMode = Player.REPEAT_MODE_OFF | |
| videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING | |
| } | |
| } | |
| val platformPlayerController = remember { | |
| object : PlatformPlayerController { | |
| override fun getCurrentPosition(): Long { | |
| return exoPlayer.currentPosition.coerceAtLeast(0) | |
| } | |
| override fun getDuration(): Long { | |
| return exoPlayer.duration.coerceAtLeast(0) | |
| } | |
| override fun getBufferedPosition(): Long { | |
| return exoPlayer.bufferedPosition.coerceAtLeast(0) | |
| } | |
| override fun getPlaybackState(): PlaybackState? { | |
| return exoPlayer.toPlaybackState() | |
| } | |
| override fun pause() { | |
| exoPlayer.playWhenReady = false | |
| } | |
| override fun play() { | |
| exoPlayer.playWhenReady = true | |
| } | |
| override fun seek(position: Long) { | |
| exoPlayer.seekTo(position) | |
| } | |
| override fun seekPreview(position: Long) { | |
| exoPlayer.playWhenReady = false | |
| exoPlayer.seekTo(position) | |
| } | |
| override fun setMedia(media: Media) { | |
| exoPlayer.apply { | |
| setMediaSource(media3Helper.createMediaSource(media)) | |
| prepare() | |
| play() | |
| } | |
| } | |
| override fun release() { | |
| exoPlayer.release() | |
| PlatformComponent.closeScope() | |
| } | |
| } | |
| } | |
| // Render the player UI | |
| PlayerContent( | |
| sourceId, | |
| requestJson, | |
| platformPlayerController, | |
| modifier, | |
| pipEnter = { | |
| try { | |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | |
| val builder = PictureInPictureParams.Builder() | |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && exoPlayer.videoSize != VideoSize.UNKNOWN) { | |
| val aspectRatio = | |
| Rational(exoPlayer.videoSize.width, exoPlayer.videoSize.height) | |
| builder.setAspectRatio(aspectRatio) | |
| } | |
| modifier.onGloballyPositioned { layoutCoordinates -> | |
| val sourceRect = | |
| layoutCoordinates.boundsInWindow().toAndroidRectF().toRect() | |
| builder.setSourceRectHint(sourceRect) | |
| } | |
| context.findActivity().enterPictureInPictureMode(builder.build()) | |
| } | |
| } catch (e: Throwable) { | |
| Toast.makeText( | |
| context, | |
| "Pip error! ${e.message}", | |
| Toast.LENGTH_SHORT, | |
| ).show() | |
| } | |
| } | |
| ) { | |
| AndroidView( | |
| factory = { context -> | |
| PlayerView(context).apply { | |
| keepScreenOn = true | |
| useController = false | |
| resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT | |
| player = exoPlayer | |
| setBackgroundColor(Color.BLACK) | |
| } | |
| }, | |
| modifier = Modifier.fillMaxSize(), | |
| ) | |
| } | |
| } | |
| actual fun systemBarsVisibility(visible: Boolean) { | |
| val activity = MainActivity.INSTANCE | |
| val window = activity.window | |
| val insetsController = WindowCompat.getInsetsController(window, window.decorView) | |
| if (visible) { | |
| insetsController.apply { | |
| show(WindowInsetsCompat.Type.statusBars()) | |
| show(WindowInsetsCompat.Type.navigationBars()) | |
| systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT | |
| } | |
| } else { | |
| insetsController.apply { | |
| hide(WindowInsetsCompat.Type.statusBars()) | |
| hide(WindowInsetsCompat.Type.navigationBars()) | |
| systemBarsBehavior = | |
| WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment