Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 6104d63a authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "Add video player to post recording flow to play recording preview" into main

parents 33d33746 52964ac6
Loading
Loading
Loading
Loading
+0 −2
Original line number Diff line number Diff line
@@ -547,9 +547,7 @@

        <activity
            android:name=".screencapture.record.smallscreen.ui.SmallScreenPostRecordingActivity"
            android:excludeFromRecents="true"
            android:exported="false"
            android:finishOnCloseSystemDialogs="true"
            android:showForAllUsers="true"
            android:theme="@style/ScreenCapture.PostRecord" />

+9 −0
Original line number Diff line number Diff line
@@ -364,6 +364,15 @@
    <string name="screen_record_edit">Edit</string>
    <!-- Button to delete a screen recording [CHAR LIMIT=20] -->
    <string name="screen_record_delete">Delete</string>
    <!-- Button to play record preview video [CHAR LIMIT=20] -->
    <string name="screen_record_play">Play</string>
    <!-- Button to pause record preview video [CHAR LIMIT=20] -->
    <string name="screen_record_pause">Pause</string>
    <!-- Button to mute record preview video [CHAR LIMIT=20] -->
    <string name="screen_record_mute">Mute</string>
    <!-- Button to unmute record preview video [CHAR LIMIT=20] -->
    <string name="screen_record_unmute">Unmute</string>
    <string name="screen_record_video_preview_elapsed_time_template" translatable="false">%s / %s</string>

    <!-- Button text for taking a fullscreen screenshot [CHAR LIMIT=50] -->
    <string name="screen_capture_fullscreen_screenshot_button">Take screenshot of entire screen</string>
+185 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.screencapture.record.smallscreen.player.ui.compose

import android.text.format.DateUtils
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.res.R
import com.android.systemui.screencapture.common.ui.compose.LoadingIcon
import com.android.systemui.screencapture.common.ui.compose.loadIcon
import com.android.systemui.screencapture.common.ui.viewmodel.DrawableLoaderViewModel
import com.android.systemui.screencapture.record.smallscreen.player.ui.viewmodel.VideoPlayerControlsViewModel
import kotlin.math.roundToInt

@Composable
fun DefaultVideoPlayerControls(
    viewModel: VideoPlayerControlsViewModel,
    modifier: Modifier = Modifier,
    color: Color = Color.White,
    contrastColor: Color = Color.Black.copy(alpha = 0.5f),
) {
    val backgroundBrush = Brush.verticalGradient(colors = listOf(Color.Transparent, contrastColor))
    Column(
        verticalArrangement = Arrangement.Bottom,
        modifier = modifier.heightIn(min = 164.dp).drawBehind { drawRect(backgroundBrush) },
    ) {
        Row(
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween,
            modifier = Modifier.padding(horizontal = 4.dp).fillMaxWidth(),
        ) {
            PlayerButton(
                viewModel = viewModel,
                checked = viewModel.playing,
                onCheckedChanged = { viewModel.updatePlaying(it) },
                iconRes =
                    if (viewModel.playing) {
                        R.drawable.ic_media_pause_button
                    } else {
                        R.drawable.ic_media_play_button
                    },
                labelRes =
                    if (viewModel.playing) {
                        R.string.screen_record_pause
                    } else {
                        R.string.screen_record_play
                    },
                color = color,
            )
            ElapsedTimeText(
                currentPositionMillis = viewModel.videoPositionMillis,
                durationMillis = viewModel.videoDurationMillis,
                color = color,
            )
            PlayerButton(
                viewModel = viewModel,
                checked = viewModel.muted,
                onCheckedChanged = { viewModel.updateMuted(it) },
                iconRes =
                    if (viewModel.muted) {
                        R.drawable.ic_speaker_mute
                    } else {
                        R.drawable.ic_speaker_on
                    },
                labelRes =
                    if (viewModel.muted) {
                        R.string.screen_record_mute
                    } else {
                        R.string.screen_record_unmute
                    },
                color = color,
            )
        }

        val animatedValue by animateFloatAsState(viewModel.videoPositionMillis.toFloat())
        Slider(
            value = animatedValue,
            valueRange = 0f..viewModel.videoDurationMillis.toFloat(),
            onValueChange = { viewModel.seek(it.roundToInt()) },
            onValueChangeFinished = { viewModel.seekFinished() },
            colors =
                SliderDefaults.colors(
                    activeTrackColor = color,
                    inactiveTrackColor = color.copy(alpha = 0.38f),
                    inactiveTickColor = color,
                    thumbColor = color,
                ),
            modifier = Modifier.padding(16.dp),
        )
    }
}

@Composable
private fun PlayerButton(
    viewModel: DrawableLoaderViewModel,
    checked: Boolean,
    onCheckedChanged: (Boolean) -> Unit,
    @DrawableRes iconRes: Int,
    @StringRes labelRes: Int,
    color: Color,
    modifier: Modifier = Modifier,
) {
    TextButton(
        onClick = { onCheckedChanged(!checked) },
        colors = ButtonDefaults.textButtonColors(contentColor = color),
        modifier = modifier.size(48.dp),
    ) {
        LoadingIcon(
            icon =
                loadIcon(
                        viewModel = viewModel,
                        resId = iconRes,
                        contentDescription = ContentDescription.Loaded(stringResource(labelRes)),
                    )
                    .value,
            modifier = Modifier.size(24.dp),
        )
    }
}

