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