diff --git a/app/build.gradle b/app/build.gradle
index c13f19a1..2fb770d0 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -168,13 +168,19 @@ dependencies {
// Libraries used for legacy View-based UI
implementation "androidx.constraintlayout:constraintlayout:2.2.0-alpha09"
implementation 'com.github.MikeOrtiz:TouchImageView:3.3'
+ implementation "androidx.appcompat:appcompat:1.7.0-alpha02"
// JDK Desugaring - polyfill for new Java APIs
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
// Markdown
implementation "com.github.discord:SimpleAST:2.7.0"
- implementation "androidx.appcompat:appcompat:1.7.0-alpha02"
+
+ // AndroidX Media3 w/ ExoPlayer
+ implementation "androidx.media3:media3-exoplayer:$media3_version"
+ implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
+ implementation "androidx.media3:media3-datasource-okhttp:$media3_version"
+ implementation "androidx.media3:media3-ui:1.0.1"
}
kapt {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1e96b0ff..aad60517 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -80,6 +80,11 @@
android:name=".activities.media.ImageViewActivity"
android:theme="@style/Theme.Revolt" />
+
+
\ No newline at end of file
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 38b8d3c1..debb836a 100644
--- a/app/src/main/java/chat/revolt/activities/media/ImageViewActivity.kt
+++ b/app/src/main/java/chat/revolt/activities/media/ImageViewActivity.kt
@@ -1,9 +1,7 @@
package chat.revolt.activities.media
import android.content.ContentValues
-import android.content.Context
import android.content.Intent
-import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
@@ -42,21 +40,18 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
-import androidx.core.content.FileProvider
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewmodel.compose.viewModel
import chat.revolt.R
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltHttp
import chat.revolt.api.schemas.AutumnResource
import chat.revolt.api.settings.GlobalState
import chat.revolt.components.generic.PageHeader
+import chat.revolt.provider.getAttachmentContentUri
import chat.revolt.ui.theme.RevoltTheme
import com.bumptech.glide.Glide
import io.ktor.client.request.get
import io.ktor.client.statement.readBytes
import kotlinx.coroutines.launch
-import java.io.File
class ImageViewActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -81,36 +76,11 @@ class ImageViewActivity : ComponentActivity() {
}
}
-class ImageViewScreenViewModel : ViewModel() {
- suspend fun getAttachmentContentUri(
- context: Context,
- resourceUrl: String,
- id: String,
- filename: String
- ): Uri {
- val attachmentsDir = File(context.cacheDir, "attachments")
- if (!attachmentsDir.exists()) {
- attachmentsDir.mkdir()
- }
-
- val response = RevoltHttp.get(resourceUrl)
- val file = File(attachmentsDir, "$id-$filename")
- file.writeBytes(response.readBytes())
-
- return FileProvider.getUriForFile(
- context,
- "chat.revolt.fileprovider",
- file
- )
- }
-}
-
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImageViewScreen(
resource: AutumnResource,
- onClose: () -> Unit = {},
- viewModel: ImageViewScreenViewModel = viewModel()
+ onClose: () -> Unit = {}
) {
val resourceUrl = "$REVOLT_FILES/attachments/${resource.id}/${resource.filename}"
@@ -144,15 +114,13 @@ fun ImageViewScreen(
shareSubmenuIsOpen.value = false
coroutineScope.launch {
- val contentUri = viewModel.getAttachmentContentUri(
+ val contentUri = getAttachmentContentUri(
context,
resourceUrl,
resource.id!!,
resource.filename ?: "image"
)
- Log.d("ImageViewActivity", "Content URI: $contentUri")
-
val intent =
Intent(Intent.ACTION_SEND)
intent.type = resource.contentType ?: "image/*"
diff --git a/app/src/main/java/chat/revolt/activities/media/VideoViewActivity.kt b/app/src/main/java/chat/revolt/activities/media/VideoViewActivity.kt
new file mode 100644
index 00000000..61c0f207
--- /dev/null
+++ b/app/src/main/java/chat/revolt/activities/media/VideoViewActivity.kt
@@ -0,0 +1,283 @@
+package chat.revolt.activities.media
+
+import android.content.ContentValues
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.provider.MediaStore
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.MediaItem
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import chat.revolt.R
+import chat.revolt.api.REVOLT_FILES
+import chat.revolt.api.RevoltHttp
+import chat.revolt.api.schemas.AutumnResource
+import chat.revolt.api.settings.GlobalState
+import chat.revolt.components.generic.PageHeader
+import chat.revolt.provider.getAttachmentContentUri
+import chat.revolt.ui.theme.RevoltTheme
+import io.ktor.client.request.get
+import io.ktor.client.statement.readBytes
+import kotlinx.coroutines.launch
+
+class VideoViewActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val autumnResource = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra("autumnResource", AutumnResource::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra("autumnResource")
+ }
+
+ if (autumnResource?.id == null) {
+ Log.e("VideoViewActivity", "No AutumnResource provided")
+ finish()
+ return
+ }
+
+ setContent {
+ VideoViewScreen(resource = autumnResource, onClose = { finish() })
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
+@Composable
+fun VideoViewScreen(
+ resource: AutumnResource,
+ onClose: () -> Unit = {}
+) {
+ val resourceUrl = "$REVOLT_FILES/attachments/${resource.id}/${resource.filename}"
+
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+ val activityLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) {}
+
+ val shareSubmenuIsOpen = remember { mutableStateOf(false) }
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ fun shareUrl() {
+ shareSubmenuIsOpen.value = false
+
+ val intent =
+ Intent(Intent.ACTION_SEND)
+ intent.type = "text/plain"
+ intent.putExtra(
+ Intent.EXTRA_TEXT,
+ resourceUrl
+ )
+
+ val shareIntent = Intent.createChooser(intent, null)
+ activityLauncher.launch(shareIntent)
+ }
+
+ fun shareVideo() {
+ shareSubmenuIsOpen.value = false
+
+ coroutineScope.launch {
+ val contentUri = getAttachmentContentUri(
+ context,
+ resourceUrl,
+ resource.id!!,
+ resource.filename ?: "video"
+ )
+
+ val intent =
+ Intent(Intent.ACTION_SEND)
+ intent.type = resource.contentType ?: "video/*"
+ intent.putExtra(
+ Intent.EXTRA_STREAM,
+ contentUri
+ )
+
+ val shareIntent = Intent.createChooser(intent, null)
+ activityLauncher.launch(shareIntent)
+ }
+ }
+
+ fun saveToGallery() {
+ coroutineScope.launch {
+ context.applicationContext.let {
+ it.contentResolver.insert(
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
+ ContentValues().apply {
+ put(MediaStore.Video.Media.DISPLAY_NAME, resource.filename)
+ put(MediaStore.Video.Media.MIME_TYPE, resource.contentType)
+ put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/Revolt")
+ put(MediaStore.Video.Media.IS_PENDING, 1)
+ }
+ )
+ }?.let { uri ->
+ context.contentResolver.openOutputStream(uri).use { stream ->
+ val video = RevoltHttp.get(resourceUrl).readBytes()
+ stream?.write(video)
+
+ context.applicationContext.let {
+ it.contentResolver.update(
+ uri,
+ ContentValues().apply {
+ put(MediaStore.Video.Media.IS_PENDING, 0)
+ },
+ null,
+ null
+ )
+ }
+
+ val snackbar = snackbarHostState.showSnackbar(
+ message = context.getString(R.string.video_viewer_saved),
+ actionLabel = context.getString(R.string.video_viewer_open),
+ duration = SnackbarDuration.Short
+ )
+
+ if (snackbar == SnackbarResult.ActionPerformed) {
+ val intent = Intent(Intent.ACTION_VIEW)
+ intent.setDataAndType(uri, resource.contentType)
+ intent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ activityLauncher.launch(intent)
+ }
+ }
+ }
+ }
+ }
+
+ val player = remember {
+ ExoPlayer.Builder(context).build().apply {
+ setMediaItem(MediaItem.fromUri(resourceUrl))
+ prepare()
+ play()
+ }
+ }
+
+ DisposableEffect(player) {
+ onDispose {
+ player.release()
+ }
+ }
+
+ RevoltTheme(requestedTheme = GlobalState.theme) {
+ Scaffold(
+ snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
+ ) { pv ->
+ Surface(
+ modifier = Modifier
+ .padding(pv)
+ .background(MaterialTheme.colorScheme.background)
+ .fillMaxSize()
+ ) {
+ Column {
+ PageHeader(text = stringResource(
+ id = R.string.video_viewer_title, resource.filename ?: resource.id!!
+ ),
+ showBackButton = true,
+ onBackButtonClicked = onClose,
+ maxLines = 1,
+ additionalButtons = {
+ Row {
+ IconButton(onClick = {
+ shareSubmenuIsOpen.value = true
+ }) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_share_24dp),
+ contentDescription = stringResource(id = R.string.share)
+ )
+ }
+
+ DropdownMenu(
+ expanded = shareSubmenuIsOpen.value,
+ onDismissRequest = {
+ shareSubmenuIsOpen.value = false
+ }) {
+ DropdownMenuItem(
+ onClick = {
+ shareUrl()
+ },
+ text = {
+ Text(stringResource(id = R.string.video_viewer_share_url))
+ }
+ )
+ DropdownMenuItem(
+ onClick = {
+ shareVideo()
+ },
+ text = {
+ Text(stringResource(id = R.string.video_viewer_share_video))
+ }
+ )
+ }
+
+ IconButton(onClick = {
+ saveToGallery()
+ }) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_download_24dp),
+ contentDescription = stringResource(id = R.string.video_viewer_save)
+ )
+ }
+ }
+ })
+
+ Box(
+ modifier = Modifier
+ .clip(RectangleShape)
+ .fillMaxSize()
+ ) {
+ AndroidView(
+ factory = { context ->
+ PlayerView(context).apply {
+ setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
+ }
+ },
+ update = {
+ it.player = player
+ },
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ )
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
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 b571769b..7f0854d7 100644
--- a/app/src/main/java/chat/revolt/components/chat/Message.kt
+++ b/app/src/main/java/chat/revolt/components/chat/Message.kt
@@ -30,6 +30,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.res.ResourcesCompat
import chat.revolt.R
import chat.revolt.activities.media.ImageViewActivity
+import chat.revolt.activities.media.VideoViewActivity
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.asJanuaryProxyUrl
@@ -220,14 +221,26 @@ fun Message(
message.attachments.forEach { attachment ->
Spacer(modifier = Modifier.height(2.dp))
MessageAttachment(attachment) {
- if (attachment.metadata?.type == "Image") {
- attachmentView.launch(
- Intent(context, ImageViewActivity::class.java).apply {
- putExtra("autumnResource", attachment)
- }
- )
- } else {
- viewAttachmentInBrowser(context, attachment)
+ when (attachment.metadata?.type) {
+ "Image" -> {
+ attachmentView.launch(
+ Intent(context, ImageViewActivity::class.java).apply {
+ putExtra("autumnResource", attachment)
+ }
+ )
+ }
+
+ "Video" -> {
+ attachmentView.launch(
+ Intent(context, VideoViewActivity::class.java).apply {
+ putExtra("autumnResource", attachment)
+ }
+ )
+ }
+
+ else -> {
+ viewAttachmentInBrowser(context, attachment)
+ }
}
}
Spacer(modifier = Modifier.height(2.dp))
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 20ee3ec8..53e6a703 100644
--- a/app/src/main/java/chat/revolt/components/chat/MessageAttachment.kt
+++ b/app/src/main/java/chat/revolt/components/chat/MessageAttachment.kt
@@ -3,11 +3,20 @@ package chat.revolt.components.chat
import android.text.format.Formatter
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
-import androidx.compose.material.Icon
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.material.LocalContentColor
-import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -17,6 +26,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
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.unit.dp
import chat.revolt.R
@@ -76,8 +86,39 @@ fun ImageAttachment(attachment: AutumnResource) {
@Composable
fun VideoAttachment(attachment: AutumnResource) {
- // FIXME Use ExoPlayer to play videos.
- FileAttachment(attachment)
+ val url = "$REVOLT_FILES/attachments/${attachment.id}/${attachment.filename}"
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ // Turns out that when you give Glide a video URL, you get a perfectly cromulent thumbnail.
+ RemoteImage(
+ url = url,
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(attachment.metadata!!.width!!.toFloat() / attachment.metadata.height!!.toFloat()),
+ description = attachment.filename ?: "Video",
+ )
+
+ Box(
+ modifier = Modifier
+ .width(48.dp)
+ .aspectRatio(1f)
+ .clip(MaterialTheme.shapes.medium)
+ .background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)),
+ )
+
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = stringResource(id = R.string.video_viewer_play),
+ modifier = Modifier
+ .width(32.dp)
+ .aspectRatio(1f),
+ )
+ }
}
@Composable
diff --git a/app/src/main/java/chat/revolt/provider/AttachmentProvider.kt b/app/src/main/java/chat/revolt/provider/AttachmentProvider.kt
index 0d50f596..8477b9c8 100644
--- a/app/src/main/java/chat/revolt/provider/AttachmentProvider.kt
+++ b/app/src/main/java/chat/revolt/provider/AttachmentProvider.kt
@@ -1,7 +1,35 @@
package chat.revolt.provider
+import android.content.Context
+import android.net.Uri
import androidx.core.content.FileProvider
import chat.revolt.R
+import chat.revolt.api.RevoltHttp
+import io.ktor.client.request.get
+import io.ktor.client.statement.readBytes
+import java.io.File
class AttachmentProvider : FileProvider(R.xml.file_paths) {
+}
+
+suspend fun getAttachmentContentUri(
+ context: Context,
+ resourceUrl: String,
+ id: String,
+ filename: String
+): Uri {
+ val attachmentsDir = File(context.cacheDir, "attachments")
+ if (!attachmentsDir.exists()) {
+ attachmentsDir.mkdir()
+ }
+
+ val response = RevoltHttp.get(resourceUrl)
+ val file = File(attachmentsDir, "$id-$filename")
+ file.writeBytes(response.readBytes())
+
+ return FileProvider.getUriForFile(
+ context,
+ "chat.revolt.fileprovider",
+ file
+ )
}
\ 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 a3f396d2..af4bbfca 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -236,6 +236,16 @@
Share URL
Share image
+ %1$s
+ Play
+ Pause
+ Save
+ Saved
+ Failed to save video
+ Open
+ Share URL
+ Share video
+
Appearance
Theme
System
diff --git a/build.gradle b/build.gradle
index a0bf34cc..d65ddf02 100644
--- a/build.gradle
+++ b/build.gradle
@@ -10,6 +10,7 @@ buildscript {
glide_version = '4.14.2'
ktor_version = '2.1.3'
aboutlibraries_version = '10.5.2'
+ media3_version = '1.0.1'
}
}