@Composable
private fun ElapsedTimeText(
    currentPositionMillis: Int,
    durationMillis: Int,
    color: Color,
    modifier: Modifier = Modifier,
) {
    val elapsedTime = format(currentPositionMillis)
    val duration = format(durationMillis)
    Text(
        text =
            stringResource(
                R.string.screen_record_video_preview_elapsed_time_template,
                elapsedTime,
                duration,
            ),
        style = MaterialTheme.typography.labelMedium.copy(),
        textAlign = TextAlign.Center,
        color = color,
        modifier = modifier,
    )
}

private fun format(durationMillis: Int): String {
    return DateUtils.formatElapsedTime(durationMillis / DateUtils.SECOND_IN_MILLIS)
}
+83 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.screencapture.record.smallscreen.player.ui.compose

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
import androidx.compose.ui.Modifier
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.screencapture.record.smallscreen.player.ui.viewmodel.VideoPlayerControlsViewModel
import com.android.systemui.screencapture.record.smallscreen.player.ui.viewmodel.VideoPlayerViewModel
import javax.inject.Inject

class VideoPlayer
@Inject
constructor(
    private val videoPlayerViewModelFactory: VideoPlayerViewModel.Factory,
    private val videoPlayerControlsViewModelFactory: VideoPlayerControlsViewModel.Factory,
) {
    @Composable
    fun Content(
        uri: Uri,
        modifier: Modifier = Modifier,
        videoControls: @Composable BoxScope.(vm: VideoPlayerControlsViewModel) -> Unit = { vm ->
            DefaultVideoPlayerControls(vm, Modifier.align(Alignment.BottomCenter))
        },
    ) {
        val playerViewModel =
            rememberViewModel(traceName = "VideoPlayer#viewModel", key = uri) {
                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()
                        )
                ) {
                    AndroidExternalSurface(
                        onInit = {
                            onSurface { surface, _, _ ->
                                player.setSurface(surface)
                                surface.onDestroyed { player.setSurface(null) }
                            }
                        }
                    )

                    val viewModel =
                        rememberViewModel(
                            traceName = "VideoPlayer#controlsViewModel",
                            key = player,
                        ) {
                            videoPlayerControlsViewModelFactory.create(player)
                        }
                    videoControls(viewModel)
                }
            }
        }
    }
}
+134 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.screencapture.record.smallscreen.player.ui.viewmodel

import android.media.MediaPlayer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.android.systemui.kairos.awaitClose
import com.android.systemui.lifecycle.HydratedActivatable
import com.android.systemui.screencapture.common.ui.viewmodel.DrawableLoaderViewModel
import com.android.systemui.screencapture.common.ui.viewmodel.DrawableLoaderViewModelImpl
import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
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.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive

class VideoPlayerControlsViewModel
@AssistedInject
constructor(
    @Assisted private val mediaPlayer: MediaPlayer,
    private val drawableLoaderViewModelImpl: DrawableLoaderViewModelImpl,
) : HydratedActivatable(), DrawableLoaderViewModel by drawableLoaderViewModelImpl {

    val videoDurationMillis: Int
        get() = mediaPlayer.duration

    val videoPositionMillis: Int by
        mediaPlayer
            .currentPositionFlow(10.milliseconds)
            .hydratedStateOf("VideoPlayerControlsViewModel#videoPositionMillis", 0)

    var playing: Boolean by mutableStateOf(false)
        private set

    var muted: Boolean by mutableStateOf(false)
        private set

    private var wasPlayingBeforeSeek: Boolean = false

    override suspend fun onActivated() {
        coroutineScope {
            mediaPlayer
                .onComplete()
                .onEach {
                    mediaPlayer.seekTo(0)
                    playing = false
                }
                .launchIn(this)
        }
    }

    fun updatePlaying(isPlaying: Boolean) {
        if (isPlaying) {
            mediaPlayer.start()
        } else {
            mediaPlayer.pause()
        }
        this.playing = isPlaying
    }

    fun seek(positionMillis: Int) {
        wasPlayingBeforeSeek = playing
        updatePlaying(false)
        mediaPlayer.seekTo(positionMillis)
    }

    fun seekFinished() {
        updatePlaying(wasPlayingBeforeSeek)
    }

    fun updateMuted(isMuted: Boolean) {
        if (isMuted) {
            mediaPlayer.setVolume(0f)
        } else {
            mediaPlayer.setVolume(1f)
        }
        muted = isMuted
    }

    @AssistedFactory
    interface Factory {
        fun create(player: MediaPlayer): VideoPlayerControlsViewModel
    }
}

private fun MediaPlayer.currentPositionFlow(pollingDelay: Duration): Flow<Int> {
    val polling = flow {
        while (currentCoroutineContext().isActive) {
            delay(pollingDelay)
            emit(Unit)
        }
    }
    val seeking = conflatedCallbackFlow {
        val listener = MediaPlayer.OnSeekCompleteListener { trySend(Unit) }
        setOnSeekCompleteListener(listener)
        awaitClose { setOnSeekCompleteListener(null) }
    }
    return merge(polling, seeking).map { currentPosition }.distinctUntilChanged()
}

private fun MediaPlayer.onComplete(): Flow<Unit> = callbackFlow {
    val listener = MediaPlayer.OnCompletionListener { trySend(Unit) }
    setOnCompletionListener(listener)
    awaitClose { setOnCompletionListener(null) }
}
Loading