···1111import androidx.compose.ui.Modifier
1212import androidx.compose.ui.draw.clip
1313import androidx.compose.ui.layout.ContentScale
1414+import androidx.compose.ui.platform.LocalContext
1415import androidx.compose.ui.unit.dp
1516import coil3.compose.AsyncImage
1717+import coil3.request.ImageRequest
1818+import coil3.request.crossfade
16191720data class Image(
1821 val url: String,
···4346 // We take the first 4 images and give them each a weight
4447 images.take(4).forEachIndexed { idx, image ->
4548 AsyncImage(
4646- model = image.url,
4949+ model = ImageRequest.Builder(LocalContext.current)
5050+ .data(image.url)
5151+ .crossfade(true)
5252+ .build(),
4753 contentDescription = image.alt,
4854 contentScale = ContentScale.Crop, // Fills the space
4955 modifier = Modifier
···11+/*
22+ * Copyright 2023 Dora Lee
33+ *
44+ * Licensed under the Apache License, Version 2.0 (the "License");
55+ * you may not use this file except in compliance with the License.
66+ * You may obtain a copy of the License at
77+ *
88+ * https://www.apache.org/licenses/LICENSE-2.0
99+ *
1010+ * Unless required by applicable law or agreed to in writing, software
1111+ * distributed under the License is distributed on an "AS IS" BASIS,
1212+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313+ * See the License for the specific language governing permissions and
1414+ * limitations under the License.
1515+ */
1616+package io.sanghun.compose.video
1717+1818+import androidx.compose.runtime.Stable
1919+import androidx.media3.common.Player
2020+2121+/**
2222+ * VideoPlayer repeat mode.
2323+ */
2424+@Stable
2525+@Suppress("UNUSED_PARAMETER")
2626+enum class RepeatMode(rawValue: String) {
2727+ /**
2828+ * No repeat.
2929+ */
3030+ NONE("none"),
3131+3232+ /**
3333+ * Repeat current media only.
3434+ */
3535+ ONE("one"),
3636+3737+ /**
3838+ * Repeat all track.
3939+ */
4040+ ALL("all"),
4141+}
4242+4343+/**
4444+ * Convert [RepeatMode] to exoplayer repeat mode.
4545+ *
4646+ * @return [Player.REPEAT_MODE_ALL] or [Player.REPEAT_MODE_OFF] or [Player.REPEAT_MODE_ONE] or
4747+ */
4848+internal fun RepeatMode.toExoPlayerRepeatMode(): Int =
4949+ when (this) {
5050+ RepeatMode.NONE -> Player.REPEAT_MODE_OFF
5151+ RepeatMode.ALL -> Player.REPEAT_MODE_ALL
5252+ RepeatMode.ONE -> Player.REPEAT_MODE_ONE
5353+ }
5454+5555+/**
5656+ * Convert exoplayer repeat mode to [RepeatMode].
5757+ *
5858+ * @return [RepeatMode.ALL] or [RepeatMode.NONE] or [RepeatMode.ONE]
5959+ */
6060+fun Int.toRepeatMode(): RepeatMode =
6161+ if (this in 0 until 3) {
6262+ when (this) {
6363+ 0 -> RepeatMode.NONE
6464+ 1 -> RepeatMode.ONE
6565+ 2 -> RepeatMode.ALL
6666+ else -> throw IllegalStateException("This is not ExoPlayer repeat mode.")
6767+ }
6868+ } else {
6969+ throw IllegalStateException("This is not ExoPlayer repeat mode.")
7070+ }
···11+/*
22+ * Copyright 2023 Dora Lee
33+ *
44+ * Licensed under the Apache License, Version 2.0 (the "License");
55+ * you may not use this file except in compliance with the License.
66+ * You may obtain a copy of the License at
77+ *
88+ * https://www.apache.org/licenses/LICENSE-2.0
99+ *
1010+ * Unless required by applicable law or agreed to in writing, software
1111+ * distributed under the License is distributed on an "AS IS" BASIS,
1212+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313+ * See the License for the specific language governing permissions and
1414+ * limitations under the License.
1515+ */
1616+package io.sanghun.compose.video
1717+1818+import androidx.annotation.OptIn
1919+import androidx.compose.runtime.Stable
2020+import androidx.media3.common.util.UnstableApi
2121+import androidx.media3.ui.AspectRatioFrameLayout
2222+2323+/**
2424+ * VideoPlayer resize mode.
2525+ */
2626+@Stable
2727+@Suppress("UNUSED_PARAMETER")
2828+enum class ResizeMode(rawValue: String) {
2929+ /**
3030+ * Either the width or height is decreased to obtain the desired aspect ratio.
3131+ */
3232+ FIT("fit"),
3333+3434+ /**
3535+ * The width is fixed and the height is increased or decreased to obtain the desired aspect ratio.
3636+ */
3737+ FIXED_WIDTH("fixed_width"),
3838+3939+ /**
4040+ * The height is fixed and the width is increased or decreased to obtain the desired aspect ratio.
4141+ */
4242+ FIXED_HEIGHT("fixed_height"),
4343+4444+ /**
4545+ * The specified aspect ratio is ignored.
4646+ */
4747+ FILL("fill"),
4848+4949+ /**
5050+ * Either the width or height is increased to obtain the desired aspect ratio.
5151+ */
5252+ ZOOM("zoom"),
5353+}
5454+5555+/**
5656+ * Convert [ResizeMode] to playerview resize mode.
5757+ *
5858+ * @return [AspectRatioFrameLayout.RESIZE_MODE_FIT] or [AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH]
5959+ * or [AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT] or [AspectRatioFrameLayout.RESIZE_MODE_FILL]
6060+ * or [AspectRatioFrameLayout.RESIZE_MODE_ZOOM]
6161+ */
6262+@OptIn(UnstableApi::class)
6363+internal fun ResizeMode.toPlayerViewResizeMode(): Int =
6464+ when (this) {
6565+ ResizeMode.FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
6666+ ResizeMode.FIXED_WIDTH -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
6767+ ResizeMode.FIXED_HEIGHT -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
6868+ ResizeMode.FILL -> AspectRatioFrameLayout.RESIZE_MODE_FILL
6969+ ResizeMode.ZOOM -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
7070+ }
7171+7272+/**
7373+ * Convert playerview resize mode to [ResizeMode].
7474+ *
7575+ * @return [ResizeMode.FIT] or [ResizeMode.FIXED_WIDTH] or [ResizeMode.FIXED_HEIGHT]
7676+ * or [ResizeMode.FILL] or [ResizeMode.ZOOM]
7777+ */
7878+fun Int.toResizeMode(): ResizeMode =
7979+ if (this in 0 until 5) {
8080+ when (this) {
8181+ 0 -> ResizeMode.FIT
8282+ 1 -> ResizeMode.FIXED_WIDTH
8383+ 2 -> ResizeMode.FIXED_HEIGHT
8484+ 3 -> ResizeMode.FILL
8585+ 4 -> ResizeMode.ZOOM
8686+ else -> throw IllegalStateException("This is not PlayerView resize mode.")
8787+ }
8888+ } else {
8989+ throw IllegalStateException("This is not PlayerView resize mode.")
9090+ }
···11+/*
22+ * Copyright 2023 Dora Lee
33+ *
44+ * Licensed under the Apache License, Version 2.0 (the "License");
55+ * you may not use this file except in compliance with the License.
66+ * You may obtain a copy of the License at
77+ *
88+ * https://www.apache.org/licenses/LICENSE-2.0
99+ *
1010+ * Unless required by applicable law or agreed to in writing, software
1111+ * distributed under the License is distributed on an "AS IS" BASIS,
1212+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313+ * See the License for the specific language governing permissions and
1414+ * limitations under the License.
1515+ */
1616+package io.sanghun.compose.video
1717+1818+import android.annotation.SuppressLint
1919+import android.content.pm.ActivityInfo
2020+import android.graphics.Color
2121+import android.os.Handler
2222+import android.os.Looper
2323+import android.widget.ImageButton
2424+import androidx.activity.compose.BackHandler
2525+import androidx.annotation.FloatRange
2626+import androidx.compose.runtime.Composable
2727+import androidx.compose.runtime.DisposableEffect
2828+import androidx.compose.runtime.LaunchedEffect
2929+import androidx.compose.runtime.getValue
3030+import androidx.compose.runtime.mutableStateOf
3131+import androidx.compose.runtime.remember
3232+import androidx.compose.runtime.rememberUpdatedState
3333+import androidx.compose.runtime.setValue
3434+import androidx.compose.ui.Modifier
3535+import androidx.compose.ui.platform.LocalContext
3636+import androidx.compose.ui.viewinterop.AndroidView
3737+import androidx.compose.ui.window.SecureFlagPolicy
3838+import androidx.lifecycle.Lifecycle
3939+import androidx.lifecycle.LifecycleEventObserver
4040+import androidx.media3.common.AudioAttributes
4141+import androidx.media3.common.C
4242+import androidx.media3.common.C.AUDIO_CONTENT_TYPE_MOVIE
4343+import androidx.media3.common.ForwardingPlayer
4444+import androidx.media3.common.MediaItem
4545+import androidx.media3.common.util.RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL
4646+import androidx.media3.common.util.RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE
4747+import androidx.media3.common.util.RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE
4848+import androidx.media3.datasource.DefaultDataSource
4949+import androidx.media3.datasource.DefaultHttpDataSource
5050+import androidx.media3.datasource.cache.CacheDataSource
5151+import androidx.media3.exoplayer.ExoPlayer
5252+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
5353+import androidx.media3.session.MediaSession
5454+import androidx.media3.ui.PlayerView
5555+import io.sanghun.compose.video.cache.VideoPlayerCacheManager
5656+import io.sanghun.compose.video.controller.VideoPlayerControllerConfig
5757+import io.sanghun.compose.video.controller.applyToExoPlayerView
5858+import io.sanghun.compose.video.pip.enterPIPMode
5959+import io.sanghun.compose.video.pip.isActivityStatePipMode
6060+import io.sanghun.compose.video.uri.VideoPlayerMediaItem
6161+import io.sanghun.compose.video.uri.toUri
6262+import io.sanghun.compose.video.util.findActivity
6363+import io.sanghun.compose.video.util.setFullScreen
6464+import kotlinx.coroutines.Dispatchers
6565+import kotlinx.coroutines.delay
6666+import kotlinx.coroutines.withContext
6767+import java.util.UUID
6868+6969+/**
7070+ * [VideoPlayer] is UI component that can play video in Jetpack Compose. It works based on ExoPlayer.
7171+ * You can play local (e.g. asset files, resource files) files and all video files in the network environment.
7272+ * For all video formats supported by the [VideoPlayer] component, see the ExoPlayer link below.
7373+ *
7474+ * If you rotate the screen, the default action is to reset the player state.
7575+ * To prevent this happening, put the following options in the `android:configChanges` option of your app's AndroidManifest.xml to keep the settings.
7676+ * ```
7777+ * keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode
7878+ * ```
7979+ *
8080+ * This component is linked with Compose [androidx.compose.runtime.DisposableEffect].
8181+ * This means that it move out of the Composable Scope, the ExoPlayer instance will automatically be destroyed as well.
8282+ *
8383+ * @see <a href="https://exoplayer.dev/supported-formats.html">Exoplayer Support Formats</a>
8484+ *
8585+ * @param modifier Modifier to apply to this layout node.
8686+ * @param mediaItems [VideoPlayerMediaItem] to be played by the video player. The reason for receiving media items as an array is to configure multi-track. If it's a single track, provide a single list (e.g. listOf(mediaItem)).
8787+ * @param handleLifecycle Sets whether to automatically play/stop the player according to the activity lifecycle. Default is true.
8888+ * @param autoPlay Autoplay when media item prepared. Default is true.
8989+ * @param usePlayerController Using player controller. Default is true.
9090+ * @param controllerConfig Player controller config. You can customize the Video Player Controller UI.
9191+ * @param seekBeforeMilliSeconds The seek back increment, in milliseconds. Default is 10sec (10000ms). Read-only props (Changes in values do not take effect.)
9292+ * @param seekAfterMilliSeconds The seek forward increment, in milliseconds. Default is 10sec (10000ms). Read-only props (Changes in values do not take effect.)
9393+ * @param repeatMode Sets the content repeat mode.
9494+ * @param volume Sets thie player volume. It's possible from 0.0 to 1.0.
9595+ * @param onCurrentTimeChanged A callback that returned once every second for player current time when the player is playing.
9696+ * @param fullScreenSecurePolicy Windows security settings to apply when full screen. Default is off. (For example, avoid screenshots that are not DRM-applied.)
9797+ * @param onFullScreenEnter A callback that occurs when the player is full screen. (The [VideoPlayerControllerConfig.showFullScreenButton] must be true to trigger a callback.)
9898+ * @param onFullScreenExit A callback that occurs when the full screen is turned off. (The [VideoPlayerControllerConfig.showFullScreenButton] must be true to trigger a callback.)
9999+ * @param enablePip Enable PIP (Picture-in-Picture).
100100+ * @param enablePipWhenBackPressed With [enablePip] is `true`, set whether to enable PIP mode even when you press Back. Default is false.
101101+ * @param handleAudioFocus Set whether to handle the video playback control automatically when it is playing in PIP mode and media is played in another app. Default is true.
102102+ * @param playerBuilder Return exoplayer builder. This instance allows you to customise the ExoPlayer while in the Building process. Used to add various components like other RenderFactories.
103103+ * @param playerInstance Return exoplayer instance. This instance allows you to add [androidx.media3.exoplayer.analytics.AnalyticsListener] to receive various events from the player.
104104+ */
105105+@SuppressLint("SourceLockedOrientationActivity", "UnsafeOptInUsageError")
106106+@Composable
107107+fun VideoPlayer(
108108+ modifier: Modifier = Modifier,
109109+ mediaItems: List<VideoPlayerMediaItem>,
110110+ handleLifecycle: Boolean = true,
111111+ autoPlay: Boolean = true,
112112+ usePlayerController: Boolean = true,
113113+ controllerConfig: VideoPlayerControllerConfig = VideoPlayerControllerConfig.Default,
114114+ seekBeforeMilliSeconds: Long = 10000L,
115115+ seekAfterMilliSeconds: Long = 10000L,
116116+ repeatMode: RepeatMode = RepeatMode.NONE,
117117+ resizeMode: ResizeMode = ResizeMode.FIT,
118118+ @FloatRange(from = 0.0, to = 1.0) volume: Float = 1f,
119119+ onCurrentTimeChanged: (Long) -> Unit = {},
120120+ fullScreenSecurePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
121121+ onFullScreenEnter: () -> Unit = {},
122122+ onFullScreenExit: () -> Unit = {},
123123+ enablePip: Boolean = false,
124124+ defaultFullScreeen: Boolean = false,
125125+ enablePipWhenBackPressed: Boolean = false,
126126+ handleAudioFocus: Boolean = true,
127127+ playerBuilder: ExoPlayer.Builder.() -> ExoPlayer.Builder = { this },
128128+ playerInstance: ExoPlayer.() -> Unit = {},
129129+) {
130130+ val context = LocalContext.current
131131+ var currentTime by remember { mutableStateOf(0L) }
132132+133133+ var mediaSession = remember<MediaSession?> { null }
134134+135135+ val player = remember {
136136+ val httpDataSourceFactory = DefaultHttpDataSource.Factory()
137137+138138+ ExoPlayer.Builder(context)
139139+ .setSeekBackIncrementMs(seekBeforeMilliSeconds)
140140+ .setSeekForwardIncrementMs(seekAfterMilliSeconds)
141141+ .setAudioAttributes(
142142+ AudioAttributes.Builder()
143143+ .setContentType(AUDIO_CONTENT_TYPE_MOVIE)
144144+ .setUsage(C.USAGE_MEDIA)
145145+ .build(),
146146+ handleAudioFocus,
147147+ )
148148+ .apply {
149149+ val cache = VideoPlayerCacheManager.getCache()
150150+ if (cache != null) {
151151+ val cacheDataSourceFactory = CacheDataSource.Factory()
152152+ .setCache(cache)
153153+ .setUpstreamDataSourceFactory(
154154+ DefaultDataSource.Factory(
155155+ context,
156156+ httpDataSourceFactory
157157+ )
158158+ )
159159+ setMediaSourceFactory(DefaultMediaSourceFactory(cacheDataSourceFactory))
160160+ }
161161+ }
162162+ .playerBuilder()
163163+ .build()
164164+ .also(playerInstance)
165165+ }
166166+167167+ val defaultPlayerView = remember {
168168+ PlayerView(context)
169169+ }
170170+171171+ BackHandler(enablePip && enablePipWhenBackPressed) {
172172+ enterPIPMode(context, defaultPlayerView)
173173+ player.play()
174174+ }
175175+176176+ LaunchedEffect(Unit) {
177177+ while (true) {
178178+ delay(1000)
179179+180180+ if (currentTime != player.currentPosition) {
181181+ onCurrentTimeChanged(currentTime)
182182+ }
183183+184184+ currentTime = player.currentPosition
185185+ }
186186+ }
187187+188188+ LaunchedEffect(usePlayerController) {
189189+ defaultPlayerView.useController = usePlayerController
190190+ }
191191+192192+ LaunchedEffect(player) {
193193+ defaultPlayerView.player = player
194194+ }
195195+196196+ LaunchedEffect(mediaItems, player) {
197197+ mediaSession?.release()
198198+ mediaSession = MediaSession.Builder(context, ForwardingPlayer(player))
199199+ .setId(
200200+ "VideoPlayerMediaSession_${
201201+ UUID.randomUUID().toString().lowercase().split("-").first()
202202+ }"
203203+ )
204204+ .build()
205205+ val exoPlayerMediaItems = withContext(Dispatchers.IO) {
206206+ mediaItems.map {
207207+ val uri = it.toUri(context)
208208+209209+ MediaItem.Builder().apply {
210210+ setUri(uri)
211211+ setMediaMetadata(it.mediaMetadata)
212212+ setMimeType(it.mimeType)
213213+ setDrmConfiguration(
214214+ if (it is VideoPlayerMediaItem.NetworkMediaItem) {
215215+ it.drmConfiguration
216216+ } else {
217217+ null
218218+ },
219219+ )
220220+ }.build()
221221+ }
222222+ }
223223+224224+ player.setMediaItems(exoPlayerMediaItems)
225225+ player.prepare()
226226+227227+ if (autoPlay) {
228228+ player.play()
229229+ }
230230+ }
231231+232232+ var isFullScreenModeEntered by remember { mutableStateOf(defaultFullScreeen) }
233233+234234+ LaunchedEffect(controllerConfig) {
235235+ controllerConfig.applyToExoPlayerView(defaultPlayerView) {
236236+ isFullScreenModeEntered = it
237237+238238+ if (it) {
239239+ onFullScreenEnter()
240240+ }
241241+ }
242242+ }
243243+244244+ LaunchedEffect(controllerConfig, repeatMode) {
245245+ defaultPlayerView.setRepeatToggleModes(
246246+ if (controllerConfig.showRepeatModeButton) {
247247+ REPEAT_TOGGLE_MODE_ALL or REPEAT_TOGGLE_MODE_ONE
248248+ } else {
249249+ REPEAT_TOGGLE_MODE_NONE
250250+ },
251251+ )
252252+ player.repeatMode = repeatMode.toExoPlayerRepeatMode()
253253+ }
254254+255255+ LaunchedEffect(volume) {
256256+ player.volume = volume
257257+ }
258258+259259+ VideoPlayerSurface(
260260+ modifier = modifier,
261261+ defaultPlayerView = defaultPlayerView,
262262+ player = player,
263263+ usePlayerController = usePlayerController,
264264+ handleLifecycle = handleLifecycle,
265265+ enablePip = enablePip,
266266+ surfaceResizeMode = resizeMode
267267+ )
268268+269269+ if (isFullScreenModeEntered) {
270270+ var fullScreenPlayerView by remember { mutableStateOf<PlayerView?>(null) }
271271+272272+ VideoPlayerFullScreenDialog(
273273+ player = player,
274274+ currentPlayerView = defaultPlayerView,
275275+ controllerConfig = controllerConfig,
276276+ repeatMode = repeatMode,
277277+ resizeMode = resizeMode,
278278+ onDismissRequest = {
279279+ fullScreenPlayerView?.let {
280280+ PlayerView.switchTargetView(player, it, defaultPlayerView)
281281+ defaultPlayerView.findViewById<ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
282282+ .performClick()
283283+ val currentActivity = context.findActivity()
284284+ currentActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
285285+ currentActivity.setFullScreen(false)
286286+ onFullScreenExit()
287287+ }
288288+289289+ isFullScreenModeEntered = false
290290+ },
291291+ securePolicy = fullScreenSecurePolicy,
292292+ enablePip = enablePip,
293293+ fullScreenPlayerView = {
294294+ fullScreenPlayerView = this
295295+ },
296296+ )
297297+ }
298298+}
299299+300300+@SuppressLint("UnsafeOptInUsageError")
301301+@Composable
302302+internal fun VideoPlayerSurface(
303303+ modifier: Modifier = Modifier,
304304+ defaultPlayerView: PlayerView,
305305+ player: ExoPlayer,
306306+ usePlayerController: Boolean,
307307+ handleLifecycle: Boolean,
308308+ enablePip: Boolean,
309309+ surfaceResizeMode: ResizeMode,
310310+ onPipEntered: () -> Unit = {},
311311+ autoDispose: Boolean = true,
312312+) {
313313+ val lifecycleOwner =
314314+ rememberUpdatedState(androidx.lifecycle.compose.LocalLifecycleOwner.current)
315315+ val context = LocalContext.current
316316+317317+ var isPendingPipMode by remember { mutableStateOf(false) }
318318+319319+ DisposableEffect(
320320+ AndroidView(
321321+ modifier = modifier,
322322+ factory = {
323323+ defaultPlayerView.apply {
324324+ useController = usePlayerController
325325+ resizeMode = surfaceResizeMode.toPlayerViewResizeMode()
326326+ setBackgroundColor(Color.BLACK)
327327+ }
328328+ },
329329+ ),
330330+ ) {
331331+ val observer = LifecycleEventObserver { _, event ->
332332+ when (event) {
333333+ Lifecycle.Event.ON_PAUSE -> {
334334+ if (handleLifecycle) {
335335+ player.pause()
336336+ }
337337+338338+ if (enablePip && player.playWhenReady) {
339339+ isPendingPipMode = true
340340+341341+ Handler(Looper.getMainLooper()).post {
342342+ enterPIPMode(context, defaultPlayerView)
343343+ onPipEntered()
344344+345345+ Handler(Looper.getMainLooper()).postDelayed({
346346+ isPendingPipMode = false
347347+ }, 500)
348348+ }
349349+ }
350350+ }
351351+352352+ Lifecycle.Event.ON_RESUME -> {
353353+ if (handleLifecycle) {
354354+ player.play()
355355+ }
356356+357357+ if (enablePip && player.playWhenReady) {
358358+ defaultPlayerView.useController = usePlayerController
359359+ }
360360+ }
361361+362362+ Lifecycle.Event.ON_STOP -> {
363363+ val isPipMode = context.isActivityStatePipMode()
364364+365365+ if (handleLifecycle || (enablePip && isPipMode && !isPendingPipMode)) {
366366+ player.stop()
367367+ }
368368+ }
369369+370370+ else -> {}
371371+ }
372372+ }
373373+ val lifecycle = lifecycleOwner.value.lifecycle
374374+ lifecycle.addObserver(observer)
375375+376376+ onDispose {
377377+ if (autoDispose) {
378378+ player.release()
379379+ lifecycle.removeObserver(observer)
380380+ }
381381+ }
382382+ }
383383+}
···11+/*
22+ * Copyright 2023 Dora Lee
33+ *
44+ * Licensed under the Apache License, Version 2.0 (the "License");
55+ * you may not use this file except in compliance with the License.
66+ * You may obtain a copy of the License at
77+ *
88+ * https://www.apache.org/licenses/LICENSE-2.0
99+ *
1010+ * Unless required by applicable law or agreed to in writing, software
1111+ * distributed under the License is distributed on an "AS IS" BASIS,
1212+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313+ * See the License for the specific language governing permissions and
1414+ * limitations under the License.
1515+ */
1616+package io.sanghun.compose.video
1717+1818+import android.annotation.SuppressLint
1919+import android.content.pm.ActivityInfo
2020+import android.view.Window
2121+import android.view.WindowManager
2222+import android.widget.ImageButton
2323+import androidx.compose.foundation.background
2424+import androidx.compose.foundation.layout.Box
2525+import androidx.compose.foundation.layout.fillMaxSize
2626+import androidx.compose.runtime.Composable
2727+import androidx.compose.runtime.LaunchedEffect
2828+import androidx.compose.runtime.SideEffect
2929+import androidx.compose.runtime.getValue
3030+import androidx.compose.runtime.mutableStateOf
3131+import androidx.compose.runtime.remember
3232+import androidx.compose.runtime.setValue
3333+import androidx.compose.ui.Alignment
3434+import androidx.compose.ui.Modifier
3535+import androidx.compose.ui.graphics.Color
3636+import androidx.compose.ui.platform.LocalContext
3737+import androidx.compose.ui.platform.LocalView
3838+import androidx.compose.ui.window.Dialog
3939+import androidx.compose.ui.window.DialogProperties
4040+import androidx.compose.ui.window.DialogWindowProvider
4141+import androidx.compose.ui.window.SecureFlagPolicy
4242+import androidx.media3.common.util.RepeatModeUtil
4343+import androidx.media3.exoplayer.ExoPlayer
4444+import androidx.media3.ui.PlayerView
4545+import io.sanghun.compose.video.controller.VideoPlayerControllerConfig
4646+import io.sanghun.compose.video.controller.applyToExoPlayerView
4747+import io.sanghun.compose.video.util.findActivity
4848+import io.sanghun.compose.video.util.setFullScreen
4949+5050+/**
5151+ * ExoPlayer does not support full screen views by default.
5252+ * So create a full screen modal that wraps the Compose Dialog.
5353+ *
5454+ * Delegate all functions of the video controller that were used just before
5555+ * the full screen to the video controller managed by that component.
5656+ * Conversely, if the full screen dismissed, it will restore all the functions it delegated
5757+ * for synchronization with the video controller on the full screen and the video controller on the previous screen.
5858+ *
5959+ * @param player Exoplayer instance.
6060+ * @param currentPlayerView [androidx.media3.ui.PlayerView] instance currently in use for playback.
6161+ * @param fullScreenPlayerView Callback to return all features to existing video player controller.
6262+ * @param controllerConfig Player controller config. You can customize the Video Player Controller UI.
6363+ * @param repeatMode Sets the content repeat mode.
6464+ * @param enablePip Enable PIP.
6565+ * @param onDismissRequest Callback that occurs when modals are closed.
6666+ * @param securePolicy Policy on setting [android.view.WindowManager.LayoutParams.FLAG_SECURE] on a full screen dialog window.
6767+ */
6868+@SuppressLint("UnsafeOptInUsageError")
6969+@Composable
7070+internal fun VideoPlayerFullScreenDialog(
7171+ player: ExoPlayer,
7272+ currentPlayerView: PlayerView,
7373+ fullScreenPlayerView: PlayerView.() -> Unit,
7474+ controllerConfig: VideoPlayerControllerConfig,
7575+ repeatMode: RepeatMode,
7676+ resizeMode: ResizeMode,
7777+ enablePip: Boolean,
7878+ onDismissRequest: () -> Unit,
7979+ securePolicy: SecureFlagPolicy,
8080+) {
8181+ val context = LocalContext.current
8282+ val internalFullScreenPlayerView = remember {
8383+ PlayerView(context)
8484+ .also(fullScreenPlayerView)
8585+ }
8686+ var isFullScreenModeEntered by remember {
8787+ mutableStateOf(false)
8888+ }
8989+9090+ Dialog(
9191+ onDismissRequest = onDismissRequest,
9292+ properties = DialogProperties(
9393+ dismissOnClickOutside = false,
9494+ usePlatformDefaultWidth = false,
9595+ securePolicy = securePolicy,
9696+ decorFitsSystemWindows = false,
9797+ ),
9898+ ) {
9999+100100+ LaunchedEffect(Unit) {
101101+ PlayerView.switchTargetView(player, currentPlayerView, internalFullScreenPlayerView)
102102+ }
103103+104104+ val activityWindow = getActivityWindow()
105105+ val dialogWindow = getDialogWindow()
106106+107107+ SideEffect {
108108+ if (activityWindow != null && dialogWindow != null && !isFullScreenModeEntered) {
109109+ activityWindow.setFullScreen(true)
110110+ dialogWindow.setFullScreen(true)
111111+ // dialogWindow has extra padding but activityWindow doesn't;
112112+ // copy the attributes from activity to dialog
113113+ WindowManager.LayoutParams().apply {
114114+ copyFrom(activityWindow.attributes)
115115+ type = dialogWindow.attributes.type
116116+ dialogWindow.attributes = this
117117+ }
118118+ isFullScreenModeEntered = true
119119+ }
120120+ }
121121+122122+ LaunchedEffect(controllerConfig) {
123123+ controllerConfig.applyToExoPlayerView(internalFullScreenPlayerView) {
124124+ if (!it) {
125125+ onDismissRequest()
126126+ }
127127+ }
128128+ internalFullScreenPlayerView.findViewById<ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
129129+ .performClick()
130130+ }
131131+132132+ LaunchedEffect(controllerConfig, repeatMode) {
133133+ internalFullScreenPlayerView.setRepeatToggleModes(
134134+ if (controllerConfig.showRepeatModeButton) {
135135+ RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL or RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE
136136+ } else {
137137+ RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE
138138+ },
139139+ )
140140+ }
141141+142142+ Box(
143143+ modifier = Modifier
144144+ .fillMaxSize()
145145+ .background(Color.Black),
146146+ ) {
147147+ VideoPlayerSurface(
148148+ defaultPlayerView = internalFullScreenPlayerView,
149149+ player = player,
150150+ usePlayerController = true,
151151+ handleLifecycle = !enablePip,
152152+ autoDispose = false,
153153+ enablePip = enablePip,
154154+ surfaceResizeMode = resizeMode,
155155+ onPipEntered = { onDismissRequest() },
156156+ modifier = Modifier
157157+ .align(Alignment.Center)
158158+ .fillMaxSize(),
159159+ )
160160+ }
161161+ }
162162+}
163163+164164+165165+@Composable
166166+internal fun getDialogWindow(): Window? = (LocalView.current.parent as? DialogWindowProvider)?.window
167167+168168+@Composable
169169+internal fun getActivityWindow(): Window? = LocalView.current.context.findActivity().window
···11+/*
22+ * Copyright 2023 Dora Lee
33+ *
44+ * Licensed under the Apache License, Version 2.0 (the "License");
55+ * you may not use this file except in compliance with the License.
66+ * You may obtain a copy of the License at
77+ *
88+ * https://www.apache.org/licenses/LICENSE-2.0
99+ *
1010+ * Unless required by applicable law or agreed to in writing, software
1111+ * distributed under the License is distributed on an "AS IS" BASIS,
1212+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313+ * See the License for the specific language governing permissions and
1414+ * limitations under the License.
1515+ */
1616+package io.sanghun.compose.video.cache
1717+1818+import android.annotation.SuppressLint
1919+import android.content.Context
2020+import androidx.media3.database.StandaloneDatabaseProvider
2121+import androidx.media3.datasource.cache.Cache
2222+import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
2323+import androidx.media3.datasource.cache.SimpleCache
2424+import java.io.File
2525+2626+/**
2727+ * Manage video player cache.
2828+ */
2929+object VideoPlayerCacheManager {
3030+3131+ private lateinit var cacheInstance: Cache
3232+3333+ /**
3434+ * Set the cache for video player.
3535+ * It can only be set once in the app, and it is shared and used by multiple video players.
3636+ *
3737+ * @param context Current activity context.
3838+ * @param maxCacheBytes Sets the maximum cache capacity in bytes. If the cache builds up as much as the set capacity, it is deleted from the oldest cache.
3939+ */
4040+ @SuppressLint("UnsafeOptInUsageError")
4141+ fun initialize(context: Context, maxCacheBytes: Long) {
4242+ if (::cacheInstance.isInitialized) {
4343+ return
4444+ }
4545+4646+ cacheInstance = SimpleCache(
4747+ File(context.cacheDir, "video"),
4848+ LeastRecentlyUsedCacheEvictor(maxCacheBytes),
4949+ StandaloneDatabaseProvider(context),
5050+ )
5151+ }
5252+5353+ /**
5454+ * Gets the ExoPlayer cache instance. If null, the cache to be disabled.
5555+ */
5656+ internal fun getCache(): Cache? =
5757+ if (::cacheInstance.isInitialized) {
5858+ cacheInstance
5959+ } else {
6060+ null
6161+ }
6262+}
···11+/*
22+ * Copyright 2023 Dora Lee
33+ *
44+ * Licensed under the Apache License, Version 2.0 (the "License");
55+ * you may not use this file except in compliance with the License.
66+ * You may obtain a copy of the License at
77+ *
88+ * https://www.apache.org/licenses/LICENSE-2.0
99+ *
1010+ * Unless required by applicable law or agreed to in writing, software
1111+ * distributed under the License is distributed on an "AS IS" BASIS,
1212+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313+ * See the License for the specific language governing permissions and
1414+ * limitations under the License.
1515+ */
1616+package io.sanghun.compose.video.controller
1717+1818+import android.annotation.SuppressLint
1919+import android.view.View
2020+import androidx.compose.runtime.Immutable
2121+import androidx.core.view.isVisible
2222+import androidx.media3.ui.PlayerView
2323+2424+/**
2525+ * Sets the detailed properties of the [io.sanghun.compose.video.VideoPlayer] Controller.
2626+ *
2727+ * @param showSpeedAndPitchOverlay Visible speed, audio track select button.
2828+ * @param showSubtitleButton Visible subtitle (CC) button.
2929+ * @param showCurrentTimeAndTotalTime Visible currentTime, totalTime text.
3030+ * @param showBufferingProgress Visible buffering progress.
3131+ * @param showForwardIncrementButton Show forward increment button.
3232+ * @param showBackwardIncrementButton Show backward increment button.
3333+ * @param showBackTrackButton Show back track button.
3434+ * @param showNextTrackButton Show next track button.
3535+ * @param showRepeatModeButton Show repeat mode toggle button.
3636+ * @param controllerShowTimeMilliSeconds Sets the playback controls timeout.
3737+ * The playback controls are automatically hidden after this duration of time has elapsed without user input and with playback or buffering in progress.
3838+ * (The timeout in milliseconds. A non-positive value will cause the controller to remain visible indefinitely.)
3939+ * @param controllerAutoShow Sets whether the playback controls are automatically shown when playback starts, pauses, ends, or fails.
4040+ * If set to false, the playback controls can be manually operated with {@link #showController()} and {@link #hideController()}.
4141+ * (Whether the playback controls are allowed to show automatically.)
4242+ * @param showFullScreenButton Show full screen button.
4343+ */
4444+@Immutable
4545+data class VideoPlayerControllerConfig(
4646+ val showSpeedAndPitchOverlay: Boolean,
4747+ val showSubtitleButton: Boolean,
4848+ val showCurrentTimeAndTotalTime: Boolean,
4949+ val showBufferingProgress: Boolean,
5050+ val showForwardIncrementButton: Boolean,
5151+ val showBackwardIncrementButton: Boolean,
5252+ val showBackTrackButton: Boolean,
5353+ val showNextTrackButton: Boolean,
5454+ val showRepeatModeButton: Boolean,
5555+ val showFullScreenButton: Boolean,
5656+5757+ val controllerShowTimeMilliSeconds: Int,
5858+ val controllerAutoShow: Boolean,
5959+) {
6060+6161+ companion object {
6262+ /**
6363+ * Default config for Controller.
6464+ */
6565+ val Default = VideoPlayerControllerConfig(
6666+ showSpeedAndPitchOverlay = false,
6767+ showSubtitleButton = true,
6868+ showCurrentTimeAndTotalTime = true,
6969+ showBufferingProgress = false,
7070+ showForwardIncrementButton = false,
7171+ showBackwardIncrementButton = false,
7272+ showBackTrackButton = true,
7373+ showNextTrackButton = true,
7474+ showRepeatModeButton = false,
7575+ controllerShowTimeMilliSeconds = 5_000,
7676+ controllerAutoShow = true,
7777+ showFullScreenButton = true,
7878+ )
7979+ }
8080+}
8181+8282+/**
8383+ * Apply the [VideoPlayerControllerConfig] to the ExoPlayer StyledViewPlayer.
8484+ *
8585+ * @param playerView [PlayerView] to which you want to apply settings.
8686+ * @param onFullScreenStatusChanged Callback that occurs when the full screen status changes.
8787+ */
8888+@SuppressLint("UnsafeOptInUsageError")
8989+internal fun VideoPlayerControllerConfig.applyToExoPlayerView(
9090+ playerView: PlayerView,
9191+ onFullScreenStatusChanged: (Boolean) -> Unit,
9292+) {
9393+ val controllerView = playerView.rootView
9494+9595+ controllerView.findViewById<View>(androidx.media3.ui.R.id.exo_settings).isVisible =
9696+ showSpeedAndPitchOverlay
9797+ playerView.setShowSubtitleButton(showSubtitleButton)
9898+ controllerView.findViewById<View>(androidx.media3.ui.R.id.exo_time).isVisible =
9999+ showCurrentTimeAndTotalTime
100100+ playerView.setShowBuffering(
101101+ if (!showBufferingProgress) PlayerView.SHOW_BUFFERING_NEVER else PlayerView.SHOW_BUFFERING_ALWAYS,
102102+ )
103103+ controllerView.findViewById<View>(androidx.media3.ui.R.id.exo_ffwd_with_amount).isVisible =
104104+ showForwardIncrementButton
105105+ controllerView.findViewById<View>(androidx.media3.ui.R.id.exo_rew_with_amount).isVisible =
106106+ showBackwardIncrementButton
107107+ playerView.setShowNextButton(showNextTrackButton)
108108+ playerView.setShowPreviousButton(showBackTrackButton)
109109+ playerView.setShowFastForwardButton(showForwardIncrementButton)
110110+ playerView.setShowRewindButton(showBackwardIncrementButton)
111111+ playerView.controllerShowTimeoutMs = controllerShowTimeMilliSeconds
112112+ playerView.controllerAutoShow = controllerAutoShow
113113+114114+ @Suppress("DEPRECATION")
115115+ if (showFullScreenButton) {
116116+ playerView.setControllerOnFullScreenModeChangedListener {
117117+ onFullScreenStatusChanged(it)
118118+ }
119119+ } else {
120120+ playerView.setControllerOnFullScreenModeChangedListener(null)
121121+ }
122122+}
···11+/*
22+ * Copyright 2023 Dora Lee
33+ *
44+ * Licensed under the Apache License, Version 2.0 (the "License");
55+ * you may not use this file except in compliance with the License.
66+ * You may obtain a copy of the License at
77+ *
88+ * https://www.apache.org/licenses/LICENSE-2.0
99+ *
1010+ * Unless required by applicable law or agreed to in writing, software
1111+ * distributed under the License is distributed on an "AS IS" BASIS,
1212+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313+ * See the License for the specific language governing permissions and
1414+ * limitations under the License.
1515+ */
1616+package io.sanghun.compose.video.pip
1717+1818+import android.app.PictureInPictureParams
1919+import android.content.Context
2020+import android.content.pm.PackageManager
2121+import android.os.Build
2222+import android.util.Rational
2323+import androidx.media3.ui.PlayerView
2424+import io.sanghun.compose.video.util.findActivity
2525+2626+/**
2727+ * Enables PIP mode for the current activity.
2828+ *
2929+ * @param context Activity context.
3030+ * @param defaultPlayerView Current video player controller.
3131+ */
3232+@Suppress("DEPRECATION")
3333+internal fun enterPIPMode(context: Context, defaultPlayerView: PlayerView) {
3434+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
3535+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
3636+ ) {
3737+ defaultPlayerView.useController = false
3838+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
3939+ val params = PictureInPictureParams.Builder()
4040+4141+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
4242+ params
4343+ .setTitle("Video Player")
4444+ .setAspectRatio(Rational(16, 9))
4545+ .setSeamlessResizeEnabled(true)
4646+ }
4747+4848+ context.findActivity().enterPictureInPictureMode(params.build())
4949+ } else {
5050+ context.findActivity().enterPictureInPictureMode()
5151+ }
5252+ }
5353+}
5454+5555+/**
5656+ * Check that the current activity is in PIP mode.
5757+ *
5858+ * @return `true` if the activity is in pip mode. (PIP mode is not supported in the version below Android N, so `false` is returned unconditionally.)
5959+ */
6060+internal fun Context.isActivityStatePipMode(): Boolean {
6161+ val currentActivity = findActivity()
6262+6363+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
6464+ currentActivity.isInPictureInPictureMode
6565+ } else {
6666+ false
6767+ }
6868+}
···11+/*
22+ * Copyright 2023 Dora Lee
33+ *
44+ * Licensed under the Apache License, Version 2.0 (the "License");
55+ * you may not use this file except in compliance with the License.
66+ * You may obtain a copy of the License at
77+ *
88+ * https://www.apache.org/licenses/LICENSE-2.0
99+ *
1010+ * Unless required by applicable law or agreed to in writing, software
1111+ * distributed under the License is distributed on an "AS IS" BASIS,
1212+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313+ * See the License for the specific language governing permissions and
1414+ * limitations under the License.
1515+ */
1616+package io.sanghun.compose.video.uri
1717+1818+import android.net.Uri
1919+import androidx.annotation.RawRes
2020+import androidx.media3.common.MediaItem.DrmConfiguration
2121+import androidx.media3.common.MediaMetadata
2222+import io.sanghun.compose.video.uri.VideoPlayerMediaItem.AssetFileMediaItem
2323+import io.sanghun.compose.video.uri.VideoPlayerMediaItem.NetworkMediaItem
2424+import io.sanghun.compose.video.uri.VideoPlayerMediaItem.RawResourceMediaItem
2525+import io.sanghun.compose.video.uri.VideoPlayerMediaItem.StorageMediaItem
2626+2727+interface BaseVideoPlayerMediaItem {
2828+ val mediaMetadata: MediaMetadata
2929+ val mimeType: String
3030+}
3131+3232+/**
3333+ * Representation of a media item for [io.sanghun.compose.video.VideoPlayer].
3434+ *
3535+ * @see RawResourceMediaItem
3636+ * @see AssetFileMediaItem
3737+ * @see StorageMediaItem
3838+ * @see NetworkMediaItem
3939+ */
4040+sealed interface VideoPlayerMediaItem : BaseVideoPlayerMediaItem {
4141+4242+ /**
4343+ * A media item in the raw resource.
4444+ * @param resourceId R.raw.xxxxx resource id
4545+ * @param mediaMetadata Media Metadata. Default is empty.
4646+ * @param mimeType Media mime type.
4747+ */
4848+ data class RawResourceMediaItem(
4949+ @RawRes val resourceId: Int,
5050+ override val mediaMetadata: MediaMetadata = MediaMetadata.EMPTY,
5151+ override val mimeType: String = "",
5252+ ) : VideoPlayerMediaItem
5353+5454+ /**
5555+ * A media item in the assets folder.
5656+ * @param assetPath asset media file path (e.g If there is a test.mp4 file in the assets folder, test.mp4 becomes the assetPath.)
5757+ * @throws androidx.media3.datasource.AssetDataSource.AssetDataSourceException asset file is not exist or load failed.
5858+ * @param mediaMetadata Media Metadata. Default is empty.
5959+ * @param mimeType Media mime type.
6060+ */
6161+ data class AssetFileMediaItem(
6262+ val assetPath: String,
6363+ override val mediaMetadata: MediaMetadata = MediaMetadata.EMPTY,
6464+ override val mimeType: String = "",
6565+ ) : VideoPlayerMediaItem
6666+6767+ /**
6868+ * A media item in the device internal / external storage.
6969+ * @param storageUri storage file uri
7070+ * @param mediaMetadata Media Metadata. Default is empty.
7171+ * @param mimeType Media mime type.
7272+ * @throws androidx.media3.datasource.FileDataSource.FileDataSourceException
7373+ */
7474+ data class StorageMediaItem(
7575+ val storageUri: Uri,
7676+ override val mediaMetadata: MediaMetadata = MediaMetadata.EMPTY,
7777+ override val mimeType: String = "",
7878+ ) : VideoPlayerMediaItem
7979+8080+ /**
8181+ * A media item in the internet
8282+ * @param url network video url'
8383+ * @param mediaMetadata Media Metadata. Default is empty.
8484+ * @param mimeType Media mime type.
8585+ * @param drmConfiguration Drm configuration for media. (Default is null)
8686+ */
8787+ data class NetworkMediaItem(
8888+ val url: String,
8989+ override val mediaMetadata: MediaMetadata = MediaMetadata.EMPTY,
9090+ override val mimeType: String = "",
9191+ val drmConfiguration: DrmConfiguration? = null,
9292+ ) : VideoPlayerMediaItem
9393+}
···11+/*
22+ * Copyright 2023 Dora Lee
33+ *
44+ * Licensed under the Apache License, Version 2.0 (the "License");
55+ * you may not use this file except in compliance with the License.
66+ * You may obtain a copy of the License at
77+ *
88+ * https://www.apache.org/licenses/LICENSE-2.0
99+ *
1010+ * Unless required by applicable law or agreed to in writing, software
1111+ * distributed under the License is distributed on an "AS IS" BASIS,
1212+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313+ * See the License for the specific language governing permissions and
1414+ * limitations under the License.
1515+ */
1616+package io.sanghun.compose.video.uri
1717+1818+import android.annotation.SuppressLint
1919+import android.content.Context
2020+import android.net.Uri
2121+import androidx.media3.datasource.AssetDataSource
2222+import androidx.media3.datasource.DataSpec
2323+import androidx.media3.datasource.FileDataSource
2424+import androidx.media3.datasource.FileDataSource.FileDataSourceException
2525+import androidx.media3.datasource.RawResourceDataSource
2626+2727+/**
2828+ * Converts [VideoPlayerMediaItem] to [android.net.Uri].
2929+ *
3030+ * @param context Pass application context or activity context. Use this context to load asset file using [android.content.res.AssetManager].
3131+ * @return [android.net.Uri]
3232+ */
3333+@SuppressLint("UnsafeOptInUsageError")
3434+internal fun VideoPlayerMediaItem.toUri(
3535+ context: Context,
3636+): Uri = when (this) {
3737+ is VideoPlayerMediaItem.RawResourceMediaItem -> {
3838+ RawResourceDataSource.buildRawResourceUri(resourceId)
3939+ }
4040+4141+ is VideoPlayerMediaItem.AssetFileMediaItem -> {
4242+ val dataSpec = DataSpec(Uri.parse("asset:///$assetPath"))
4343+ val assetDataSource = AssetDataSource(context)
4444+ try {
4545+ assetDataSource.open(dataSpec)
4646+ } catch (e: AssetDataSource.AssetDataSourceException) {
4747+ e.printStackTrace()
4848+ }
4949+5050+ assetDataSource.uri ?: Uri.EMPTY
5151+ }
5252+5353+ is VideoPlayerMediaItem.NetworkMediaItem -> {
5454+ Uri.parse(url)
5555+ }
5656+5757+ is VideoPlayerMediaItem.StorageMediaItem -> {
5858+ val dataSpec = DataSpec(storageUri)
5959+ val fileDataSource = FileDataSource()
6060+ try {
6161+ fileDataSource.open(dataSpec)
6262+ } catch (e: FileDataSourceException) {
6363+ e.printStackTrace()
6464+ }
6565+6666+ fileDataSource.uri ?: Uri.EMPTY
6767+ }
6868+}
···11+/*
22+ * Copyright 2023 Dora Lee
33+ *
44+ * Licensed under the Apache License, Version 2.0 (the "License");
55+ * you may not use this file except in compliance with the License.
66+ * You may obtain a copy of the License at
77+ *
88+ * https://www.apache.org/licenses/LICENSE-2.0
99+ *
1010+ * Unless required by applicable law or agreed to in writing, software
1111+ * distributed under the License is distributed on an "AS IS" BASIS,
1212+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313+ * See the License for the specific language governing permissions and
1414+ * limitations under the License.
1515+ */
1616+package io.sanghun.compose.video.util
1717+1818+import android.app.Activity
1919+import android.content.Context
2020+import android.content.ContextWrapper
2121+2222+/**
2323+ * The environment in which Compose is hosted may not be an activity unconditionally.
2424+ * Gets the current activity that is open from various kinds of contexts such as Fragment, Dialog, etc.
2525+ */
2626+internal fun Context.findActivity(): Activity {
2727+ var context = this
2828+ while (context is ContextWrapper) {
2929+ if (context is Activity) return context
3030+ context = context.baseContext
3131+ }
3232+ throw IllegalStateException("Activity not found. Unknown error.")
3333+}
···11+/*
22+ * Copyright 2023 Dora Lee
33+ *
44+ * Licensed under the Apache License, Version 2.0 (the "License");
55+ * you may not use this file except in compliance with the License.
66+ * You may obtain a copy of the License at
77+ *
88+ * https://www.apache.org/licenses/LICENSE-2.0
99+ *
1010+ * Unless required by applicable law or agreed to in writing, software
1111+ * distributed under the License is distributed on an "AS IS" BASIS,
1212+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313+ * See the License for the specific language governing permissions and
1414+ * limitations under the License.
1515+ */
1616+package io.sanghun.compose.video.util
1717+1818+import android.app.Activity
1919+import android.view.View
2020+import android.view.Window
2121+import androidx.core.view.WindowCompat
2222+import androidx.core.view.WindowInsetsCompat
2323+import androidx.core.view.WindowInsetsControllerCompat
2424+2525+/**
2626+ * Bring the activity to the full screen.
2727+ */
2828+internal fun Activity.setFullScreen(fullscreen: Boolean) {
2929+ window.setFullScreen(fullscreen)
3030+}
3131+3232+/**
3333+ * Bring the window to full screen. (Remove the status bar and navigation bar.)
3434+ */
3535+@Suppress("Deprecation")
3636+internal fun Window.setFullScreen(fullscreen: Boolean) {
3737+ if (fullscreen) {
3838+ this.hideSystemBars()
3939+ } else {
4040+ this.showSystemBars()
4141+ }
4242+}
4343+4444+private fun Window.hideSystemBars() {
4545+ WindowCompat.setDecorFitsSystemWindows(this, false)
4646+ // Without this deprecated systemUiVisibility, videos are not completely immersive mode in API 30.
4747+ // ie. navigation bars are visible, if this is removed.
4848+ this.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
4949+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
5050+ View.SYSTEM_UI_FLAG_IMMERSIVE
5151+ WindowCompat.getInsetsController(this, this.decorView).let { controller ->
5252+ controller.hide(WindowInsetsCompat.Type.systemBars())
5353+ controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
5454+ }
5555+}
5656+5757+private fun Window.showSystemBars() {
5858+ WindowCompat.setDecorFitsSystemWindows(this, true)
5959+ WindowCompat.getInsetsController(this, this.decorView).show(WindowInsetsCompat.Type.systemBars())
6060+}