• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.photopicker.features.preview
18 
19 import android.content.ContentResolver.EXTRA_SIZE
20 import android.graphics.Point
21 import android.media.AudioAttributes
22 import android.media.AudioFocusRequest
23 import android.media.AudioManager
24 import android.os.Bundle
25 import android.os.RemoteException
26 import android.provider.CloudMediaProviderContract.EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED
27 import android.util.Log
28 import android.view.Surface
29 import android.view.SurfaceHolder
30 import android.view.SurfaceView
31 import android.view.View
32 import android.widget.FrameLayout
33 import androidx.compose.animation.AnimatedVisibility
34 import androidx.compose.animation.fadeIn
35 import androidx.compose.animation.fadeOut
36 import androidx.compose.foundation.clickable
37 import androidx.compose.foundation.layout.Box
38 import androidx.compose.foundation.layout.aspectRatio
39 import androidx.compose.foundation.layout.fillMaxSize
40 import androidx.compose.foundation.layout.padding
41 import androidx.compose.foundation.layout.size
42 import androidx.compose.material.icons.Icons
43 import androidx.compose.material.icons.automirrored.outlined.VolumeOff
44 import androidx.compose.material.icons.automirrored.outlined.VolumeUp
45 import androidx.compose.material.icons.outlined.Pause
46 import androidx.compose.material.icons.outlined.PlayArrow
47 import androidx.compose.material3.CircularProgressIndicator
48 import androidx.compose.material3.FilledTonalIconButton
49 import androidx.compose.material3.Icon
50 import androidx.compose.material3.IconButton
51 import androidx.compose.material3.IconButtonDefaults
52 import androidx.compose.material3.SnackbarHostState
53 import androidx.compose.material3.Surface
54 import androidx.compose.runtime.Composable
55 import androidx.compose.runtime.DisposableEffect
56 import androidx.compose.runtime.LaunchedEffect
57 import androidx.compose.runtime.State
58 import androidx.compose.runtime.getValue
59 import androidx.compose.runtime.mutableStateOf
60 import androidx.compose.runtime.produceState
61 import androidx.compose.runtime.remember
62 import androidx.compose.runtime.rememberCoroutineScope
63 import androidx.compose.runtime.setValue
64 import androidx.compose.ui.Alignment
65 import androidx.compose.ui.Modifier
66 import androidx.compose.ui.graphics.Color
67 import androidx.compose.ui.platform.LocalContext
68 import androidx.compose.ui.res.stringResource
69 import androidx.compose.ui.semantics.contentDescription
70 import androidx.compose.ui.semantics.semantics
71 import androidx.compose.ui.unit.dp
72 import androidx.compose.ui.viewinterop.AndroidView
73 import androidx.core.os.bundleOf
74 import com.android.photopicker.R
75 import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration
76 import com.android.photopicker.core.events.Event
77 import com.android.photopicker.core.events.LocalEvents
78 import com.android.photopicker.core.events.Telemetry
79 import com.android.photopicker.core.features.FeatureToken
80 import com.android.photopicker.core.obtainViewModel
81 import com.android.photopicker.data.model.Media
82 import com.android.photopicker.extensions.requireSystemService
83 import kotlinx.coroutines.delay
84 import kotlinx.coroutines.flow.filter
85 import kotlinx.coroutines.launch
86 
87 /** [AudioAttributes] to use with all VideoUi instances. */
88 private val AUDIO_ATTRIBUTES =
89     AudioAttributes.Builder()
90         .apply {
91             setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
92             setUsage(AudioAttributes.USAGE_MEDIA)
93         }
94         .build()
95 
96 /** The size of the Play/Pause button in the center of the video controls */
97 private val MEASUREMENT_PLAY_PAUSE_ICON_SIZE = 48.dp
98 
99 /** Padding between the edge of the screen and the Player controls box. */
100 private val MEASUREMENT_PLAYER_CONTROLS_PADDING_HORIZONTAL = 8.dp
101 private val MEASUREMENT_PLAYER_CONTROLS_PADDING_VERTICAL = 12.dp
102 
103 /** Delay in milliseconds before the player controls are faded. */
104 private val TIME_MS_PLAYER_CONTROLS_FADE_DELAY = 5000L
105 
106 /**
107  * Builds a remote video player surface and handles the interactions with the
108  * [RemoteSurfaceController] for remote video playback.
109  *
110  * This composable is the entry point into creating a remote player for Photopicker video sources.
111  * It utilizes the remote preview functionality of [CloudMediaProvider] to expose a [Surface] to a
112  * remote process.
113  *
114  * @param video The video to prepare and play
115  * @param audioIsMuted a preview session-global of the audio mute state
116  * @param onRequestAudioMuteChange a callback to request a switch of the [audioIsMuted] state
117  * @param viewModel The current instance of the [PreviewViewModel], injected by hilt.
118  */
119 @Composable
VideoUinull120 fun VideoUi(
121     video: Media.Video,
122     audioIsMuted: Boolean,
123     onRequestAudioMuteChange: (Boolean) -> Unit,
124     snackbarHostState: SnackbarHostState,
125     singleItemPreview: Boolean,
126     contentDescriptionString: String,
127     viewModel: PreviewViewModel = obtainViewModel(),
128 ) {
129 
130     /**
131      * The controller is remembered based on the authority so it is efficiently re-used for videos
132      * from the same authority. The view model also caches surface controllers to avoid re-creating
133      * them.
134      */
135     val controller =
136         remember(video.authority) { viewModel.getControllerForAuthority(video.authority) }
137 
138     /** Obtain a surfaceId which will identify this VideoUi's surface to the remote player. */
139     val surfaceId = remember(video) { controller.getNextSurfaceId() }
140 
141     /** The visibility of the player controls for this video */
142     var areControlsVisible by remember { mutableStateOf(false) }
143 
144     /** If the underlying video surface has been created */
145     var surfaceCreated by remember(video) { mutableStateOf(false) }
146 
147     /** Whether the [RetriableErrorDialog] is visible. */
148     var showErrorDialog by remember { mutableStateOf(false) }
149 
150     /** Producer for [PlaybackInfo] for the current video surface */
151     val playbackInfo by producePlaybackInfo(surfaceId, video)
152 
153     /** Producer for AspectRatio for the current video surface */
154     val aspectRatio by produceAspectRatio(surfaceId, video)
155 
156     val context = LocalContext.current
157     val scope = rememberCoroutineScope()
158     val events = LocalEvents.current
159     val configuration = LocalPhotopickerConfiguration.current
160 
161     // Log that the video audio is muted
162     if (singleItemPreview && audioIsMuted) {
163         LaunchedEffect(video) {
164             events.dispatch(
165                 Event.LogPhotopickerPreviewInfo(
166                     FeatureToken.PREVIEW.token,
167                     configuration.sessionId,
168                     Telemetry.PreviewModeEntry.LONG_PRESS,
169                     previewItemCount = 1,
170                     Telemetry.MediaType.VIDEO,
171                     Telemetry.VideoPlayBackInteractions.MUTE,
172                 )
173             )
174         }
175     }
176 
177     /** Run these effects when a new PlaybackInfo is received */
178     LaunchedEffect(playbackInfo) {
179         when (playbackInfo.state) {
180             PlaybackState.READY -> {
181                 // When the controller indicates the video is ready to be played,
182                 // immediately request for it to begin playing.
183                 controller.onMediaPlay(surfaceId)
184             }
185             PlaybackState.STARTED -> {
186                 // When playback starts, show the controls to the user.
187                 areControlsVisible = true
188             }
189             PlaybackState.ERROR_RETRIABLE_FAILURE -> {
190                 // The remote player has indicated a retriable failure, so show the
191                 // error dialog.
192                 showErrorDialog = true
193             }
194             PlaybackState.ERROR_PERMANENT_FAILURE -> {
195                 snackbarHostState.showSnackbar(
196                     context.getString(R.string.photopicker_preview_video_error_snackbar)
197                 )
198             }
199             else -> {}
200         }
201     }
202 
203     // Acquire audio focus for the player, and establish a callback to change audio mute status.
204     val onAudioMuteToggle =
205         rememberAudioFocus(
206             video,
207             surfaceCreated,
208             audioIsMuted,
209             onFocusLost = {
210                 try {
211                     controller.onMediaPause(surfaceId)
212                 } catch (e: RemoteException) {
213                     Log.d(PreviewFeature.TAG, "Failed to pause media when audio focus was lost.")
214                 }
215             },
216             onConfigChangeRequested = { bundle -> controller.onConfigChange(bundle) },
217             onRequestAudioMuteChange = onRequestAudioMuteChange,
218         )
219 
220     // Finally! Now the actual VideoPlayer can be created! \0/
221     // This is the top level box of the player, and all of its children are drawn on-top
222     // of each other.
223     Box(Modifier.semantics { contentDescription = contentDescriptionString }) {
224         VideoPlayer(
225             aspectRatio = aspectRatio,
226             playbackInfo = playbackInfo,
227             muteAudio = audioIsMuted,
228             areControlsVisible = areControlsVisible,
229             onPlayPause = {
230                 when (playbackInfo.state) {
231                     PlaybackState.STARTED -> {
232                         if (singleItemPreview) {
233                             // Log video playback interactions
234                             scope.launch {
235                                 events.dispatch(
236                                     Event.LogPhotopickerPreviewInfo(
237                                         FeatureToken.PREVIEW.token,
238                                         configuration.sessionId,
239                                         Telemetry.PreviewModeEntry.LONG_PRESS,
240                                         previewItemCount = 1,
241                                         Telemetry.MediaType.VIDEO,
242                                         Telemetry.VideoPlayBackInteractions.PLAY,
243                                     )
244                                 )
245                             }
246                         }
247                         controller.onMediaPause(surfaceId)
248                     }
249                     PlaybackState.PAUSED -> {
250                         if (singleItemPreview) {
251                             scope.launch {
252                                 events.dispatch(
253                                     Event.LogPhotopickerPreviewInfo(
254                                         FeatureToken.PREVIEW.token,
255                                         configuration.sessionId,
256                                         Telemetry.PreviewModeEntry.LONG_PRESS,
257                                         previewItemCount = 1,
258                                         Telemetry.MediaType.VIDEO,
259                                         Telemetry.VideoPlayBackInteractions.PAUSE,
260                                     )
261                                 )
262                             }
263                         }
264                         controller.onMediaPlay(surfaceId)
265                     }
266                     else -> {}
267                 }
268             },
269             onToggleAudioMute = { onAudioMuteToggle(audioIsMuted) },
270             onTogglePlayerControls = { areControlsVisible = !areControlsVisible },
271             onSurfaceCreated = { surface ->
272                 controller.onSurfaceCreated(surfaceId, surface, video.mediaId)
273                 surfaceCreated = true
274             },
275             onSurfaceChanged = { format, width, height ->
276                 controller.onSurfaceChanged(surfaceId, format, width, height)
277             },
278             onSurfaceDestroyed = { controller.onSurfaceDestroyed(surfaceId) },
279         )
280     }
281 
282     // If the Error dialog is needed, launch the dialog.
283     if (showErrorDialog) {
284         RetriableErrorDialog(
285             onDismissRequest = { showErrorDialog = false },
286             onRetry = {
287                 showErrorDialog = !showErrorDialog
288                 controller.onMediaPlay(surfaceId)
289             },
290         )
291     }
292 }
293 
294 /**
295  * Composable that creates the video SurfaceView and player controls. The VideoPlayer itself is
296  * stateless, and handles showing loading indicators and player controls when requested by the
297  * parent.
298  *
299  * It hoists a number of events for the parent to handle:
300  * - Button/UI touch interactions
301  * - the underlying video surface's lifecycle events.
302  *
303  * @param aspectRatio the aspectRatio of the video to be played. (Null until it is known)
304  * @param playbackInfo the current PlaybackState from the remote controller
305  * @param muteAudio if the audio is currently muted
306  * @param areControlsVisible if the controls are currently visible
307  * @param onPlayPause Callback for the Play/Pause button
308  * @param onToggleAudioMute Callback for the Audio mute/unmute button
309  * @param onTogglePlayerControls Callback for toggling the player controls visibility
310  * @param onSurfaceCreated Callback for the underlying [SurfaceView] lifecycle
311  * @param onSurfaceChanged Callback for the underlying [SurfaceView] lifecycle
312  * @param onSurfaceDestroyed Callback for the underlying [SurfaceView] lifecycle
313  */
314 @Composable
VideoPlayernull315 private fun VideoPlayer(
316     aspectRatio: Float?,
317     playbackInfo: PlaybackInfo,
318     muteAudio: Boolean,
319     areControlsVisible: Boolean,
320     onPlayPause: () -> Unit,
321     onToggleAudioMute: () -> Unit,
322     onTogglePlayerControls: () -> Unit,
323     onSurfaceCreated: (Surface) -> Unit,
324     onSurfaceChanged: (format: Int, width: Int, height: Int) -> Unit,
325     onSurfaceDestroyed: () -> Unit,
326 ) {
327 
328     // Clicking anywhere on the player should toggle the visibility of the controls.
329     Box(Modifier.fillMaxSize().clickable { onTogglePlayerControls() }) {
330         val modifier =
331             if (aspectRatio != null) Modifier.aspectRatio(aspectRatio).align(Alignment.Center)
332             else Modifier.align(Alignment.Center)
333         VideoSurfaceView(
334             modifier = modifier,
335             playerSizeSet = aspectRatio != null,
336             onSurfaceCreated = onSurfaceCreated,
337             onSurfaceChanged = onSurfaceChanged,
338             onSurfaceDestroyed = onSurfaceDestroyed,
339         )
340 
341         // Auto hides the controls after the delay has passed (if they are still visible).
342         LaunchedEffect(areControlsVisible) {
343             if (areControlsVisible) {
344                 delay(TIME_MS_PLAYER_CONTROLS_FADE_DELAY)
345                 onTogglePlayerControls()
346             }
347         }
348 
349         // Overlay the playback controls
350         VideoPlayerControls(
351             visible = areControlsVisible,
352             currentPlaybackState = playbackInfo.state,
353             onPlayPauseClicked = onPlayPause,
354             audioIsMuted = muteAudio,
355             onToggleAudioMute = onToggleAudioMute,
356         )
357 
358         Box(Modifier.fillMaxSize()) {
359             /** Conditional UI based on the current [PlaybackInfo] */
360             when (playbackInfo.state) {
361                 PlaybackState.UNKNOWN,
362                 PlaybackState.BUFFERING -> {
363                     CircularProgressIndicator(
364                         color = Color.White,
365                         modifier = Modifier.align(Alignment.Center),
366                     )
367                 }
368                 else -> {}
369             }
370         }
371     }
372 }
373 
374 /**
375  * Composes a [SurfaceView] for remote video rendering via the [CloudMediaProvider]'s remote video
376  * preview Binder process.
377  *
378  * The [SurfaceView] itself is wrapped inside of a compose interop [AndroidView] which wraps a
379  * [FrameLayout] for managing visibility, and then the [SurfaceView] itself. The SurfaceView
380  * attaches its own [SurfaceHolder.Callback] and hoists those events out of this composable for the
381  * parent to handle.
382  *
383  * @param modifier A modifier which can be used to position the SurfaceView inside of the parent.
384  * @param playerSizeSet Indicates the aspectRatio and size of the surface has been set by the
385  *   parent.
386  * @param onSurfaceCreated Surface lifecycle callback when the underlying surface has been created.
387  * @param onSurfaceChanged Surface lifecycle callback when the underlying surface has been changed.
388  * @param onSurfaceDestroyed Surface lifecycle callback when the underlying surface has been
389  *   destroyed.
390  */
391 @Composable
VideoSurfaceViewnull392 private fun VideoSurfaceView(
393     modifier: Modifier = Modifier,
394     playerSizeSet: Boolean,
395     onSurfaceCreated: (Surface) -> Unit,
396     onSurfaceChanged: (format: Int, width: Int, height: Int) -> Unit,
397     onSurfaceDestroyed: () -> Unit,
398 ) {
399 
400     /**
401      * [SurfaceView] is not available in compose, however the remote video preview with the cloud
402      * provider requires a [Surface] object passed via Binder.
403      *
404      * The SurfaceView is instead wrapped in this [AndroidView] compose inter-op and behaves like a
405      * normal SurfaceView.
406      */
407     AndroidView(
408         /** Factory is called once on first compose, and never again */
409         modifier = modifier,
410         factory = { context ->
411 
412             // The [FrameLayout] will manage sizing the SurfaceView since it uses a LayoutParam of
413             // [MATCH_PARENT] by default, it doesn't need to be explicitly set.
414             FrameLayout(context).apply {
415 
416                 // Add a child view to the FrameLayout which is the [SurfaceView] itself.
417                 addView(
418                     SurfaceView(context).apply {
419                         /**
420                          * The SurfaceHolder callback is held by the SurfaceView itself, and is
421                          * directly attached to this view's SurfaceHolder, so that each SurfaceView
422                          * has its own SurfaceHolder.Callback associated with it.
423                          */
424                         val surfaceCallback =
425                             object : SurfaceHolder.Callback {
426 
427                                 override fun surfaceCreated(holder: SurfaceHolder) {
428                                     onSurfaceCreated(holder.getSurface())
429                                 }
430 
431                                 override fun surfaceChanged(
432                                     holder: SurfaceHolder,
433                                     format: Int,
434                                     width: Int,
435                                     height: Int,
436                                 ) {
437                                     onSurfaceChanged(format, width, height)
438                                 }
439 
440                                 override fun surfaceDestroyed(holder: SurfaceHolder) {
441                                     onSurfaceDestroyed()
442                                 }
443                             }
444 
445                         // Ensure the SurfaceView never draws outside of its parent's bounds.
446                         setClipToOutline(true)
447 
448                         getHolder().addCallback(surfaceCallback)
449                     }
450                 )
451 
452                 // Initially hide the view until there is a aspect ratio set to avoid any visual
453                 // snapping to position.
454                 setVisibility(View.INVISIBLE)
455             }
456         },
457         update = { view ->
458             // Once the parent has indicated the size has been set, make the player visible.
459             if (playerSizeSet) {
460                 view.setVisibility(View.VISIBLE)
461             }
462         },
463     )
464 }
465 
466 /**
467  * Composable which generates the Video controls UI and handles displaying / fading the controls
468  * when the visibility changes.
469  *
470  * @param visible Whether the controls are currently visible.
471  * @param currentPlaybackState the current [PlaybackInfo] of the player.
472  * @param onPlayPauseClicked Click handler for the Play/Pause button
473  * @param audioIsMuted The current audio mute state (true if muted)
474  * @param onToggleAudioMute Click handler for the audio mute button.
475  */
476 @Composable
VideoPlayerControlsnull477 private fun VideoPlayerControls(
478     visible: Boolean,
479     currentPlaybackState: PlaybackState,
480     onPlayPauseClicked: () -> Unit,
481     audioIsMuted: Boolean,
482     onToggleAudioMute: () -> Unit,
483 ) {
484 
485     AnimatedVisibility(
486         visible = visible,
487         modifier = Modifier.fillMaxSize(),
488         enter = fadeIn(),
489         exit = fadeOut(),
490     ) {
491         // Box to draw everything on top of the video surface which is underneath.
492         Box(
493             Modifier.padding(
494                 vertical = MEASUREMENT_PLAYER_CONTROLS_PADDING_VERTICAL,
495                 horizontal = MEASUREMENT_PLAYER_CONTROLS_PADDING_HORIZONTAL,
496             )
497         ) {
498             // Play / Pause button (center of the screen)
499             FilledTonalIconButton(
500                 modifier = Modifier.align(Alignment.Center).size(MEASUREMENT_PLAY_PAUSE_ICON_SIZE),
501                 onClick = { onPlayPauseClicked() },
502                 colors =
503                     IconButtonDefaults.filledTonalIconButtonColors(
504                         containerColor = Color.Black.copy(alpha = 0.4f),
505                         contentColor = Color.White,
506                     ),
507             ) {
508                 when (currentPlaybackState) {
509                     PlaybackState.STARTED ->
510                         Icon(
511                             Icons.Outlined.Pause,
512                             contentDescription =
513                                 stringResource(R.string.photopicker_video_pause_button_description),
514                         )
515                     else ->
516                         Icon(
517                             Icons.Outlined.PlayArrow,
518                             contentDescription =
519                                 stringResource(R.string.photopicker_video_play_button_description),
520                         )
521                 }
522             }
523 
524             // Mute / UnMute button (bottom right for LTR layouts)
525             IconButton(
526                 modifier = Modifier.align(Alignment.BottomEnd),
527                 onClick = onToggleAudioMute,
528             ) {
529                 when (audioIsMuted) {
530                     false ->
531                         Icon(
532                             Icons.AutoMirrored.Outlined.VolumeUp,
533                             contentDescription =
534                                 stringResource(R.string.photopicker_video_mute_button_description),
535                             tint = Color.White,
536                         )
537                     true ->
538                         Icon(
539                             Icons.AutoMirrored.Outlined.VolumeOff,
540                             contentDescription =
541                                 stringResource(
542                                     R.string.photopicker_video_unmute_button_description
543                                 ),
544                             tint = Color.White,
545                         )
546                 }
547             }
548         }
549     }
550 }
551 
552 /**
553  * Acquire and remember the audio focus for the current composable context.
554  *
555  * This composable encapsulates all of the audio focus / abandon focus logic for the VideoUi. Focus
556  * is managed via [AudioManager] and this composable will react to changes to [audioIsMuted] and
557  * request (in the event video players have switched) / or abandon focus accordingly.
558  *
559  * @param video The current video being played
560  * @param surfaceCreated If the video surface has been created
561  * @param audioIsMuted if the audio is currently muted
562  * @param onFocusLost Callback for when the AudioManager informs the audioListener that focus has
563  *   been lost.
564  * @param onConfigChangeRequested Callback for when the controller's configuration needs to be
565  *   updated
566  * @param onRequestAudioMuteChange Callback to request audio mute state change
567  * @return Additionally, return a function which should be called to toggle the current audio mute
568  *   status of the player. Utilizing the provided callbacks to update the controller configuration,
569  *   this ensures the correct requests are sent to [AudioManager] before the players are unmuted /
570  *   muted.
571  */
572 @Composable
rememberAudioFocusnull573 private fun rememberAudioFocus(
574     video: Media.Video,
575     surfaceCreated: Boolean,
576     audioIsMuted: Boolean,
577     onFocusLost: () -> Unit,
578     onConfigChangeRequested: (Bundle) -> Unit,
579     onRequestAudioMuteChange: (Boolean) -> Unit,
580 ): (Boolean) -> Unit {
581 
582     val context = LocalContext.current
583     val audioManager: AudioManager = remember { context.requireSystemService() }
584 
585     /** [OnAudioFocusChangeListener] unique to this remote player (authority based) */
586     val audioListener =
587         remember(video.authority) {
588             object : AudioManager.OnAudioFocusChangeListener {
589                 override fun onAudioFocusChange(focusChange: Int) {
590                     if (
591                         focusChange == AudioManager.AUDIOFOCUS_LOSS ||
592                             focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT ||
593                             focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK
594                     ) {
595                         onFocusLost()
596                     }
597                 }
598             }
599         }
600 
601     /** [AudioFocusRequest] unique to this remote player (authority based) */
602     val audioRequest =
603         remember(video.authority) {
604             AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
605                 .apply {
606                     setAudioAttributes(AUDIO_ATTRIBUTES)
607                     setWillPauseWhenDucked(true)
608                     setAcceptsDelayedFocusGain(true)
609                     setOnAudioFocusChangeListener(audioListener)
610                 }
611                 .build()
612         }
613 
614     // Wait for the video surface to be created before setting up audio focus for the player.
615     // This is required because the Player may not exist yet if this is the first / only active
616     // surface for this controller.
617     if (surfaceCreated) {
618 
619         // A DisposableEffect is needed here to ensure the audio focus is abandoned
620         // when this composable leaves the view. Otherwise, AudioManager will continue
621         // to make calls to the callback which can potentially cause runtime errors,
622         // and audio may continue to play until the underlying video surface gets
623         // destroyed.
624         DisposableEffect(video.authority) {
625 
626             // Additionally, any time the current video's authority is different from the
627             // last compose, set the audio state on the current controller to match the
628             // session's audio state.
629             val bundle =
630                 when (audioIsMuted) {
631                     true -> bundleOf(EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED to true)
632                     false -> bundleOf(EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED to false)
633                 }
634             onConfigChangeRequested(bundle)
635 
636             // If the audio currently isn't muted, then request audio focus again with the new
637             // request to ensure callbacks are received.
638             if (!audioIsMuted) {
639                 audioManager.requestAudioFocus(audioRequest)
640             }
641 
642             // When the composable leaves the tree, cleanup the audio request to prevent any
643             // audio from playing while the screen isn't being shown to the user.
644             onDispose {
645                 Log.d(PreviewFeature.TAG, "Abandoning audio focus for authority $video.authority")
646                 audioManager.abandonAudioFocusRequest(audioRequest)
647             }
648         }
649     }
650 
651     /** Return a function that can be used to toggle the mute status of the composable */
652     return { currentlyMuted: Boolean ->
653         when (currentlyMuted) {
654             true -> {
655                 if (
656                     audioManager.requestAudioFocus(audioRequest) ==
657                         AudioManager.AUDIOFOCUS_REQUEST_GRANTED
658                 ) {
659                     Log.d(PreviewFeature.TAG, "Acquired audio focus to unmute player")
660                     val bundle = bundleOf(EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED to false)
661                     onConfigChangeRequested(bundle)
662                     onRequestAudioMuteChange(false)
663                 }
664             }
665             false -> {
666                 Log.d(PreviewFeature.TAG, "Abandoning audio focus and muting player")
667                 audioManager.abandonAudioFocusRequest(audioRequest)
668                 val bundle = bundleOf(EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED to true)
669                 onConfigChangeRequested(bundle)
670                 onRequestAudioMuteChange(true)
671             }
672         }
673     }
674 }
675 
676 /**
677  * State produce for a video's [PlaybackInfo].
678  *
679  * This producer listens to all [PlaybackState] updates for the given video and surface, and
680  * produces the most recent update as observable composable [State].
681  *
682  * @param surfaceId the id of the player's surface.
683  * @param video the video to calculate the aspect ratio for. @viewModel an instance of
684  *   [PreviewViewModel], this is injected by hilt.
685  * @return observable composable state object that yields the most recent [PlaybackInfo].
686  */
687 @Composable
producePlaybackInfonull688 private fun producePlaybackInfo(
689     surfaceId: Int,
690     video: Media.Video,
691     viewModel: PreviewViewModel = obtainViewModel(),
692 ): State<PlaybackInfo> {
693 
694     return produceState<PlaybackInfo>(
695         initialValue =
696             PlaybackInfo(state = PlaybackState.UNKNOWN, surfaceId, authority = video.authority),
697         surfaceId,
698         video,
699     ) {
700         viewModel.getPlaybackInfoForPlayer(surfaceId, video).collect { playbackInfo ->
701             Log.d(PreviewFeature.TAG, "PlaybackState change received: $playbackInfo")
702             value = playbackInfo
703         }
704     }
705 }
706 
707 /**
708  * State producer for a video's AspectRatio.
709  *
710  * This producer listens to the controller's [PlaybackState] flow and extracts any
711  * [MEDIA_SIZE_CHANGED] events for the given surfaceId and video and produces the correct aspect
712  * ratio for the video as composable [State]
713  *
714  * @param surfaceId the id of the player's surface.
715  * @param video the video to calculate the aspect ratio for. @viewModel an instance of
716  *   [PreviewViewModel], this is injected by hilt.
717  * @return observable composable state object that yields the correct AspectRatio
718  */
719 @Composable
produceAspectRationull720 private fun produceAspectRatio(
721     surfaceId: Int,
722     video: Media.Video,
723     viewModel: PreviewViewModel = obtainViewModel(),
724 ): State<Float?> {
725 
726     return produceState<Float?>(initialValue = null, surfaceId, video) {
727         viewModel
728             .getPlaybackInfoForPlayer(surfaceId, video)
729             .filter { it.state == PlaybackState.MEDIA_SIZE_CHANGED }
730             .collect { playbackInfo ->
731                 val size: Point? =
732                     playbackInfo.playbackStateInfo?.getParcelable(EXTRA_SIZE, Point::class.java)
733                 size?.let {
734                     // AspectRatio = Width divided by height as a float
735                     Log.d(PreviewFeature.TAG, "Media Size change received: ${size.x} x ${size.y}")
736                     value = size.x.toFloat() / size.y.toFloat()
737                 }
738             }
739     }
740 }
741