feat: video player (w/ exoplayer)

This commit is contained in:
Infi 2023-04-22 17:04:51 +02:00
parent 399177ca63
commit 9c418e81af
9 changed files with 404 additions and 49 deletions

View File

@ -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 {

View File

@ -80,6 +80,11 @@
android:name=".activities.media.ImageViewActivity"
android:theme="@style/Theme.Revolt" />
<activity
android:name=".activities.media.VideoViewActivity"
android:configChanges="orientation|screenSize"
android:theme="@style/Theme.Revolt" />
</application>
</manifest>

View File

@ -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/*"

View File

@ -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),
)
}
}
}
}
}
}

View File

@ -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))

View File

@ -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

View File

@ -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
)
}

View File

@ -236,6 +236,16 @@
<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="settings_appearance">Appearance</string>
<string name="settings_appearance_theme">Theme</string>
<string name="settings_appearance_theme_none">System</string>

View File

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