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