- Displays Video Uploading Progress
- Keeps position in the Video feed between screen - Animates to the top when posting - Adds NIP-95 in-disk caching - Fixes the Crossfade for BlurHash on NIP94 and NIP95 events
This commit is contained in:
@@ -403,7 +403,7 @@ class Account(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendNip95(data: ByteArray, headerInfo: FileHeader): Note? {
|
fun createNip95(data: ByteArray, headerInfo: FileHeader): Pair<FileStorageEvent, FileStorageHeaderEvent>? {
|
||||||
if (!isWriteable()) return null
|
if (!isWriteable()) return null
|
||||||
|
|
||||||
val data = FileStorageEvent.create(
|
val data = FileStorageEvent.create(
|
||||||
@@ -422,6 +422,12 @@ class Account(
|
|||||||
privateKey = loggedIn.privKey!!
|
privateKey = loggedIn.privKey!!
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return Pair(data, signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
|
||||||
|
if (!isWriteable()) return null
|
||||||
|
|
||||||
Client.send(data)
|
Client.send(data)
|
||||||
LocalCache.consume(data, null)
|
LocalCache.consume(data, null)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.vitorpamplona.amethyst.model
|
package com.vitorpamplona.amethyst.model
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
import com.vitorpamplona.amethyst.service.model.*
|
import com.vitorpamplona.amethyst.service.model.*
|
||||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||||
@@ -11,6 +13,9 @@ import fr.acinq.secp256k1.Hex
|
|||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import nostr.postr.toNpub
|
import nostr.postr.toNpub
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -720,7 +725,21 @@ object LocalCache {
|
|||||||
// Already processed this event.
|
// Already processed this event.
|
||||||
if (note.event != null) return
|
if (note.event != null) return
|
||||||
|
|
||||||
note.loadEvent(event, author, emptyList())
|
try {
|
||||||
|
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||||
|
cachePath.mkdirs()
|
||||||
|
val stream = FileOutputStream(File(cachePath, event.id))
|
||||||
|
stream.write(event.decode())
|
||||||
|
stream.close()
|
||||||
|
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is an invalid event. But we don't need to keep the data in memory.
|
||||||
|
val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
|
||||||
|
|
||||||
|
note.loadEvent(eventNoData, author, emptyList())
|
||||||
|
|
||||||
refreshObservers(note)
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,14 @@ class FileHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
fun prepare(
|
||||||
|
data: ByteArray,
|
||||||
|
fileUrl: String,
|
||||||
|
mimeType: String?,
|
||||||
|
description: String?,
|
||||||
|
onReady: (FileHeader) -> Unit,
|
||||||
|
onError: () -> Unit
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||||
|
|
||||||
|
|||||||
@@ -86,10 +86,6 @@ open class BaseTextNoteEvent(
|
|||||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||||
|
|
||||||
if (parsed != null) {
|
if (parsed != null) {
|
||||||
if (content.contains("Testing event")) {
|
|
||||||
println("AAAA $key")
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -40,13 +40,9 @@ class LnZapPaymentResponseEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
|
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
|
||||||
println("Response Arrived: Decrypting")
|
|
||||||
if (content.isNotEmpty()) {
|
if (content.isNotEmpty()) {
|
||||||
val decrypted = decrypt(privKey, pubKey)
|
val decrypted = decrypt(privKey, pubKey)
|
||||||
println("Response Arrived: Parsing $decrypted")
|
gson.fromJson(decrypted, Response::class.java)
|
||||||
val response = gson.fromJson(decrypted, Response::class.java)
|
|
||||||
println("Response Arrived: Decrypted ${response?.resultType}")
|
|
||||||
response
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ object ImageSaver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun saveImage(
|
fun saveImage(
|
||||||
byteArray: ByteArray,
|
localFile: File,
|
||||||
mimeType: String?,
|
mimeType: String?,
|
||||||
context: Context,
|
context: Context,
|
||||||
onSuccess: () -> Any?,
|
onSuccess: () -> Any?,
|
||||||
@@ -87,7 +87,7 @@ object ImageSaver {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||||
val buffer = byteArray.inputStream().source().buffer()
|
val buffer = localFile.inputStream().source().buffer()
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
saveContentQ(
|
saveContentQ(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -13,6 +14,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
open class NewMediaModel : ViewModel() {
|
open class NewMediaModel : ViewModel() {
|
||||||
var account: Account? = null
|
var account: Account? = null
|
||||||
@@ -27,6 +29,11 @@ open class NewMediaModel : ViewModel() {
|
|||||||
// Images and Videos
|
// Images and Videos
|
||||||
var galleryUri by mutableStateOf<Uri?>(null)
|
var galleryUri by mutableStateOf<Uri?>(null)
|
||||||
|
|
||||||
|
var uploadingPercentage = mutableStateOf(0.0f)
|
||||||
|
var uploadingDescription = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
var onceUploaded: () -> Unit = {}
|
||||||
|
|
||||||
open fun load(account: Account, uri: Uri, contentType: String?) {
|
open fun load(account: Account, uri: Uri, contentType: String?) {
|
||||||
this.account = account
|
this.account = account
|
||||||
this.galleryUri = uri
|
this.galleryUri = uri
|
||||||
@@ -40,7 +47,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun upload(context: Context, onClose: () -> Unit) {
|
fun upload(context: Context) {
|
||||||
isUploadingImage = true
|
isUploadingImage = true
|
||||||
|
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
@@ -48,23 +55,32 @@ open class NewMediaModel : ViewModel() {
|
|||||||
val serverToUse = selectedServer ?: return
|
val serverToUse = selectedServer ?: return
|
||||||
|
|
||||||
if (selectedServer == ServersAvailable.NIP95) {
|
if (selectedServer == ServersAvailable.NIP95) {
|
||||||
|
uploadingPercentage.value = 0.1f
|
||||||
|
uploadingDescription.value = "Loading"
|
||||||
val contentType = contentResolver.getType(uri)
|
val contentType = contentResolver.getType(uri)
|
||||||
contentResolver.openInputStream(uri)?.use {
|
contentResolver.openInputStream(uri)?.use {
|
||||||
createNIP95Record(it.readBytes(), contentType, description, onClose)
|
createNIP95Record(it.readBytes(), contentType, description)
|
||||||
}
|
}
|
||||||
?: viewModelScope.launch {
|
?: viewModelScope.launch {
|
||||||
imageUploadingError.emit("Failed to upload the image / video")
|
imageUploadingError.emit("Failed to upload the image / video")
|
||||||
|
isUploadingImage = false
|
||||||
|
uploadingPercentage.value = 0.00f
|
||||||
|
uploadingDescription.value = null
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
uploadingPercentage.value = 0.1f
|
||||||
|
uploadingDescription.value = "Uploading"
|
||||||
ImageUploader.uploadImage(
|
ImageUploader.uploadImage(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
server = serverToUse,
|
server = serverToUse,
|
||||||
contentResolver = contentResolver,
|
contentResolver = contentResolver,
|
||||||
onSuccess = { imageUrl, mimeType ->
|
onSuccess = { imageUrl, mimeType ->
|
||||||
createNIP94Record(imageUrl, mimeType, description, onClose)
|
createNIP94Record(imageUrl, mimeType, description)
|
||||||
},
|
},
|
||||||
onError = {
|
onError = {
|
||||||
isUploadingImage = false
|
isUploadingImage = false
|
||||||
|
uploadingPercentage.value = 0.00f
|
||||||
|
uploadingDescription.value = null
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
imageUploadingError.emit("Failed to upload the image / video")
|
imageUploadingError.emit("Failed to upload the image / video")
|
||||||
}
|
}
|
||||||
@@ -77,6 +93,8 @@ open class NewMediaModel : ViewModel() {
|
|||||||
galleryUri = null
|
galleryUri = null
|
||||||
isUploadingImage = false
|
isUploadingImage = false
|
||||||
mediaType = null
|
mediaType = null
|
||||||
|
uploadingDescription.value = null
|
||||||
|
uploadingPercentage.value = 0.0f
|
||||||
|
|
||||||
description = ""
|
description = ""
|
||||||
selectedServer = account?.defaultFileServer
|
selectedServer = account?.defaultFileServer
|
||||||
@@ -86,37 +104,68 @@ open class NewMediaModel : ViewModel() {
|
|||||||
return !isUploadingImage && galleryUri != null && selectedServer != null
|
return !isUploadingImage && galleryUri != null && selectedServer != null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, onClose: () -> Unit) {
|
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
|
||||||
|
uploadingPercentage.value = 0.40f
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
uploadingDescription.value = "Server Processing"
|
||||||
// Images don't seem to be ready immediately after upload
|
// Images don't seem to be ready immediately after upload
|
||||||
|
|
||||||
if (mimeType?.startsWith("image/") == true) {
|
if (mimeType?.startsWith("image/") == true) {
|
||||||
delay(2000)
|
delay(2000)
|
||||||
} else {
|
} else {
|
||||||
delay(5000)
|
delay(15000)
|
||||||
}
|
}
|
||||||
|
|
||||||
FileHeader.prepare(
|
uploadingDescription.value = "Downloading"
|
||||||
imageUrl,
|
uploadingPercentage.value = 0.60f
|
||||||
mimeType,
|
|
||||||
description,
|
try {
|
||||||
onReady = {
|
val imageData = URL(imageUrl).readBytes()
|
||||||
val note = account?.sendHeader(it)
|
|
||||||
isUploadingImage = false
|
uploadingPercentage.value = 0.80f
|
||||||
cancel()
|
uploadingDescription.value = "Hashing"
|
||||||
onClose()
|
|
||||||
},
|
FileHeader.prepare(
|
||||||
onError = {
|
imageData,
|
||||||
isUploadingImage = false
|
imageUrl,
|
||||||
viewModelScope.launch {
|
mimeType,
|
||||||
imageUploadingError.emit("Failed to upload the image / video")
|
description,
|
||||||
|
onReady = {
|
||||||
|
uploadingPercentage.value = 0.90f
|
||||||
|
uploadingDescription.value = "Sending"
|
||||||
|
val note = account?.sendHeader(it)
|
||||||
|
uploadingPercentage.value = 1.00f
|
||||||
|
isUploadingImage = false
|
||||||
|
onceUploaded()
|
||||||
|
cancel()
|
||||||
|
},
|
||||||
|
onError = {
|
||||||
|
cancel()
|
||||||
|
uploadingPercentage.value = 0.00f
|
||||||
|
uploadingDescription.value = null
|
||||||
|
isUploadingImage = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
imageUploadingError.emit("Failed to upload the image / video")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||||
|
cancel()
|
||||||
|
uploadingPercentage.value = 0.00f
|
||||||
|
uploadingDescription.value = null
|
||||||
|
isUploadingImage = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
imageUploadingError.emit("Failed to upload the image / video")
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, onClose: () -> Unit) {
|
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
|
||||||
|
uploadingPercentage.value = 0.20f
|
||||||
|
uploadingDescription.value = "Hashing"
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
FileHeader.prepare(
|
FileHeader.prepare(
|
||||||
bytes,
|
bytes,
|
||||||
@@ -124,13 +173,26 @@ open class NewMediaModel : ViewModel() {
|
|||||||
mimeType,
|
mimeType,
|
||||||
description,
|
description,
|
||||||
onReady = {
|
onReady = {
|
||||||
account?.sendNip95(bytes, headerInfo = it)
|
uploadingDescription.value = "Signing"
|
||||||
|
uploadingPercentage.value = 0.30f
|
||||||
|
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||||
|
|
||||||
|
if (nip95 != null) {
|
||||||
|
uploadingDescription.value = "Sending"
|
||||||
|
uploadingPercentage.value = 0.60f
|
||||||
|
account?.sendNip95(nip95.first, nip95.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadingPercentage.value = 1.00f
|
||||||
isUploadingImage = false
|
isUploadingImage = false
|
||||||
|
onceUploaded()
|
||||||
cancel()
|
cancel()
|
||||||
onClose()
|
|
||||||
},
|
},
|
||||||
onError = {
|
onError = {
|
||||||
|
uploadingDescription.value = null
|
||||||
|
uploadingPercentage.value = 0.00f
|
||||||
isUploadingImage = false
|
isUploadingImage = false
|
||||||
|
cancel()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
imageUploadingError.emit("Failed to upload the image / video")
|
imageUploadingError.emit("Failed to upload the image / video")
|
||||||
}
|
}
|
||||||
@@ -142,4 +204,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
fun isImage() = mediaType?.startsWith("image")
|
fun isImage() = mediaType?.startsWith("image")
|
||||||
fun isVideo() = mediaType?.startsWith("video")
|
fun isVideo() = mediaType?.startsWith("video")
|
||||||
fun defaultServer() = account?.defaultFileServer
|
fun defaultServer() = account?.defaultFileServer
|
||||||
|
fun onceUploaded(onceUploaded: () -> Unit) {
|
||||||
|
this.onceUploaded = onceUploaded
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
@@ -36,23 +35,20 @@ import com.vitorpamplona.amethyst.ui.components.*
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewModel, navController: NavController) {
|
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
val account = accountViewModel.accountLiveData.value?.account ?: return
|
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||||
val resolver = LocalContext.current.contentResolver
|
val resolver = LocalContext.current.contentResolver
|
||||||
val postViewModel: NewMediaModel = viewModel()
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val scroolState = rememberScrollState()
|
val scroolState = rememberScrollState()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
val mediaType = resolver.getType(uri) ?: ""
|
||||||
val mediaType = resolver.getType(uri) ?: ""
|
postViewModel.load(account, uri, mediaType)
|
||||||
postViewModel.load(account, uri, mediaType)
|
|
||||||
delay(100)
|
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
postViewModel.imageUploadingError.collect { error ->
|
postViewModel.imageUploadingError.collect { error ->
|
||||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
@@ -85,15 +81,11 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewMod
|
|||||||
onClose()
|
onClose()
|
||||||
})
|
})
|
||||||
|
|
||||||
if (postViewModel.isUploadingImage) {
|
|
||||||
LoadingAnimation()
|
|
||||||
}
|
|
||||||
|
|
||||||
PostButton(
|
PostButton(
|
||||||
onPost = {
|
onPost = {
|
||||||
postViewModel.upload(context) {
|
onClose()
|
||||||
onClose()
|
postViewModel.upload(context)
|
||||||
}
|
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
|
||||||
},
|
},
|
||||||
isActive = postViewModel.canPost()
|
isActive = postViewModel.canPost()
|
||||||
)
|
)
|
||||||
@@ -154,7 +146,11 @@ fun ImageVideoPost(postViewModel: NewMediaModel) {
|
|||||||
LaunchedEffect(key1 = postViewModel.galleryUri) {
|
LaunchedEffect(key1 = postViewModel.galleryUri) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
postViewModel.galleryUri?.let {
|
postViewModel.galleryUri?.let {
|
||||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
try {
|
||||||
|
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
postViewModel.imageUploadingError.emit("Unable to load file")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +178,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel) {
|
|||||||
) {
|
) {
|
||||||
TextSpinner(
|
TextSpinner(
|
||||||
label = stringResource(id = R.string.file_server),
|
label = stringResource(id = R.string.file_server),
|
||||||
placeholder = fileServers.filter { it.first == postViewModel.defaultServer() }.firstOrNull()?.second ?: fileServers[0].second,
|
placeholder = fileServers.firstOrNull { it.first == postViewModel.selectedServer }?.second ?: fileServers[0].second,
|
||||||
options = fileServerOptions,
|
options = fileServerOptions,
|
||||||
explainers = fileServerExplainers,
|
explainers = fileServerExplainers,
|
||||||
onSelect = {
|
onSelect = {
|
||||||
|
|||||||
@@ -329,7 +329,8 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
mimeType,
|
mimeType,
|
||||||
description,
|
description,
|
||||||
onReady = {
|
onReady = {
|
||||||
val note = account?.sendNip95(bytes, headerInfo = it)
|
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||||
|
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }
|
||||||
|
|
||||||
isUploadingImage = false
|
isUploadingImage = false
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import com.google.accompanist.permissions.isGranted
|
|||||||
import com.google.accompanist.permissions.rememberPermissionState
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A button to save the remote image to the gallery.
|
* A button to save the remote image to the gallery.
|
||||||
@@ -86,14 +87,14 @@ fun SaveToGallery(url: String) {
|
|||||||
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SaveToGallery(byteArray: ByteArray, mimeType: String?) {
|
fun SaveToGallery(localFile: File, mimeType: String?) {
|
||||||
val localContext = LocalContext.current
|
val localContext = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
fun saveImage() {
|
fun saveImage() {
|
||||||
ImageSaver.saveImage(
|
ImageSaver.saveImage(
|
||||||
context = localContext,
|
context = localContext,
|
||||||
byteArray = byteArray,
|
localFile = localFile,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import com.google.android.exoplayer2.C
|
import com.google.android.exoplayer2.C
|
||||||
@@ -22,7 +23,14 @@ import com.google.android.exoplayer2.Player
|
|||||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||||
|
import com.google.android.exoplayer2.util.EventLogger
|
||||||
import com.vitorpamplona.amethyst.VideoCache
|
import com.vitorpamplona.amethyst.VideoCache
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||||
|
VideoView(localFile.toUri(), description, onDialog)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||||
@@ -48,15 +56,21 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
|||||||
ExoPlayer.Builder(context).build().apply {
|
ExoPlayer.Builder(context).build().apply {
|
||||||
repeatMode = Player.REPEAT_MODE_ALL
|
repeatMode = Player.REPEAT_MODE_ALL
|
||||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
||||||
setMediaSource(
|
if (videoUri.scheme?.startsWith("file") == true) {
|
||||||
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
|
setMediaItem(media)
|
||||||
media
|
} else {
|
||||||
|
setMediaSource(
|
||||||
|
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
|
||||||
|
media
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
prepare()
|
prepare()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exoPlayer.addAnalyticsListener(EventLogger())
|
||||||
|
|
||||||
DisposableEffect(
|
DisposableEffect(
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -95,7 +109,3 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun VideoView(videoBytes: ByteArray, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.ui.components
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -71,6 +74,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.engawapg.lib.zoomable.rememberZoomState
|
import net.engawapg.lib.zoomable.rememberZoomState
|
||||||
import net.engawapg.lib.zoomable.zoomable
|
import net.engawapg.lib.zoomable.zoomable
|
||||||
|
import java.io.File
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
abstract class ZoomableContent(
|
abstract class ZoomableContent(
|
||||||
@@ -106,7 +110,7 @@ abstract class ZoomablePreloadedContent(
|
|||||||
) : ZoomableContent(description)
|
) : ZoomableContent(description)
|
||||||
|
|
||||||
class ZoomableBitmapImage(
|
class ZoomableBitmapImage(
|
||||||
val byteArray: ByteArray?,
|
val localFile: File,
|
||||||
val mimeType: String? = null,
|
val mimeType: String? = null,
|
||||||
description: String? = null,
|
description: String? = null,
|
||||||
val bluehash: String? = null,
|
val bluehash: String? = null,
|
||||||
@@ -115,7 +119,7 @@ class ZoomableBitmapImage(
|
|||||||
) : ZoomablePreloadedContent(description, isVerified, uri)
|
) : ZoomablePreloadedContent(description, isVerified, uri)
|
||||||
|
|
||||||
class ZoomableBytesVideo(
|
class ZoomableBytesVideo(
|
||||||
val byteArray: ByteArray,
|
val localFile: File,
|
||||||
val mimeType: String? = null,
|
val mimeType: String? = null,
|
||||||
description: String? = null,
|
description: String? = null,
|
||||||
isVerified: Boolean? = null,
|
isVerified: Boolean? = null,
|
||||||
@@ -166,7 +170,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (content is ZoomableBitmapImage) {
|
} else if (content is ZoomableBitmapImage) {
|
||||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
|
||||||
if (imageState is AsyncImagePainter.State.Success) {
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
verifiedHash = content.isVerified
|
verifiedHash = content.isVerified
|
||||||
@@ -174,12 +178,8 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (content is ZoomableBytesVideo) {
|
} else if (content is ZoomableBytesVideo) {
|
||||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
|
||||||
if (imageState is AsyncImagePainter.State.Success) {
|
verifiedHash = content.isVerified
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
verifiedHash = content.isVerified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (content is ZoomableUrlImage) {
|
if (content is ZoomableUrlImage) {
|
||||||
Box() {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = content.url,
|
model = content.url,
|
||||||
contentDescription = content.description,
|
contentDescription = content.description,
|
||||||
@@ -231,13 +231,16 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
|||||||
if (imageState is AsyncImagePainter.State.Success) {
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (imageState !is AsyncImagePainter.State.Success) {
|
AnimatedVisibility(
|
||||||
if (content.bluehash != null) {
|
visible = imageState !is AsyncImagePainter.State.Success,
|
||||||
DisplayBlueHash(content, mainImageModifier)
|
exit = fadeOut(animationSpec = tween(200))
|
||||||
} else {
|
) {
|
||||||
DisplayUrlWithLoadingSymbol(content)
|
if (content.bluehash != null) {
|
||||||
|
DisplayBlueHash(content, mainImageModifier)
|
||||||
|
} else {
|
||||||
|
DisplayUrlWithLoadingSymbol(content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (content is ZoomableUrlVideo) {
|
} else if (content is ZoomableUrlVideo) {
|
||||||
@@ -245,7 +248,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
|||||||
} else if (content is ZoomableBitmapImage) {
|
} else if (content is ZoomableBitmapImage) {
|
||||||
Box() {
|
Box() {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = content.byteArray,
|
model = content.localFile,
|
||||||
contentDescription = content.description,
|
contentDescription = content.description,
|
||||||
contentScale = ContentScale.FillWidth,
|
contentScale = ContentScale.FillWidth,
|
||||||
modifier = mainImageModifier,
|
modifier = mainImageModifier,
|
||||||
@@ -270,6 +273,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (content is ZoomableBytesVideo) {
|
} else if (content is ZoomableBytesVideo) {
|
||||||
|
VideoView(content.localFile, content.description) { dialogOpen = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
@@ -384,8 +388,8 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
|
|||||||
val myContent = allImages[pagerState.currentPage]
|
val myContent = allImages[pagerState.currentPage]
|
||||||
if (myContent is ZoomableUrlContent) {
|
if (myContent is ZoomableUrlContent) {
|
||||||
SaveToGallery(url = myContent.url)
|
SaveToGallery(url = myContent.url)
|
||||||
} else if (myContent is ZoomableBitmapImage && myContent.byteArray != null) {
|
} else if (myContent is ZoomableBitmapImage && myContent.localFile != null) {
|
||||||
SaveToGallery(byteArray = myContent.byteArray, mimeType = myContent.mimeType)
|
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +432,7 @@ fun RenderImageOrVideo(content: ZoomableContent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (content is ZoomableBitmapImage) {
|
} else if (content is ZoomableBitmapImage) {
|
||||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
|
||||||
if (imageState is AsyncImagePainter.State.Success) {
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
verifiedHash = content.isVerified
|
verifiedHash = content.isVerified
|
||||||
@@ -436,12 +440,8 @@ fun RenderImageOrVideo(content: ZoomableContent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (content is ZoomableBytesVideo) {
|
} else if (content is ZoomableBytesVideo) {
|
||||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
|
||||||
if (imageState is AsyncImagePainter.State.Success) {
|
verifiedHash = content.isVerified
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
verifiedHash = content.isVerified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,11 +461,21 @@ fun RenderImageOrVideo(content: ZoomableContent) {
|
|||||||
imageState = it
|
imageState = it
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (imageState !is AsyncImagePainter.State.Success) {
|
|
||||||
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
} else {
|
|
||||||
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = imageState !is AsyncImagePainter.State.Success,
|
||||||
|
exit = fadeOut(animationSpec = tween(200))
|
||||||
|
) {
|
||||||
|
if (content.bluehash != null) {
|
||||||
|
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
|
||||||
|
} else {
|
||||||
|
DisplayUrlWithLoadingSymbol(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (content is ZoomableUrlVideo) {
|
} else if (content is ZoomableUrlVideo) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||||
@@ -474,7 +484,7 @@ fun RenderImageOrVideo(content: ZoomableContent) {
|
|||||||
} else if (content is ZoomableBitmapImage) {
|
} else if (content is ZoomableBitmapImage) {
|
||||||
Box() {
|
Box() {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = content.byteArray,
|
model = content.localFile,
|
||||||
contentDescription = content.description,
|
contentDescription = content.description,
|
||||||
contentScale = ContentScale.FillWidth,
|
contentScale = ContentScale.FillWidth,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -496,7 +506,7 @@ fun RenderImageOrVideo(content: ZoomableContent) {
|
|||||||
}
|
}
|
||||||
} else if (content is ZoomableBytesVideo) {
|
} else if (content is ZoomableBytesVideo) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||||
VideoView(content.byteArray, content.description)
|
VideoView(content.localFile, content.description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
|||||||
import com.vitorpamplona.amethyst.ui.theme.Following
|
import com.vitorpamplona.amethyst.ui.theme.Following
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import kotlin.time.ExperimentalTime
|
import kotlin.time.ExperimentalTime
|
||||||
@@ -892,6 +893,7 @@ fun FileHeaderDisplay(note: Note) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FileStorageHeaderDisplay(baseNote: Note) {
|
fun FileStorageHeaderDisplay(baseNote: Note) {
|
||||||
|
val appContext = LocalContext.current.applicationContext
|
||||||
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
|
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
|
||||||
|
|
||||||
val fileNote = eventHeader.dataEventId()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
val fileNote = eventHeader.dataEventId()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||||
@@ -906,16 +908,17 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
|
|||||||
LaunchedEffect(key1 = eventHeader.id, key2 = noteState) {
|
LaunchedEffect(key1 = eventHeader.id, key2 = noteState) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val uri = "nostr:" + baseNote.toNEvent()
|
val uri = "nostr:" + baseNote.toNEvent()
|
||||||
|
val localDir = File(File(appContext.externalCacheDir, "NIP95"), fileNote.idHex)
|
||||||
val bytes = eventBytes?.decode()
|
val bytes = eventBytes?.decode()
|
||||||
val blurHash = eventHeader.blurhash()
|
val blurHash = eventHeader.blurhash()
|
||||||
val description = eventHeader.content
|
val description = eventHeader.content
|
||||||
val mimeType = eventHeader.mimeType()
|
val mimeType = eventHeader.mimeType()
|
||||||
|
|
||||||
content = if (mimeType?.startsWith("image") == true) {
|
content = if (mimeType?.startsWith("image") == true) {
|
||||||
ZoomableBitmapImage(bytes, mimeType, description, blurHash, true, uri)
|
ZoomableBitmapImage(localDir, mimeType, description, blurHash, true, uri)
|
||||||
} else {
|
} else {
|
||||||
if (bytes != null) {
|
if (bytes != null) {
|
||||||
ZoomableBytesVideo(bytes, mimeType, description, true, uri)
|
ZoomableBytesVideo(localDir, mimeType, description, true, uri)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private val savedScrollStates = mutableMapOf<String, ScrollState>()
|
private val savedScrollStates = mutableMapOf<String, ScrollState>()
|
||||||
private data class ScrollState(val index: Int, val scrollOffset: Int)
|
private data class ScrollState(val index: Int, val scrollOffsetFraction: Float)
|
||||||
|
|
||||||
object ScrollStateKeys {
|
object ScrollStateKeys {
|
||||||
const val GLOBAL_SCREEN = "Global"
|
const val GLOBAL_SCREEN = "Global"
|
||||||
const val NOTIFICATION_SCREEN = "Notifications"
|
const val NOTIFICATION_SCREEN = "Notifications"
|
||||||
|
const val VIDEO_SCREEN = "Video"
|
||||||
val HOME_FOLLOWS = Route.Home.base + "Follows"
|
val HOME_FOLLOWS = Route.Home.base + "Follows"
|
||||||
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
|
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
|
||||||
}
|
}
|
||||||
@@ -25,16 +29,42 @@ fun rememberForeverLazyListState(
|
|||||||
val scrollState = rememberSaveable(saver = LazyListState.Saver) {
|
val scrollState = rememberSaveable(saver = LazyListState.Saver) {
|
||||||
val savedValue = savedScrollStates[key]
|
val savedValue = savedScrollStates[key]
|
||||||
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
|
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
|
||||||
val savedOffset = savedValue?.scrollOffset ?: initialFirstVisibleItemScrollOffset
|
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat()
|
||||||
LazyListState(
|
LazyListState(
|
||||||
savedIndex,
|
savedIndex,
|
||||||
savedOffset
|
savedOffset.roundToInt()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
val lastIndex = scrollState.firstVisibleItemIndex
|
val lastIndex = scrollState.firstVisibleItemIndex
|
||||||
val lastOffset = scrollState.firstVisibleItemScrollOffset
|
val lastOffset = scrollState.firstVisibleItemScrollOffset
|
||||||
|
savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scrollState
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun rememberForeverPagerState(
|
||||||
|
key: String,
|
||||||
|
initialFirstVisibleItemIndex: Int = 0,
|
||||||
|
initialFirstVisibleItemScrollOffset: Float = 0.0f
|
||||||
|
): PagerState {
|
||||||
|
val scrollState = rememberSaveable(saver = PagerState.Saver) {
|
||||||
|
val savedValue = savedScrollStates[key]
|
||||||
|
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
|
||||||
|
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset
|
||||||
|
PagerState(
|
||||||
|
savedIndex,
|
||||||
|
savedOffset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
val lastIndex = scrollState.currentPage
|
||||||
|
val lastOffset = scrollState.currentPageOffsetFraction
|
||||||
savedScrollStates[key] = ScrollState(lastIndex, lastOffset)
|
savedScrollStates[key] = ScrollState(lastIndex, lastOffset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import android.os.Build
|
|||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -22,10 +24,12 @@ import androidx.compose.foundation.pager.PagerState
|
|||||||
import androidx.compose.foundation.pager.VerticalPager
|
import androidx.compose.foundation.pager.VerticalPager
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.ButtonDefaults
|
import androidx.compose.material.ButtonDefaults
|
||||||
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
import androidx.compose.material.IconButton
|
import androidx.compose.material.IconButton
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.OutlinedButton
|
import androidx.compose.material.OutlinedButton
|
||||||
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -37,16 +41,27 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.graphics.ColorMatrix
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import com.google.accompanist.flowlayout.FlowRow
|
||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.google.accompanist.permissions.isGranted
|
import com.google.accompanist.permissions.isGranted
|
||||||
import com.google.accompanist.permissions.rememberPermissionState
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
@@ -56,10 +71,13 @@ import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
|||||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||||
import com.vitorpamplona.amethyst.ui.actions.GallerySelect
|
import com.vitorpamplona.amethyst.ui.actions.GallerySelect
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
|
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||||
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||||
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
|
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
|
||||||
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
|
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
|
||||||
import com.vitorpamplona.amethyst.ui.note.LikeReaction
|
import com.vitorpamplona.amethyst.ui.note.LikeReaction
|
||||||
@@ -73,6 +91,10 @@ import com.vitorpamplona.amethyst.ui.screen.FeedError
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.FeedState
|
import com.vitorpamplona.amethyst.ui.screen.FeedState
|
||||||
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
|
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
|
||||||
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
|
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VideoScreen(
|
fun VideoScreen(
|
||||||
@@ -116,7 +138,7 @@ fun VideoScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(vertical = 0.dp)
|
modifier = Modifier.padding(vertical = 0.dp)
|
||||||
) {
|
) {
|
||||||
FeedView(videoFeedView, accountViewModel, navController)
|
FeedView(videoFeedView, accountViewModel, navController, ScrollStateKeys.VIDEO_SCREEN, scrollToTop)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +147,9 @@ fun VideoScreen(
|
|||||||
fun FeedView(
|
fun FeedView(
|
||||||
videoFeedView: NostrVideoFeedViewModel,
|
videoFeedView: NostrVideoFeedViewModel,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
navController: NavController
|
navController: NavController,
|
||||||
|
scrollStateKey: String? = null,
|
||||||
|
scrollToTop: Boolean = false
|
||||||
) {
|
) {
|
||||||
val feedState by videoFeedView.feedContent.collectAsState()
|
val feedState by videoFeedView.feedContent.collectAsState()
|
||||||
|
|
||||||
@@ -148,7 +172,9 @@ fun FeedView(
|
|||||||
SlidingCarousel(
|
SlidingCarousel(
|
||||||
state.feed,
|
state.feed,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
navController
|
navController,
|
||||||
|
scrollStateKey,
|
||||||
|
scrollToTop
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,9 +192,21 @@ fun FeedView(
|
|||||||
fun SlidingCarousel(
|
fun SlidingCarousel(
|
||||||
feed: MutableState<List<Note>>,
|
feed: MutableState<List<Note>>,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
navController: NavController
|
navController: NavController,
|
||||||
|
scrollStateKey: String? = null,
|
||||||
|
scrollToTop: Boolean = false
|
||||||
) {
|
) {
|
||||||
val pagerState: PagerState = remember { PagerState() }
|
val pagerState = if (scrollStateKey != null) {
|
||||||
|
rememberForeverPagerState(scrollStateKey)
|
||||||
|
} else {
|
||||||
|
remember { PagerState() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollToTop) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
pagerState.scrollToPage(page = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
VerticalPager(
|
VerticalPager(
|
||||||
pageCount = feed.value.size,
|
pageCount = feed.value.size,
|
||||||
@@ -212,14 +250,14 @@ private fun RenderVideoOrPictureNote(
|
|||||||
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) {
|
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) {
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) {
|
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) {
|
||||||
Column(Modifier.size(45.dp), verticalArrangement = Arrangement.Center) {
|
Column(Modifier.size(55.dp), verticalArrangement = Arrangement.Center) {
|
||||||
NoteAuthorPicture(note, navController, loggedIn, 45.dp)
|
NoteAuthorPicture(note, navController, loggedIn, 55.dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 10.dp, end = 10.dp)
|
.padding(start = 10.dp, end = 10.dp)
|
||||||
.height(45.dp)
|
.height(60.dp)
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
@@ -243,6 +281,9 @@ private fun RenderVideoOrPictureNote(
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
|
ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp)) {
|
||||||
|
RelayBadges(baseNote = note)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,6 +301,42 @@ private fun RenderVideoOrPictureNote(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RelayBadges(baseNote: Note) {
|
||||||
|
val noteRelaysState by baseNote.live().relays.observeAsState()
|
||||||
|
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
|
||||||
|
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val relaysToDisplay = noteRelays
|
||||||
|
|
||||||
|
val uri = LocalUriHandler.current
|
||||||
|
|
||||||
|
FlowRow() {
|
||||||
|
relaysToDisplay.forEach {
|
||||||
|
val url = it.removePrefix("wss://").removePrefix("ws://")
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(15.dp)
|
||||||
|
.padding(1.dp)
|
||||||
|
) {
|
||||||
|
RobohashFallbackAsyncImage(
|
||||||
|
robot = "https://$url/favicon.ico",
|
||||||
|
robotSize = 15.dp,
|
||||||
|
model = "https://$url/favicon.ico",
|
||||||
|
contentDescription = stringResource(id = R.string.relay_icon),
|
||||||
|
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize(1f)
|
||||||
|
.clip(shape = CircleShape)
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
.clickable(onClick = { uri.openUri("https://$url") })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
|
fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||||
@@ -308,6 +385,26 @@ fun NewImageButton(accountViewModel: AccountViewModel, navController: NavControl
|
|||||||
mutableStateOf<Uri?>(null)
|
mutableStateOf<Uri?>(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val postViewModel: NewMediaModel = viewModel()
|
||||||
|
postViewModel.onceUploaded {
|
||||||
|
scope.launch {
|
||||||
|
// awaits an refresh on the list
|
||||||
|
delay(250)
|
||||||
|
val route = Route.Video.route.replace("{scrollToTop}", "true")
|
||||||
|
navController.navigate(route) {
|
||||||
|
navController.graph.startDestinationRoute?.let { start ->
|
||||||
|
popUpTo(start) { inclusive = false }
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (wantsToPost) {
|
if (wantsToPost) {
|
||||||
val cameraPermissionState =
|
val cameraPermissionState =
|
||||||
rememberPermissionState(
|
rememberPermissionState(
|
||||||
@@ -339,21 +436,53 @@ fun NewImageButton(accountViewModel: AccountViewModel, navController: NavControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
pickedURI?.let {
|
pickedURI?.let {
|
||||||
NewMediaView(it, onClose = { pickedURI = null }, accountViewModel = accountViewModel, navController = navController)
|
NewMediaView(
|
||||||
}
|
uri = it,
|
||||||
|
onClose = { pickedURI = null },
|
||||||
OutlinedButton(
|
postViewModel = postViewModel,
|
||||||
onClick = { wantsToPost = true },
|
accountViewModel = accountViewModel,
|
||||||
modifier = Modifier.size(55.dp),
|
navController = navController
|
||||||
shape = CircleShape,
|
|
||||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
|
||||||
contentPadding = PaddingValues(0.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_compose),
|
|
||||||
null,
|
|
||||||
modifier = Modifier.size(26.dp),
|
|
||||||
tint = Color.White
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (postViewModel.isUploadingImage) {
|
||||||
|
ShowProgress(postViewModel)
|
||||||
|
} else {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { wantsToPost = true },
|
||||||
|
modifier = Modifier.size(55.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||||
|
contentPadding = PaddingValues(0.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_compose),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(26.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShowProgress(postViewModel: NewMediaModel) {
|
||||||
|
Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = postViewModel.uploadingPercentage.value,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(55.dp)
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
.clip(CircleShape),
|
||||||
|
strokeWidth = 5.dp
|
||||||
|
)
|
||||||
|
postViewModel.uploadingDescription.value?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = MaterialTheme.colors.onSurface,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user