feat: inline audio player

Signed-off-by: Infi <wingit@geist.ga>
This commit is contained in:
Infi 2023-04-22 22:15:55 +02:00
parent 6069f2aa27
commit a35f550172
9 changed files with 326 additions and 32 deletions

View File

@ -118,6 +118,7 @@ dependencies {
implementation 'androidx.compose.material:material'
implementation 'androidx.compose.material3:material3'
implementation "androidx.compose.ui:ui-tooling-preview"
implementation "androidx.compose.runtime:runtime-livedata"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1'
implementation 'androidx.activity:activity-compose:1.7.1'

View File

@ -163,8 +163,8 @@ fun ImageViewScreen(
}
val snackbar = snackbarHostState.showSnackbar(
message = context.getString(R.string.image_viewer_saved),
actionLabel = context.getString(R.string.image_viewer_open),
message = context.getString(R.string.media_viewer_saved),
actionLabel = context.getString(R.string.media_viewer_open),
duration = SnackbarDuration.Short
)
@ -191,7 +191,8 @@ fun ImageViewScreen(
) {
Column {
PageHeader(text = stringResource(
id = R.string.image_viewer_title, resource.filename ?: resource.id!!
id = R.string.media_viewer_title_image,
resource.filename ?: resource.id!!
),
showBackButton = true,
onBackButtonClicked = onClose,
@ -217,7 +218,7 @@ fun ImageViewScreen(
shareUrl()
},
text = {
Text(stringResource(id = R.string.image_viewer_share_url))
Text(stringResource(id = R.string.media_viewer_share_url))
}
)
DropdownMenuItem(
@ -225,7 +226,7 @@ fun ImageViewScreen(
shareImage()
},
text = {
Text(stringResource(id = R.string.image_viewer_share_image))
Text(stringResource(id = R.string.media_viewer_share_image))
}
)
}
@ -235,7 +236,7 @@ fun ImageViewScreen(
}) {
Icon(
painter = painterResource(id = R.drawable.ic_download_24dp),
contentDescription = stringResource(id = R.string.image_viewer_save)
contentDescription = stringResource(id = R.string.media_viewer_save)
)
}
}

View File

@ -165,8 +165,8 @@ fun VideoViewScreen(
}
val snackbar = snackbarHostState.showSnackbar(
message = context.getString(R.string.video_viewer_saved),
actionLabel = context.getString(R.string.video_viewer_open),
message = context.getString(R.string.media_viewer_saved),
actionLabel = context.getString(R.string.media_viewer_open),
duration = SnackbarDuration.Short
)
@ -207,7 +207,8 @@ fun VideoViewScreen(
) {
Column {
PageHeader(text = stringResource(
id = R.string.video_viewer_title, resource.filename ?: resource.id!!
id = R.string.media_viewer_title_video,
resource.filename ?: resource.id!!
),
showBackButton = true,
onBackButtonClicked = onClose,
@ -233,7 +234,7 @@ fun VideoViewScreen(
shareUrl()
},
text = {
Text(stringResource(id = R.string.video_viewer_share_url))
Text(stringResource(id = R.string.media_viewer_share_url))
}
)
DropdownMenuItem(
@ -241,7 +242,7 @@ fun VideoViewScreen(
shareVideo()
},
text = {
Text(stringResource(id = R.string.video_viewer_share_video))
Text(stringResource(id = R.string.media_viewer_share_video))
}
)
}
@ -251,7 +252,7 @@ fun VideoViewScreen(
}) {
Icon(
painter = painterResource(id = R.drawable.ic_download_24dp),
contentDescription = stringResource(id = R.string.video_viewer_save)
contentDescription = stringResource(id = R.string.media_viewer_save)
)
}
}

View File

@ -238,6 +238,10 @@ fun Message(
)
}
"Audio" -> {
/* no-op */
}
else -> {
viewAttachmentInBrowser(context, attachment)
}

View File

