Loading packages/SystemUI/src/com/android/systemui/screencapture/record/smallscreen/player/ui/compose/VideoPlayer.kt +12 −18 Original line number Diff line number Diff line Loading @@ -20,7 +20,6 @@ import android.net.Uri import androidx.compose.foundation.AndroidExternalSurface import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment Loading Loading @@ -49,25 +48,20 @@ constructor( videoPlayerViewModelFactory.create(uri) } val player = playerViewModel.player if (player == null) { Spacer(modifier = modifier) } else { Box(contentAlignment = Alignment.Center, modifier = modifier) { Box( modifier = Modifier.aspectRatio( player.videoWidth.toFloat() / player.videoHeight.toFloat() ) ) { val aspectRatioModifier: Modifier = playerViewModel.videoAspectRatio?.let { Modifier.aspectRatio(it) } ?: Modifier Box(modifier = aspectRatioModifier) { AndroidExternalSurface( onInit = { onSurface { surface, _, _ -> player.setSurface(surface) surface.onDestroyed { player.setSurface(null) } playerViewModel.onSurfaceCreated(surface) surface.onDestroyed { playerViewModel.onSurfaceDestroyed() } } } ) if (player != null) { val viewModel = rememberViewModel( traceName = "VideoPlayer#controlsViewModel", Loading packages/SystemUI/src/com/android/systemui/screencapture/record/smallscreen/player/ui/viewmodel/VideoPlayerControlsViewModel.kt +42 −24 Original line number Diff line number Diff line Loading @@ -38,7 +38,7 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive Loading @@ -51,60 +51,65 @@ constructor( ) : HydratedActivatable(), DrawableLoaderViewModel by drawableLoaderViewModelImpl { val videoDurationMillis: Int get() = mediaPlayer.duration get() = mediaPlayer.callSafe { duration } ?: 0 val videoPositionMillis: Int by mediaPlayer .currentPositionFlow(10.milliseconds) .hydratedStateOf("VideoPlayerControlsViewModel#videoPositionMillis", 0) var playing: Boolean by mutableStateOf(false) private set val playing: Boolean get() = mediaPlayer.callSafe { isPlaying } ?: false var muted: Boolean by mutableStateOf(false) private set private var wasPlayingBeforeSeek: Boolean = false private var wasPlayingBeforeSeek: Boolean? = null override suspend fun onActivated() { coroutineScope { mediaPlayer .onComplete() .onEach { mediaPlayer.seekTo(0) playing = false } .onEach { mediaPlayer.callSafe { mediaPlayer.seekTo(0) } } .launchIn(this) } } fun updatePlaying(isPlaying: Boolean) { mediaPlayer.callSafe { if (isPlaying) { mediaPlayer.start() start() } else { mediaPlayer.pause() pause() } } this.playing = isPlaying } fun seek(positionMillis: Int) { mediaPlayer.callSafe { if (wasPlayingBeforeSeek == null) { wasPlayingBeforeSeek = playing updatePlaying(false) mediaPlayer.seekTo(positionMillis) } seekTo(positionMillis.toLong(), MediaPlayer.SEEK_CLOSEST) } } fun seekFinished() { updatePlaying(wasPlayingBeforeSeek) wasPlayingBeforeSeek?.let(::updatePlaying) wasPlayingBeforeSeek = null } fun updateMuted(isMuted: Boolean) { mediaPlayer.callSafe { if (isMuted) { mediaPlayer.setVolume(0f) setVolume(0f) } else { mediaPlayer.setVolume(1f) setVolume(1f) } muted = isMuted } } @AssistedFactory interface Factory { Loading @@ -124,7 +129,9 @@ private fun MediaPlayer.currentPositionFlow(pollingDelay: Duration): Flow<Int> { setOnSeekCompleteListener(listener) awaitClose { setOnSeekCompleteListener(null) } } return merge(polling, seeking).map { currentPosition }.distinctUntilChanged() return merge(polling, seeking) .mapNotNull { callSafe { currentPosition } } .distinctUntilChanged() } private fun MediaPlayer.onComplete(): Flow<Unit> = callbackFlow { Loading @@ -132,3 +139,14 @@ private fun MediaPlayer.onComplete(): Flow<Unit> = callbackFlow { setOnCompletionListener(listener) awaitClose { setOnCompletionListener(null) } } /** * Unfortunately [MediaPlayer] API doesn't allow to check for the current player state, so there is * no good way to tell if it has been released already or not before calling a method. */ private fun <T> MediaPlayer.callSafe(action: MediaPlayer.() -> T): T? = try { action() } catch (_: IllegalStateException) { null } packages/SystemUI/src/com/android/systemui/screencapture/record/smallscreen/player/ui/viewmodel/VideoPlayerViewModel.kt +17 −9 Original line number Diff line number Diff line Loading @@ -20,10 +20,11 @@ import android.content.Context import android.media.AudioAttributes import android.media.MediaPlayer import android.net.Uri import android.view.Surface import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.android.app.tracing.coroutines.launchTraced import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.lifecycle.HydratedActivatable Loading @@ -31,7 +32,6 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext class VideoPlayerViewModel Loading @@ -45,16 +45,24 @@ constructor( var player: MediaPlayer? by mutableStateOf(null) private set override suspend fun onActivated() { coroutineScope { launchTraced("VideoPlayerViewModel#createPlayer") { player = createPlayer() } val videoAspectRatio: Float? by derivedStateOf { player?.run { videoWidth.toFloat() / videoHeight.toFloat() }?.takeIf { it > 0 } } val controlsViewModel: VideoPlayerControlsViewModel? = null suspend fun onSurfaceCreated(surface: Surface) { player = createPlayer().apply { setSurface(surface) start() } } override suspend fun onDeactivated() { player?.let { fun onSurfaceDestroyed() { player?.let { currentPlayer -> player = null it.release() currentPlayer.release() } } Loading packages/SystemUI/src/com/android/systemui/screencapture/record/smallscreen/ui/PostRecordSnackbarDialogs.kt +2 −2 Original line number Diff line number Diff line Loading @@ -142,7 +142,7 @@ private class SnackbarDialogDelegate(private val onDismissed: () -> Unit) : super.onCreate(dialog, savedInstanceState) dialog.setOnDismissListener { onDismissed() } with(dialog.window!!) { setGravity(Gravity.TOP) setGravity(Gravity.TOP or Gravity.CENTER_HORIZONTAL) addFlags( WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or Loading @@ -154,7 +154,7 @@ private class SnackbarDialogDelegate(private val onDismissed: () -> Unit) : } } override fun getWidth(dialog: SystemUIDialog): Int = WindowManager.LayoutParams.MATCH_PARENT override fun getWidth(dialog: SystemUIDialog): Int = WindowManager.LayoutParams.WRAP_CONTENT override fun getHeight(dialog: SystemUIDialog): Int = WindowManager.LayoutParams.WRAP_CONTENT } packages/SystemUI/src/com/android/systemui/screencapture/record/smallscreen/ui/SmallScreenPostRecordingActivity.kt +18 −2 Original line number Diff line number Diff line Loading @@ -84,6 +84,9 @@ constructor( private val systemUIDialogFactory: SystemUIDialogFactory, ) : ComponentActivity() { private val shouldShowVideoSaved: Boolean get() = intent.getBooleanExtra(SHOULD_SHOW_VIDEO_SAVED, SHOULD_SHOW_VIDEO_SAVED_DEFAULT) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() Loading @@ -98,7 +101,12 @@ constructor( viewModelFactory.create(intent.data ?: error("Data URI is missing")) } LaunchedEffect(Unit) { postRecordSnackbarDialogs.showVideoSaved() } LaunchedEffect(shouldShowVideoSaved) { if (shouldShowVideoSaved) { intent.putExtra(SHOULD_SHOW_VIDEO_SAVED, false) postRecordSnackbarDialogs.showVideoSaved() } } val shouldUseFlatBottomBar = booleanResource(R.bool.screen_record_post_recording_flat_bottom_bar) Loading Loading @@ -239,10 +247,18 @@ constructor( companion object { fun getStartingIntent(context: Context, videoUri: Uri): Intent { private const val SHOULD_SHOW_VIDEO_SAVED = "should_show_video_saved" private const val SHOULD_SHOW_VIDEO_SAVED_DEFAULT = false fun getStartingIntent( context: Context, videoUri: Uri, shouldShowVideoSaved: Boolean = SHOULD_SHOW_VIDEO_SAVED_DEFAULT, ): Intent { return Intent(context, SmallScreenPostRecordingActivity::class.java) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) .setDataAndType(videoUri, MimeTypes.VIDEO_MP4) .putExtra(SHOULD_SHOW_VIDEO_SAVED, shouldShowVideoSaved) } } } Loading Loading
packages/SystemUI/src/com/android/systemui/screencapture/record/smallscreen/player/ui/compose/VideoPlayer.kt +12 −18 Original line number Diff line number Diff line Loading @@ -20,7 +20,6 @@ import android.net.Uri import androidx.compose.foundation.AndroidExternalSurface import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment Loading Loading @@ -49,25 +48,20 @@ constructor( videoPlayerViewModelFactory.create(uri) } val player = playerViewModel.player if (player == null) { Spacer(modifier = modifier) } else { Box(contentAlignment = Alignment.Center, modifier = modifier) { Box( modifier = Modifier.aspectRatio( player.videoWidth.toFloat() / player.videoHeight.toFloat() ) ) { val aspectRatioModifier: Modifier = playerViewModel.videoAspectRatio?.let { Modifier.aspectRatio(it) } ?: Modifier Box(modifier = aspectRatioModifier) { AndroidExternalSurface( onInit = { onSurface { surface, _, _ -> player.setSurface(surface) surface.onDestroyed { player.setSurface(null) } playerViewModel.onSurfaceCreated(surface) surface.onDestroyed { playerViewModel.onSurfaceDestroyed() } } } ) if (player != null) { val viewModel = rememberViewModel( traceName = "VideoPlayer#controlsViewModel", Loading
packages/SystemUI/src/com/android/systemui/screencapture/record/smallscreen/player/ui/viewmodel/VideoPlayerControlsViewModel.kt +42 −24 Original line number Diff line number Diff line Loading @@ -38,7 +38,7 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive Loading @@ -51,60 +51,65 @@ constructor( ) : HydratedActivatable(), DrawableLoaderViewModel by drawableLoaderViewModelImpl { val videoDurationMillis: Int get() = mediaPlayer.duration get() = mediaPlayer.callSafe { duration } ?: 0 val videoPositionMillis: Int by mediaPlayer .currentPositionFlow(10.milliseconds) .hydratedStateOf("VideoPlayerControlsViewModel#videoPositionMillis", 0) var playing: Boolean by mutableStateOf(false) private set val playing: Boolean get() = mediaPlayer.callSafe { isPlaying } ?: false var muted: Boolean by mutableStateOf(false) private set private var wasPlayingBeforeSeek: Boolean = false private var wasPlayingBeforeSeek: Boolean? = null override suspend fun onActivated() { coroutineScope { mediaPlayer .onComplete() .onEach { mediaPlayer.seekTo(0) playing = false } .onEach { mediaPlayer.callSafe { mediaPlayer.seekTo(0) } } .launchIn(this) } } fun updatePlaying(isPlaying: Boolean) { mediaPlayer.callSafe { if (isPlaying) { mediaPlayer.start() start() } else { mediaPlayer.pause() pause() } } this.playing = isPlaying } fun seek(positionMillis: Int) { mediaPlayer.callSafe { if (wasPlayingBeforeSeek == null) { wasPlayingBeforeSeek = playing updatePlaying(false) mediaPlayer.seekTo(positionMillis) } seekTo(positionMillis.toLong(), MediaPlayer.SEEK_CLOSEST) } } fun seekFinished() { updatePlaying(wasPlayingBeforeSeek) wasPlayingBeforeSeek?.let(::updatePlaying) wasPlayingBeforeSeek = null } fun updateMuted(isMuted: Boolean) { mediaPlayer.callSafe { if (isMuted) { mediaPlayer.setVolume(0f) setVolume(0f) } else { mediaPlayer.setVolume(1f) setVolume(1f) } muted = isMuted } } @AssistedFactory interface Factory { Loading @@ -124,7 +129,9 @@ private fun MediaPlayer.currentPositionFlow(pollingDelay: Duration): Flow<Int> { setOnSeekCompleteListener(listener) awaitClose { setOnSeekCompleteListener(null) } } return merge(polling, seeking).map { currentPosition }.distinctUntilChanged() return merge(polling, seeking) .mapNotNull { callSafe { currentPosition } } .distinctUntilChanged() } private fun MediaPlayer.onComplete(): Flow<Unit> = callbackFlow { Loading @@ -132,3 +139,14 @@ private fun MediaPlayer.onComplete(): Flow<Unit> = callbackFlow { setOnCompletionListener(listener) awaitClose { setOnCompletionListener(null) } } /** * Unfortunately [MediaPlayer] API doesn't allow to check for the current player state, so there is * no good way to tell if it has been released already or not before calling a method. */ private fun <T> MediaPlayer.callSafe(action: MediaPlayer.() -> T): T? = try { action() } catch (_: IllegalStateException) { null }
packages/SystemUI/src/com/android/systemui/screencapture/record/smallscreen/player/ui/viewmodel/VideoPlayerViewModel.kt +17 −9 Original line number Diff line number Diff line Loading @@ -20,10 +20,11 @@ import android.content.Context import android.media.AudioAttributes import android.media.MediaPlayer import android.net.Uri import android.view.Surface import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.android.app.tracing.coroutines.launchTraced import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.lifecycle.HydratedActivatable Loading @@ -31,7 +32,6 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext class VideoPlayerViewModel Loading @@ -45,16 +45,24 @@ constructor( var player: MediaPlayer? by mutableStateOf(null) private set override suspend fun onActivated() { coroutineScope { launchTraced("VideoPlayerViewModel#createPlayer") { player = createPlayer() } val videoAspectRatio: Float? by derivedStateOf { player?.run { videoWidth.toFloat() / videoHeight.toFloat() }?.takeIf { it > 0 } } val controlsViewModel: VideoPlayerControlsViewModel? = null suspend fun onSurfaceCreated(surface: Surface) { player = createPlayer().apply { setSurface(surface) start() } } override suspend fun onDeactivated() { player?.let { fun onSurfaceDestroyed() { player?.let { currentPlayer -> player = null it.release() currentPlayer.release() } } Loading
packages/SystemUI/src/com/android/systemui/screencapture/record/smallscreen/ui/PostRecordSnackbarDialogs.kt +2 −2 Original line number Diff line number Diff line Loading @@ -142,7 +142,7 @@ private class SnackbarDialogDelegate(private val onDismissed: () -> Unit) : super.onCreate(dialog, savedInstanceState) dialog.setOnDismissListener { onDismissed() } with(dialog.window!!) { setGravity(Gravity.TOP) setGravity(Gravity.TOP or Gravity.CENTER_HORIZONTAL) addFlags( WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or Loading @@ -154,7 +154,7 @@ private class SnackbarDialogDelegate(private val onDismissed: () -> Unit) : } } override fun getWidth(dialog: SystemUIDialog): Int = WindowManager.LayoutParams.MATCH_PARENT override fun getWidth(dialog: SystemUIDialog): Int = WindowManager.LayoutParams.WRAP_CONTENT override fun getHeight(dialog: SystemUIDialog): Int = WindowManager.LayoutParams.WRAP_CONTENT }
packages/SystemUI/src/com/android/systemui/screencapture/record/smallscreen/ui/SmallScreenPostRecordingActivity.kt +18 −2 Original line number Diff line number Diff line Loading @@ -84,6 +84,9 @@ constructor( private val systemUIDialogFactory: SystemUIDialogFactory, ) : ComponentActivity() { private val shouldShowVideoSaved: Boolean get() = intent.getBooleanExtra(SHOULD_SHOW_VIDEO_SAVED, SHOULD_SHOW_VIDEO_SAVED_DEFAULT) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() Loading @@ -98,7 +101,12 @@ constructor( viewModelFactory.create(intent.data ?: error("Data URI is missing")) } LaunchedEffect(Unit) { postRecordSnackbarDialogs.showVideoSaved() } LaunchedEffect(shouldShowVideoSaved) { if (shouldShowVideoSaved) { intent.putExtra(SHOULD_SHOW_VIDEO_SAVED, false) postRecordSnackbarDialogs.showVideoSaved() } } val shouldUseFlatBottomBar = booleanResource(R.bool.screen_record_post_recording_flat_bottom_bar) Loading Loading @@ -239,10 +247,18 @@ constructor( companion object { fun getStartingIntent(context: Context, videoUri: Uri): Intent { private const val SHOULD_SHOW_VIDEO_SAVED = "should_show_video_saved" private const val SHOULD_SHOW_VIDEO_SAVED_DEFAULT = false fun getStartingIntent( context: Context, videoUri: Uri, shouldShowVideoSaved: Boolean = SHOULD_SHOW_VIDEO_SAVED_DEFAULT, ): Intent { return Intent(context, SmallScreenPostRecordingActivity::class.java) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) .setDataAndType(videoUri, MimeTypes.VIDEO_MP4) .putExtra(SHOULD_SHOW_VIDEO_SAVED, shouldShowVideoSaved) } } } Loading