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