mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2024-09-29 08:20:51 +00:00
Adds support for video and image descriptions for accessibility use cases
This commit is contained in:
parent
f1affc2dbb
commit
655cd20a01
@ -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"
|
||||
|
@ -6,6 +6,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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<Bitmap?>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -38,6 +38,9 @@ open class NewPostViewModel : ViewModel() {
|
||||
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||
var userSuggestionAnchor: TextRange? = null
|
||||
|
||||
// Images and Videos
|
||||
var contentToAddUrl by mutableStateOf<Uri?>(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
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -175,7 +175,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -297,4 +297,11 @@
|
||||
<string name="hash_verification_passed">Image is the same since the post</string>
|
||||
<string name="hash_verification_failed">Image has changed. The author might not have seen the change</string>
|
||||
|
||||
<string name="content_description_add_image">Add Image</string>
|
||||
<string name="content_description_add_video">Add Video</string>
|
||||
<string name="content_description_add_document">Add Document</string>
|
||||
<string name="add_content">Create and Add</string>
|
||||
<string name="content_description">Description of the contents</string>
|
||||
<string name="content_description_example">A blue boat in a white sandy beach at sunset</string>
|
||||
|
||||
</resources>
|
||||
|
Loading…
Reference in New Issue
Block a user