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