From 655cd20a01fcfedf830d15578b0a618392103858 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Apr 2023 10:21:01 -0400 Subject: [PATCH] Adds support for video and image descriptions for accessibility use cases --- app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 1 + .../amethyst/service/FileHeader.kt | 4 +- .../amethyst/ui/actions/NewPostView.kt | 175 +++++++++++++++++- .../amethyst/ui/actions/NewPostViewModel.kt | 85 +++++---- .../amethyst/ui/components/VideoView.kt | 23 ++- .../ui/components/ZoomableContentView.kt | 4 +- .../ui/screen/loggedIn/ChannelScreen.kt | 2 +- .../ui/screen/loggedIn/ChatroomScreen.kt | 2 +- app/src/main/res/values/strings.xml | 7 + 10 files changed, 260 insertions(+), 45 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 5a206becf..5e5be2e08 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -137,7 +137,7 @@ dependencies { implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05' // view videos - implementation 'com.google.android.exoplayer:exoplayer:2.18.5' + implementation 'com.google.android.exoplayer:exoplayer:2.18.6' // Load images from the web. implementation "io.coil-kt:coil-compose:$coil_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 724d0252e..b7becbfa0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index a7543e56f..c4e6ff8ee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -18,7 +18,7 @@ class FileHeader( val description: String? = null ) { companion object { - fun prepare(fileUrl: String, mimeType: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { + fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { try { val imageData = URL(fileUrl).readBytes() val sha256 = MessageDigest.getInstance("SHA-256") @@ -55,7 +55,7 @@ class FileHeader( null } - onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, "")) + onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description)) } catch (e: Exception) { Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") onError() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index e5068b3d5..37904090f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -1,5 +1,9 @@ package com.vitorpamplona.amethyst.ui.actions +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.util.Size import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image @@ -13,10 +17,15 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -25,15 +34,18 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel @@ -48,7 +60,9 @@ import com.vitorpamplona.amethyst.ui.note.ReplyInformation import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -188,6 +202,19 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } } + val url = postViewModel.contentToAddUrl + if (url != null) { + ImageVideoDescription( + url, + onAdd = { description -> + postViewModel.upload(url, description, context) + }, + onCancel = { + postViewModel.contentToAddUrl = null + } + ) + } + val user = postViewModel.account?.userProfile() val lud16 = user?.info?.lnAddress() @@ -277,7 +304,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n tint = MaterialTheme.colors.onBackground, modifier = Modifier.padding(bottom = 10.dp) ) { - postViewModel.upload(it, context) + postViewModel.selectImage(it) } if (postViewModel.canUsePoll) { @@ -457,3 +484,149 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier ) } } + +@Composable +fun ImageVideoDescription( + uri: Uri, + onAdd: (String) -> Unit, + onCancel: () -> Unit +) { + val resolver = LocalContext.current.contentResolver + val mediaType = resolver.getType(uri) ?: "" + val scope = rememberCoroutineScope() + + val isImage = mediaType.startsWith("image") + val isVideo = mediaType.startsWith("video") + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 30.dp, end = 30.dp) + .clip(shape = RoundedCornerShape(10.dp)) + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + RoundedCornerShape(15.dp) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(30.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + ) { + Text( + text = stringResource( + if (isImage) { + R.string.content_description_add_image + } else { + if (isVideo) { + R.string.content_description_add_video + } else { + R.string.content_description_add_document + } + } + ), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier + .padding(start = 10.dp) + .weight(1.0f) + ) + + IconButton( + modifier = Modifier.size(30.dp), + onClick = onCancel + ) { + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Modifier + .padding(end = 5.dp) + .size(30.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + ) { + if (mediaType.startsWith("image")) { + AsyncImage( + model = uri.toString(), + contentDescription = uri.toString(), + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + ) + } else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + var bitmap by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = uri) { + scope.launch(Dispatchers.IO) { + bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null) + } + } + + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "some useful description", + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + ) + } + } else { + VideoView(uri) + } + } + + var message by remember { mutableStateOf("") } + + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, + modifier = Modifier.fillMaxWidth(), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ) + ) + + Button( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + onClick = { + onAdd(message) + }, + shape = RoundedCornerShape(15.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 5fccd7a43..600d60df1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -38,6 +38,9 @@ open class NewPostViewModel : ViewModel() { var userSuggestions by mutableStateOf>(emptyList()) var userSuggestionAnchor: TextRange? = null + // Images and Videos + var contentToAddUrl by mutableStateOf(null) + // Polls var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) @@ -84,6 +87,7 @@ open class NewPostViewModel : ViewModel() { canAddInvoice = account.userProfile().info?.lnAddress() != null canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null + contentToAddUrl = null this.account = account } @@ -105,46 +109,15 @@ open class NewPostViewModel : ViewModel() { cancel() } - fun upload(it: Uri, context: Context) { + fun upload(it: Uri, description: String, context: Context) { isUploadingImage = true + contentToAddUrl = null ImageUploader.uploadImage( uri = it, contentResolver = context.contentResolver, onSuccess = { imageUrl, mimeType -> - viewModelScope.launch(Dispatchers.IO) { - // Images don't seem to be ready immediately after upload - - if (mimeType?.startsWith("image/") == true) { - delay(2000) - } else { - delay(5000) - } - - FileHeader.prepare( - imageUrl, - mimeType, - onReady = { - val note = account?.sendHeader(it) - - isUploadingImage = false - - if (note == null) { - message = TextFieldValue(message.text + "\n\n" + imageUrl) - } else { - message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote()) - } - - urlPreview = findUrlInMessage() - }, - onError = { - isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - } - } - ) - } + createNIP97Record(imageUrl, mimeType, description) }, onError = { isUploadingImage = false @@ -157,6 +130,7 @@ open class NewPostViewModel : ViewModel() { open fun cancel() { message = TextFieldValue("") + contentToAddUrl = null urlPreview = null isUploadingImage = false mentions = null @@ -220,7 +194,7 @@ open class NewPostViewModel : ViewModel() { fun canPost(): Boolean { return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && - (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) + (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null } fun includePollHashtagInMessage(include: Boolean, hashtag: String) { @@ -235,4 +209,45 @@ open class NewPostViewModel : ViewModel() { ) } } + + fun createNIP97Record(imageUrl: String, mimeType: String?, description: String) { + viewModelScope.launch(Dispatchers.IO) { + // Images don't seem to be ready immediately after upload + + if (mimeType?.startsWith("image/") == true) { + delay(2000) + } else { + delay(5000) + } + + FileHeader.prepare( + imageUrl, + mimeType, + description, + onReady = { + val note = account?.sendHeader(it) + + isUploadingImage = false + + if (note == null) { + message = TextFieldValue(message.text + "\n\n" + imageUrl) + } else { + message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote()) + } + + urlPreview = findUrlInMessage() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } + } + + fun selectImage(uri: Uri) { + contentToAddUrl = uri + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index ab67d171d..21b0efb53 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.components +import android.net.Uri import android.view.ViewGroup import android.widget.FrameLayout import androidx.compose.foundation.layout.fillMaxWidth @@ -16,6 +17,7 @@ import androidx.lifecycle.LifecycleEventObserver import com.google.android.exoplayer2.C import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.ui.AspectRatioFrameLayout @@ -23,16 +25,33 @@ import com.google.android.exoplayer2.ui.StyledPlayerView import com.vitorpamplona.amethyst.VideoCache @Composable -fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) { +fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { + VideoView(Uri.parse(videoUri), description, onDialog) +} + +@Composable +fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { val context = LocalContext.current val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) val exoPlayer = remember(videoUri) { + val mediaBuilder = MediaItem.Builder().setUri(videoUri) + + description?.let { + mediaBuilder.setMediaMetadata( + MediaMetadata.Builder().setDisplayTitle(it).build() + ) + } + + val media = mediaBuilder.build() + ExoPlayer.Builder(context).build().apply { repeatMode = Player.REPEAT_MODE_ALL videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING setMediaSource( - ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(MediaItem.fromUri(videoUri)) + ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource( + media + ) ) prepare() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 69b053577..8f8c15cfa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -175,7 +175,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List } } } else { - VideoView(content.url) { dialogOpen = true } + VideoView(content.url, content.description) { dialogOpen = true } } if (dialogOpen) { @@ -322,7 +322,7 @@ private fun RenderImageOrVideo(content: ZoomableContent) { } } else { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { - VideoView(content.url) + VideoView(content.url, content.description) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 784d61262..fe3602a31 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -233,7 +233,7 @@ fun ChannelScreen( tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), modifier = Modifier.padding(start = 5.dp) ) { - channelScreenModel.upload(it, context) + channelScreenModel.upload(it, "", context) } }, colors = TextFieldDefaults.textFieldColors( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 07381c5e7..5dd3bb2ca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -194,7 +194,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), modifier = Modifier.padding(start = 5.dp) ) { - chatRoomScreenModel.upload(it, context) + chatRoomScreenModel.upload(it, "", context) } }, colors = TextFieldDefaults.textFieldColors( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3e3109e95..504fcfae0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -297,4 +297,11 @@ Image is the same since the post Image has changed. The author might not have seen the change + Add Image + Add Video + Add Document + Create and Add + Description of the contents + A blue boat in a white sandy beach at sunset +