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' } }