From a35f550172a14ca173cda8a28b4aa3971bd98e5c Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 22 Apr 2023 22:15:55 +0200 Subject: [PATCH] feat: inline audio player Signed-off-by: Infi --- app/build.gradle | 1 + .../activities/media/ImageViewActivity.kt | 13 +- .../activities/media/VideoViewActivity.kt | 13 +- .../chat/revolt/components/chat/Message.kt | 4 + .../components/chat/MessageAttachment.kt | 11 +- .../revolt/components/generic/PageHeader.kt | 2 + .../revolt/components/media/AudioPlayer.kt | 273 ++++++++++++++++++ app/src/main/res/drawable/ic_pause_24dp.xml | 9 + app/src/main/res/values/strings.xml | 32 +- 9 files changed, 326 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/chat/revolt/components/media/AudioPlayer.kt create mode 100644 app/src/main/res/drawable/ic_pause_24dp.xml diff --git a/app/build.gradle b/app/build.gradle index 5af10938..a6360534 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/java/chat/revolt/activities/media/ImageViewActivity.kt b/app/src/main/java/chat/revolt/activities/media/ImageViewActivity.kt index debb836a..de86ad62 100644 --- a/app/src/main/java/chat/revolt/activities/media/ImageViewActivity.kt +++ b/app/src/main/java/chat/revolt/activities/media/ImageViewActivity.kt @@ -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) ) } } diff --git a/app/src/main/java/chat/revolt/activities/media/VideoViewActivity.kt b/app/src/main/java/chat/revolt/activities/media/VideoViewActivity.kt index 61c0f207..0f2c5558 100644 --- a/app/src/main/java/chat/revolt/activities/media/VideoViewActivity.kt +++ b/app/src/main/java/chat/revolt/activities/media/VideoViewActivity.kt @@ -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) ) } } diff --git a/app/src/main/java/chat/revolt/components/chat/Message.kt b/app/src/main/java/chat/revolt/components/chat/Message.kt index 7f0854d7..26735561 100644 --- a/app/src/main/java/chat/revolt/components/chat/Message.kt +++ b/app/src/main/java/chat/revolt/components/chat/Message.kt @@ -238,6 +238,10 @@ fun Message( ) } + "Audio" -> { + /* no-op */ + } + else -> { viewAttachmentInBrowser(context, attachment) } diff --git a/app/src/main/java/chat/revolt/components/chat/MessageAttachment.kt b/app/src/main/java/chat/revolt/components/chat/MessageAttachment.kt index 53e6a703..63c6c0e9 100644 --- a/app/src/main/java/chat/revolt/components/chat/MessageAttachment.kt +++ b/app/src/main/java/chat/revolt/components/chat/MessageAttachment.kt @@ -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 diff --git a/app/src/main/java/chat/revolt/components/generic/PageHeader.kt b/app/src/main/java/chat/revolt/components/generic/PageHeader.kt index 6c28a401..8fbb4c6c 100644 --- a/app/src/main/java/chat/revolt/components/generic/PageHeader.kt +++ b/app/src/main/java/chat/revolt/components/generic/PageHeader.kt @@ -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, diff --git a/app/src/main/java/chat/revolt/components/media/AudioPlayer.kt b/app/src/main/java/chat/revolt/components/media/AudioPlayer.kt new file mode 100644 index 00000000..dc525f76 --- /dev/null +++ b/app/src/main/java/chat/revolt/components/media/AudioPlayer.kt @@ -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)) + } + ) + + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_pause_24dp.xml b/app/src/main/res/drawable/ic_pause_24dp.xml new file mode 100644 index 00000000..211a0bf1 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af4bbfca..80b06b0c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -228,23 +228,21 @@ You are banned from this server. An unknown error occurred. - %1$s - Save - Saved - Failed to save image - Open - Share URL - Share image - - %1$s - Play - Pause - Save - Saved - Failed to save video - Open - Share URL - Share video + %1$s + %1$s + Play + Pause + More… + Save + Saved + Failed to save audio + Failed to save video + Failed to save image + Open + Share URL + Share audio + Share video + Share image Appearance Theme