Merge pull request #3959 from element-hq/feature/bma/videoPlayer
Video player controller
This commit is contained in:
commit
74b5991b56
15 changed files with 518 additions and 46 deletions
|
|
@ -11,4 +11,9 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.libraries.dateformatter.api"
|
namespace = "io.element.android.libraries.dateformatter.api"
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.dateformatter.api
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert milliseconds to human readable duration.
|
||||||
|
* Hours in 1 digit or more.
|
||||||
|
* Minutes in 2 digits when hours are available.
|
||||||
|
* Seconds always on 2 digits.
|
||||||
|
* Example:
|
||||||
|
* - when the duration is longer than 1 hour:
|
||||||
|
* - "10:23:34"
|
||||||
|
* - "1:23:34"
|
||||||
|
* - "1:03:04"
|
||||||
|
* - when the duration is shorter:
|
||||||
|
* - "4:56"
|
||||||
|
* - "14:06"
|
||||||
|
* - Less than one minute:
|
||||||
|
* - "0:00"
|
||||||
|
* - "0:01"
|
||||||
|
* - "0:59"
|
||||||
|
*/
|
||||||
|
fun Long.toHumanReadableDuration(): String {
|
||||||
|
val inSeconds = this / 1_000
|
||||||
|
val hours = inSeconds / 3_600
|
||||||
|
val minutes = inSeconds % 3_600 / 60
|
||||||
|
val seconds = inSeconds % 60
|
||||||
|
return if (hours > 0) {
|
||||||
|
String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
} else {
|
||||||
|
String.format(Locale.US, "%d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.dateformatter.api
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DurationFormatterTest {
|
||||||
|
@Test
|
||||||
|
fun `format seconds only`() {
|
||||||
|
assertThat(buildDuration().toHumanReadableDuration()).isEqualTo("0:00")
|
||||||
|
assertThat(buildDuration(seconds = 1).toHumanReadableDuration()).isEqualTo("0:01")
|
||||||
|
assertThat(buildDuration(seconds = 59).toHumanReadableDuration()).isEqualTo("0:59")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `format minutes and seconds`() {
|
||||||
|
assertThat(buildDuration(minutes = 1).toHumanReadableDuration()).isEqualTo("1:00")
|
||||||
|
assertThat(buildDuration(minutes = 1, seconds = 30).toHumanReadableDuration()).isEqualTo("1:30")
|
||||||
|
assertThat(buildDuration(minutes = 59, seconds = 59).toHumanReadableDuration()).isEqualTo("59:59")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `format hours, minutes and seconds`() {
|
||||||
|
assertThat(buildDuration(hours = 1).toHumanReadableDuration()).isEqualTo("1:00:00")
|
||||||
|
assertThat(buildDuration(hours = 1, minutes = 1, seconds = 1).toHumanReadableDuration()).isEqualTo("1:01:01")
|
||||||
|
assertThat(buildDuration(hours = 24, minutes = 59, seconds = 59).toHumanReadableDuration()).isEqualTo("24:59:59")
|
||||||
|
assertThat(buildDuration(hours = 25, minutes = 0, seconds = 0).toHumanReadableDuration()).isEqualTo("25:00:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildDuration(
|
||||||
|
hours: Int = 0,
|
||||||
|
minutes: Int = 0,
|
||||||
|
seconds: Int = 0
|
||||||
|
): Long {
|
||||||
|
return (hours * 60 * 60 + minutes * 60 + seconds) * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,19 +5,32 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
package io.element.android.libraries.designsystem.theme.components
|
package io.element.android.libraries.designsystem.theme.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.DragInteraction
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.PressInteraction
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SliderColors
|
import androidx.compose.material3.SliderColors
|
||||||
import androidx.compose.material3.SliderDefaults
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||||
|
|
||||||
|
|
@ -32,8 +45,20 @@ fun Slider(
|
||||||
steps: Int = 0,
|
steps: Int = 0,
|
||||||
onValueChangeFinish: (() -> Unit)? = null,
|
onValueChangeFinish: (() -> Unit)? = null,
|
||||||
colors: SliderColors = SliderDefaults.colors(),
|
colors: SliderColors = SliderDefaults.colors(),
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
useCustomLayout: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
val thumbColor = ElementTheme.colors.iconOnSolidPrimary
|
||||||
|
var isUserInteracting by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(interactionSource) {
|
||||||
|
interactionSource.interactions.collect { interaction ->
|
||||||
|
isUserInteracting = when (interaction) {
|
||||||
|
is DragInteraction.Start,
|
||||||
|
is PressInteraction.Press -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
androidx.compose.material3.Slider(
|
androidx.compose.material3.Slider(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
|
|
@ -43,6 +68,54 @@ fun Slider(
|
||||||
steps = steps,
|
steps = steps,
|
||||||
onValueChangeFinished = onValueChangeFinish,
|
onValueChangeFinished = onValueChangeFinish,
|
||||||
colors = colors,
|
colors = colors,
|
||||||
|
thumb = {
|
||||||
|
if (useCustomLayout) {
|
||||||
|
SliderDefaults.Thumb(
|
||||||
|
modifier = Modifier.drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
if (isUserInteracting.not()) {
|
||||||
|
drawCircle(thumbColor, radius = 8.dp.toPx())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
colors = colors.copy(
|
||||||
|
thumbColor = ElementTheme.colors.iconPrimary,
|
||||||
|
),
|
||||||
|
enabled = enabled,
|
||||||
|
thumbSize = DpSize(
|
||||||
|
if (isUserInteracting) 44.dp else 22.dp,
|
||||||
|
22.dp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SliderDefaults.Thumb(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
colors = colors,
|
||||||
|
enabled = enabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
track = { sliderState ->
|
||||||
|
if (useCustomLayout) {
|
||||||
|
SliderDefaults.Track(
|
||||||
|
modifier = Modifier.height(8.dp),
|
||||||
|
colors = colors.copy(
|
||||||
|
activeTrackColor = Color(0x66E0EDFF),
|
||||||
|
inactiveTrackColor = Color(0x66E0EDFF),
|
||||||
|
),
|
||||||
|
enabled = enabled,
|
||||||
|
sliderState = sliderState,
|
||||||
|
thumbTrackGapSize = 0.dp,
|
||||||
|
drawStopIndicator = { },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SliderDefaults.Track(
|
||||||
|
colors = colors,
|
||||||
|
enabled = enabled,
|
||||||
|
sliderState = sliderState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -55,5 +128,6 @@ internal fun SlidersPreview() = ElementThemedPreview {
|
||||||
Slider(onValueChange = { value = it }, value = value, enabled = true)
|
Slider(onValueChange = { value = it }, value = value, enabled = true)
|
||||||
Slider(steps = 10, onValueChange = { value = it }, value = value, enabled = true)
|
Slider(steps = 10, onValueChange = { value = it }, value = value, enabled = true)
|
||||||
Slider(onValueChange = { value = it }, value = value, enabled = false)
|
Slider(onValueChange = { value = it }, value = value, enabled = false)
|
||||||
|
Slider(onValueChange = { value = it }, value = value, enabled = true, useCustomLayout = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ dependencies {
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
|
implementation(projects.libraries.dateformatter.api)
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ package io.element.android.libraries.mediaviewer.api.local
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
|
@ -19,6 +18,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
|
@ -29,7 +30,9 @@ import androidx.compose.material.icons.outlined.GraphicEq
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -49,6 +52,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
|
@ -67,9 +71,13 @@ import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAn
|
||||||
import io.element.android.libraries.mediaviewer.api.local.exoplayer.ExoPlayerWrapper
|
import io.element.android.libraries.mediaviewer.api.local.exoplayer.ExoPlayerWrapper
|
||||||
import io.element.android.libraries.mediaviewer.api.local.pdf.PdfViewer
|
import io.element.android.libraries.mediaviewer.api.local.pdf.PdfViewer
|
||||||
import io.element.android.libraries.mediaviewer.api.local.pdf.rememberPdfViewerState
|
import io.element.android.libraries.mediaviewer.api.local.pdf.rememberPdfViewerState
|
||||||
|
import io.element.android.libraries.mediaviewer.api.player.MediaPlayerControllerState
|
||||||
|
import io.element.android.libraries.mediaviewer.api.player.MediaPlayerControllerView
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
|
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
|
||||||
import me.saket.telephoto.zoomable.rememberZoomableImageState
|
import me.saket.telephoto.zoomable.rememberZoomableImageState
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LocalMediaView(
|
fun LocalMediaView(
|
||||||
|
|
@ -91,7 +99,6 @@ fun LocalMediaView(
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClick = onClick,
|
|
||||||
)
|
)
|
||||||
mimeType == MimeTypes.Pdf -> MediaPDFView(
|
mimeType == MimeTypes.Pdf -> MediaPDFView(
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
|
|
@ -141,7 +148,6 @@ private fun MediaImageView(
|
||||||
private fun MediaVideoView(
|
private fun MediaVideoView(
|
||||||
localMediaViewState: LocalMediaViewState,
|
localMediaViewState: LocalMediaViewState,
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (LocalInspectionMode.current) {
|
if (LocalInspectionMode.current) {
|
||||||
|
|
@ -155,7 +161,6 @@ private fun MediaVideoView(
|
||||||
ExoPlayerMediaVideoView(
|
ExoPlayerMediaVideoView(
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -166,30 +171,90 @@ private fun MediaVideoView(
|
||||||
private fun ExoPlayerMediaVideoView(
|
private fun ExoPlayerMediaVideoView(
|
||||||
localMediaViewState: LocalMediaViewState,
|
localMediaViewState: LocalMediaViewState,
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var playableState: PlayableState.Playable by remember {
|
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
|
||||||
mutableStateOf(PlayableState.Playable(isPlaying = false, isShowingControls = false))
|
mutableStateOf(
|
||||||
|
MediaPlayerControllerState(
|
||||||
|
isVisible = false,
|
||||||
|
isPlaying = false,
|
||||||
|
progressInMillis = 0,
|
||||||
|
durationInMillis = 0,
|
||||||
|
isMuted = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val playableState: PlayableState.Playable by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
PlayableState.Playable(
|
||||||
|
isShowingControls = mediaPlayerControllerState.isVisible,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
localMediaViewState.playableState = playableState
|
localMediaViewState.playableState = playableState
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val exoPlayer = remember {
|
||||||
|
ExoPlayerWrapper.create(context)
|
||||||
|
}
|
||||||
val playerListener = object : Player.Listener {
|
val playerListener = object : Player.Listener {
|
||||||
override fun onRenderedFirstFrame() {
|
override fun onRenderedFirstFrame() {
|
||||||
localMediaViewState.isReady = true
|
localMediaViewState.isReady = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
playableState = playableState.copy(isPlaying = isPlaying)
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
|
isPlaying = isPlaying,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVolumeChanged(volume: Float) {
|
||||||
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
|
isMuted = volume == 0f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||||
|
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
|
||||||
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
|
durationInMillis = exoPlayer.duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val exoPlayer = remember {
|
|
||||||
ExoPlayerWrapper.create(context)
|
LaunchedEffect(Unit) {
|
||||||
.apply {
|
exoPlayer.addListener(playerListener)
|
||||||
addListener(playerListener)
|
exoPlayer.prepare()
|
||||||
this.prepare()
|
}
|
||||||
|
|
||||||
|
var autoHideController by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
LaunchedEffect(autoHideController) {
|
||||||
|
delay(5.seconds)
|
||||||
|
if (exoPlayer.isPlaying) {
|
||||||
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
|
isVisible = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(exoPlayer.isPlaying) {
|
||||||
|
if (exoPlayer.isPlaying) {
|
||||||
|
while (true) {
|
||||||
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
|
progressInMillis = exoPlayer.currentPosition,
|
||||||
|
)
|
||||||
|
delay(200)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Ensure we render the final state
|
||||||
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
|
progressInMillis = exoPlayer.currentPosition,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (localMedia?.uri != null) {
|
if (localMedia?.uri != null) {
|
||||||
LaunchedEffect(localMedia.uri) {
|
LaunchedEffect(localMedia.uri) {
|
||||||
|
|
@ -199,35 +264,64 @@ private fun ExoPlayerMediaVideoView(
|
||||||
} else {
|
} else {
|
||||||
exoPlayer.setMediaItems(emptyList())
|
exoPlayer.setMediaItems(emptyList())
|
||||||
}
|
}
|
||||||
KeepScreenOn(playableState.isPlaying)
|
KeepScreenOn(mediaPlayerControllerState.isPlaying)
|
||||||
AndroidView(
|
Box(
|
||||||
factory = {
|
|
||||||
PlayerView(context).apply {
|
|
||||||
player = exoPlayer
|
|
||||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
|
||||||
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
|
||||||
setOnClickListener {
|
|
||||||
onClick()
|
|
||||||
}
|
|
||||||
setControllerVisibilityListener(PlayerView.ControllerVisibilityListener { visibility ->
|
|
||||||
val isShowingControls = visibility == View.VISIBLE
|
|
||||||
playableState = playableState.copy(isShowingControls = isShowingControls)
|
|
||||||
})
|
|
||||||
controllerShowTimeoutMs = 1500
|
|
||||||
setShowPreviousButton(false)
|
|
||||||
setShowFastForwardButton(false)
|
|
||||||
setShowRewindButton(false)
|
|
||||||
setShowNextButton(false)
|
|
||||||
showController()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRelease = { playerView ->
|
|
||||||
playerView.setOnClickListener(null)
|
|
||||||
playerView.setControllerVisibilityListener(null as PlayerView.ControllerVisibilityListener?)
|
|
||||||
playerView.player = null
|
|
||||||
},
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
.background(ElementTheme.colors.bgSubtlePrimary)
|
||||||
|
.wrapContentSize(),
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = {
|
||||||
|
PlayerView(context).apply {
|
||||||
|
player = exoPlayer
|
||||||
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
|
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
||||||
|
setOnClickListener {
|
||||||
|
autoHideController++
|
||||||
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
|
isVisible = !mediaPlayerControllerState.isVisible,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
useController = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRelease = { playerView ->
|
||||||
|
playerView.setOnClickListener(null)
|
||||||
|
playerView.setControllerVisibilityListener(null as PlayerView.ControllerVisibilityListener?)
|
||||||
|
playerView.player = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
MediaPlayerControllerView(
|
||||||
|
state = mediaPlayerControllerState,
|
||||||
|
onTogglePlay = {
|
||||||
|
autoHideController++
|
||||||
|
if (exoPlayer.isPlaying) {
|
||||||
|
exoPlayer.pause()
|
||||||
|
} else {
|
||||||
|
if (exoPlayer.playbackState == Player.STATE_ENDED) {
|
||||||
|
exoPlayer.seekTo(0)
|
||||||
|
} else {
|
||||||
|
exoPlayer.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSeekChange = {
|
||||||
|
autoHideController++
|
||||||
|
if (exoPlayer.isPlaying.not()) {
|
||||||
|
exoPlayer.play()
|
||||||
|
}
|
||||||
|
exoPlayer.seekTo(it.toLong())
|
||||||
|
},
|
||||||
|
onToggleMute = {
|
||||||
|
autoHideController++
|
||||||
|
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
OnLifecycleEvent { _, event ->
|
OnLifecycleEvent { _, event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,7 @@ class LocalMediaViewState internal constructor(
|
||||||
sealed interface PlayableState {
|
sealed interface PlayableState {
|
||||||
data object NotPlayable : PlayableState
|
data object NotPlayable : PlayableState
|
||||||
data class Playable(
|
data class Playable(
|
||||||
val isPlaying: Boolean,
|
val isShowingControls: Boolean,
|
||||||
val isShowingControls: Boolean
|
|
||||||
) : PlayableState
|
) : PlayableState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.api.player
|
||||||
|
|
||||||
|
data class MediaPlayerControllerState(
|
||||||
|
val isVisible: Boolean,
|
||||||
|
val isPlaying: Boolean,
|
||||||
|
val progressInMillis: Long,
|
||||||
|
val durationInMillis: Long,
|
||||||
|
val isMuted: Boolean,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.api.player
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
|
||||||
|
open class MediaPlayerControllerStateProvider : PreviewParameterProvider<MediaPlayerControllerState> {
|
||||||
|
override val values: Sequence<MediaPlayerControllerState> = sequenceOf(
|
||||||
|
aMediaPlayerControllerState(),
|
||||||
|
aMediaPlayerControllerState(
|
||||||
|
isPlaying = true,
|
||||||
|
progressInMillis = 59_000,
|
||||||
|
durationInMillis = 83_000,
|
||||||
|
isMuted = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aMediaPlayerControllerState(
|
||||||
|
isVisible: Boolean = true,
|
||||||
|
isPlaying: Boolean = false,
|
||||||
|
progressInMillis: Long = 0,
|
||||||
|
// Default to 1 minute and 23 seconds
|
||||||
|
durationInMillis: Long = 83_000,
|
||||||
|
isMuted: Boolean = false,
|
||||||
|
) = MediaPlayerControllerState(
|
||||||
|
isVisible = isVisible,
|
||||||
|
isPlaying = isPlaying,
|
||||||
|
progressInMillis = progressInMillis,
|
||||||
|
durationInMillis = durationInMillis,
|
||||||
|
isMuted = isMuted,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.api.player
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Slider
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MediaPlayerControllerView(
|
||||||
|
state: MediaPlayerControllerState,
|
||||||
|
onTogglePlay: () -> Unit,
|
||||||
|
onSeekChange: (Float) -> Unit,
|
||||||
|
onToggleMute: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = state.isVisible,
|
||||||
|
modifier = modifier,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(color = Color(0x99101317))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 480.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onTogglePlay,
|
||||||
|
) {
|
||||||
|
if (state.isPlaying) {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.PauseSolid(),
|
||||||
|
tint = ElementTheme.colors.iconPrimary,
|
||||||
|
contentDescription = stringResource(CommonStrings.a11y_pause)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.PlaySolid(),
|
||||||
|
tint = ElementTheme.colors.iconPrimary,
|
||||||
|
contentDescription = stringResource(CommonStrings.a11y_play)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = 48.dp)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
text = state.progressInMillis.toHumanReadableDuration(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
style = ElementTheme.typography.fontBodyXsMedium,
|
||||||
|
)
|
||||||
|
var lastSelectedValue by remember { mutableFloatStateOf(-1f) }
|
||||||
|
Slider(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
valueRange = 0f..state.durationInMillis.toFloat(),
|
||||||
|
value = lastSelectedValue.takeIf { it >= 0 } ?: state.progressInMillis.toFloat(),
|
||||||
|
onValueChange = {
|
||||||
|
lastSelectedValue = it
|
||||||
|
},
|
||||||
|
onValueChangeFinish = {
|
||||||
|
onSeekChange(lastSelectedValue)
|
||||||
|
lastSelectedValue = -1f
|
||||||
|
},
|
||||||
|
useCustomLayout = true,
|
||||||
|
)
|
||||||
|
val formattedDuration = remember(state.durationInMillis) {
|
||||||
|
state.durationInMillis.toHumanReadableDuration()
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = 48.dp)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
text = formattedDuration,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
style = ElementTheme.typography.fontBodyXsMedium,
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = onToggleMute,
|
||||||
|
) {
|
||||||
|
if (state.isMuted) {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.VolumeOffSolid(),
|
||||||
|
tint = ElementTheme.colors.iconPrimary,
|
||||||
|
contentDescription = stringResource(CommonStrings.common_unmute)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.VolumeOnSolid(),
|
||||||
|
tint = ElementTheme.colors.iconPrimary,
|
||||||
|
contentDescription = stringResource(CommonStrings.common_mute)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun MediaPlayerControllerViewPreview(
|
||||||
|
@PreviewParameter(MediaPlayerControllerStateProvider::class) state: MediaPlayerControllerState
|
||||||
|
) = ElementPreview {
|
||||||
|
MediaPlayerControllerView(
|
||||||
|
state = state,
|
||||||
|
onTogglePlay = {},
|
||||||
|
onSeekChange = {},
|
||||||
|
onToggleMute = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:99102e56ca747eb69e03543032915e9a6a608d6013843889ad0dcd3e1953141c
|
oid sha256:05c37f69de81b26ce0083047cb18120300cc0e76aa7e3c0d3c24ba5de39681bd
|
||||||
size 11451
|
size 14134
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e7a7ea1da7e7602cc53cc162c4a685516a7a46ca148eafca6e19a5748630bda5
|
||||||
|
size 7036
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bc35254c6962b5b113a2c25c4c9dd0c94449521152ffd73f41a0c36429fabbb0
|
||||||
|
size 7258
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9f69e358ddd7e00f7301598f5dd235a2f5b9077f8926c2bfa3a4bca1168900f0
|
||||||
|
size 7173
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b096093e04eb187fce0a85496c8a5b251afe0d0bccd977abcfb4d1e3dbe32a20
|
||||||
|
size 7436
|
||||||
Loading…
Add table
Add a link
Reference in a new issue