@ -33,6 +33,7 @@ import chat.revolt.R
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.schemas.AutumnResource
import chat.revolt.components.generic.RemoteImage
import chat.revolt.components.media.AudioPlayer
@Composable
fun FileAttachment(attachment: AutumnResource) {
@ -113,7 +114,7 @@ fun VideoAttachment(attachment: AutumnResource) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = stringResource(id = R.string.video_viewer_play),
contentDescription = stringResource(id = R.string.media_viewer_play),
modifier = Modifier
.width(32.dp)
.aspectRatio(1f),
@ -123,8 +124,12 @@ fun VideoAttachment(attachment: AutumnResource) {
@Composable
fun AudioAttachment(attachment: AutumnResource) {
// FIXME Use ExoPlayer to play audio.
FileAttachment(attachment)
val url = "$REVOLT_FILES/attachments/${attachment.id}/${attachment.filename}"
AudioPlayer(
url = url,
filename = attachment.filename ?: "Audio",
contentType = attachment.metadata?.type ?: "audio/mpeg",
)
}
@Composable

View File

@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -44,6 +45,7 @@ fun PageHeader(
Text(
text = text,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.displaySmall.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Left,

View File

@ -0,0 +1,273 @@
package chat.revolt.components.media
import android.content.ContentValues
import android.provider.MediaStore
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import chat.revolt.R
import chat.revolt.api.RevoltHttp
import io.ktor.client.request.get
import io.ktor.client.statement.readBytes
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun AudioPlayer(
url: String,
filename: String,
contentType: String,
) {
val context = LocalContext.current
val showMenu = remember { mutableStateOf(false) }
val currentTime = remember { mutableStateOf(0L) }
val isPlaying = remember { mutableStateOf(false) }
val isLoading = remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val player = remember {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url))
prepare()
addListener(object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
super.onIsPlayingChanged(playing)
isPlaying.value = playing
}
override fun onIsLoadingChanged(loading: Boolean) {
super.onIsLoadingChanged(loading)
isLoading.value = loading
}
})
}
}
fun seekTo(position: Long) {
player.seekTo(position)
currentTime.value = position
}
fun formatTime(time: Long): String {
val seconds = time / 1000
val minutes = seconds / 60
val hours = minutes / 60
return when {
hours > 0 -> {
val remainingMinutes = minutes % 60
val remainingSeconds = seconds % 60
"%02d:%02d:%02d".format(hours, remainingMinutes, remainingSeconds)
}
else -> {
val remainingSeconds = seconds % 60
"%02d:%02d".format(minutes, remainingSeconds)
}
}
}
fun saveToStorage() {
showMenu.value = false
coroutineScope.launch {
context.applicationContext.let {
it.contentResolver.insert(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
ContentValues().apply {
put(MediaStore.Audio.Media.DISPLAY_NAME, filename)
put(MediaStore.Audio.Media.MIME_TYPE, contentType)
put(MediaStore.Audio.Media.RELATIVE_PATH, "Music/Revolt")
put(MediaStore.Audio.Media.IS_PENDING, 1)
}
)
}?.let { uri ->
context.contentResolver.openOutputStream(uri).use { stream ->
val audio = RevoltHttp.get(url).readBytes()
stream?.write(audio)
context.applicationContext.let {
it.contentResolver.update(
uri,
ContentValues().apply {
put(MediaStore.Audio.Media.IS_PENDING, 0)
},
null,
null
)
}
Toast.makeText(
context,
context.getString(R.string.media_viewer_saved),
Toast.LENGTH_SHORT
).show()
}
}
}
}
LaunchedEffect(Unit) {
while (true) {
if (currentTime.value != player.currentPosition && player.isPlaying) {
currentTime.value = player.currentPosition
}
if (player.currentPosition == player.duration) {
player.seekTo(0)
player.pause()
}
if (player.duration < 0) {
currentTime.value = 0
}
delay(100)
}
}
DisposableEffect(player) {
onDispose {
player.release()
}
}
Column(
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp))
.padding(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
) {
Text(
text = filename,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = formatTime(currentTime.value),
fontWeight = FontWeight.Medium
)
if (player.duration >= 0) {
Text(
text = " / ${formatTime(player.duration)}"
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = {
if (isPlaying.value) {
player.pause()
} else {
player.play()
}
}) {
if (isLoading.value) {
CircularProgressIndicator()
} else {
if (isPlaying.value) {
Icon(
painter = painterResource(R.drawable.ic_pause_24dp),
contentDescription = stringResource(R.string.media_viewer_pause),
)
} else {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.media_viewer_play),
)
}
}
}
if (player.duration >= 0) {
Slider(
value = player.currentPosition.toFloat(),
onValueChange = { seekTo(it.toLong()) },
valueRange = 0f..player.duration.toFloat(),
modifier = Modifier.weight(1f)
)
} else {
Slider(
value = 0f,
onValueChange = {},
valueRange = 0f..1f,
enabled = false,
modifier = Modifier.weight(1f)
)
}
IconButton(onClick = {
showMenu.value = !showMenu.value
}) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.media_viewer_more),
)
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = {
showMenu.value = false
}
) {
DropdownMenuItem(
onClick = {
saveToStorage()
},
text = {
Text(text = stringResource(R.string.media_viewer_save))
}
)
}
}
}
}
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M14,19H18V5H14M6,19H10V5H6V19Z" />
</vector>

View File

@ -228,23 +228,21 @@
<string name="invite_error_banned">You are banned from this server.</string>
<string name="invite_error_unknown">An unknown error occurred.</string>
<string name="image_viewer_title">%1$s</string>
<string name="image_viewer_save">Save</string>
<string name="image_viewer_saved">Saved</string>
<string name="image_viewer_save_failed">Failed to save image</string>
<string name="image_viewer_open">Open</string>
<string name="image_viewer_share_url">Share URL</string>
<string name="image_viewer_share_image">Share image</string>
<string name="video_viewer_title">%1$s</string>
<string name="video_viewer_play">Play</string>
<string name="video_viewer_pause">Pause</string>
<string name="video_viewer_save">Save</string>
<string name="video_viewer_saved">Saved</string>
<string name="video_viewer_save_failed">Failed to save video</string>
<string name="video_viewer_open">Open</string>
<string name="video_viewer_share_url">Share URL</string>
<string name="video_viewer_share_video">Share video</string>
<string name="media_viewer_title_image">%1$s</string>
<string name="media_viewer_title_video">%1$s</string>
<string name="media_viewer_play">Play</string>
<string name="media_viewer_pause">Pause</string>
<string name="media_viewer_more">More…</string>
<string name="media_viewer_save">Save</string>
<string name="media_viewer_saved">Saved</string>
<string name="media_viewer_save_failed_audio">Failed to save audio</string>
<string name="media_viewer_save_failed_video">Failed to save video</string>
<string name="media_viewer_save_failed_image">Failed to save image</string>
<string name="media_viewer_open">Open</string>
<string name="media_viewer_share_url">Share URL</string>
<string name="media_viewer_share_audio">Share audio</string>
<string name="media_viewer_share_video">Share video</string>
<string name="media_viewer_share_image">Share image</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_appearance_theme">Theme</string>