Merge branch 'edit_video' into 'master'
Video edit functionality See merge request pixeldroid/PixelDroid!429
This commit is contained in:
commit
cc37bfefce
@ -133,7 +133,7 @@ dependencies {
|
||||
|
||||
|
||||
// Use the most recent version of CameraX
|
||||
def cameraX_version = '1.1.0-rc01'
|
||||
def cameraX_version = '1.1.0-rc02'
|
||||
implementation "androidx.camera:camera-core:$cameraX_version"
|
||||
implementation "androidx.camera:camera-camera2:$cameraX_version"
|
||||
// CameraX Lifecycle library
|
||||
@ -142,8 +142,6 @@ dependencies {
|
||||
// CameraX View class
|
||||
implementation "androidx.camera:camera-view:$cameraX_version"
|
||||
|
||||
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
||||
|
||||
def room_version = "2.4.2"
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
kapt "androidx.room:room-compiler:$room_version"
|
||||
@ -155,6 +153,8 @@ dependencies {
|
||||
* ----------------------------------------------------------
|
||||
*/
|
||||
|
||||
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
||||
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1-1.LTS'
|
||||
|
||||
implementation 'com.google.android.material:material:1.6.1'
|
||||
|
||||
|
@ -526,12 +526,6 @@
|
||||
copyrightHolder: Google Inc.
|
||||
license: The Apache Software License, Version 2.0
|
||||
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
- artifact: androidx.lifecycle:lifecycle-extensions:+
|
||||
name: lifecycle-extensions
|
||||
copyrightHolder: Google Inc.
|
||||
license: The Apache Software License, Version 2.0
|
||||
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
url: https://developer.android.com/topic/libraries/architecture/index.html
|
||||
- artifact: androidx.lifecycle:lifecycle-process:+
|
||||
name: lifecycle-process
|
||||
copyrightHolder: Google Inc.
|
||||
@ -955,3 +949,27 @@
|
||||
license: The Apache Software License, Version 2.0
|
||||
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
url: https://developer.android.com/jetpack/androidx/releases/media2
|
||||
- artifact: com.davemorrissey.labs:subsampling-scale-image-view-androidx:+
|
||||
name: subsampling-scale-image-view-androidx
|
||||
copyrightHolder: David Morrissey and contributors
|
||||
license: The Apache Software License, Version 2.0
|
||||
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
url: https://github.com/davemorrissey/subsampling-scale-image-view
|
||||
- artifact: com.arthenica:ffmpeg-kit-min:+
|
||||
name: ffmpeg-kit-min
|
||||
copyrightHolder: Taner Şener
|
||||
license: GNU Lesser General Public License, Version 3
|
||||
licenseUrl: https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
url: https://github.com/tanersener/ffmpeg-kit
|
||||
- artifact: com.arthenica:smart-exception-java:+
|
||||
name: smart-exception-java
|
||||
copyrightHolder: Taner Şener
|
||||
license: The 3-Clause BSD License
|
||||
licenseUrl: https://opensource.org/licenses/BSD-3-Clause
|
||||
url: https://github.com/tanersener/smart-exception
|
||||
- artifact: com.arthenica:smart-exception-common:+
|
||||
name: smart-exception-common
|
||||
copyrightHolder: Taner Şener
|
||||
license: The 3-Clause BSD License
|
||||
licenseUrl: https://opensource.org/licenses/BSD-3-Clause
|
||||
url: https://github.com/tanersener/smart-exception
|
||||
|
@ -26,6 +26,10 @@
|
||||
android:name=".posts.AlbumActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/AppTheme.ActionBar.Transparent"/>
|
||||
<activity
|
||||
android:name=".postCreation.photoEdit.VideoEditActivity"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".posts.MediaViewerActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||
@ -40,9 +44,7 @@
|
||||
<activity
|
||||
android:name=".postCreation.PostCreationActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
tools:ignore="LockedOrientationActivity">
|
||||
android:theme="@style/AppTheme.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
||||
|
File diff suppressed because one or more lines are too long
@ -5,11 +5,8 @@ import android.app.AlertDialog
|
||||
import android.content.*
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.os.*
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.View.INVISIBLE
|
||||
@ -18,36 +15,38 @@ import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.HandlerCompat
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.arthenica.ffmpegkit.*
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.pixeldroid.app.MainActivity
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityPostCreationBinding
|
||||
import org.pixeldroid.app.postCreation.camera.CameraActivity
|
||||
import org.pixeldroid.app.postCreation.carousel.CarouselItem
|
||||
import org.pixeldroid.app.postCreation.carousel.ImageCarousel
|
||||
import org.pixeldroid.app.postCreation.photoEdit.PhotoEditActivity
|
||||
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.api.objects.Attachment
|
||||
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
|
||||
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.HttpException
|
||||
import org.pixeldroid.app.utils.ffmpegSafeUri
|
||||
import org.pixeldroid.app.utils.fileExtension
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val TAG = "Post Creation Activity"
|
||||
|
||||
const val TAG = "Post Creation Activity"
|
||||
|
||||
data class PhotoData(
|
||||
var imageUri: Uri,
|
||||
@ -56,6 +55,7 @@ data class PhotoData(
|
||||
var progress: Int? = null,
|
||||
var imageDescription: String? = null,
|
||||
var video: Boolean,
|
||||
var videoEncodeProgress: Int? = null,
|
||||
)
|
||||
|
||||
class PostCreationActivity : BaseActivity() {
|
||||
@ -63,10 +63,13 @@ class PostCreationActivity : BaseActivity() {
|
||||
private var user: UserDatabaseEntity? = null
|
||||
private lateinit var instance: InstanceDatabaseEntity
|
||||
|
||||
private val photoData: ArrayList<PhotoData> = ArrayList()
|
||||
|
||||
private lateinit var binding: ActivityPostCreationBinding
|
||||
|
||||
private val resultHandler: Handler = HandlerCompat.createAsync(Looper.getMainLooper())
|
||||
|
||||
private lateinit var model: PostCreationViewModel
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityPostCreationBinding.inflate(layoutInflater)
|
||||
@ -80,47 +83,87 @@ class PostCreationActivity : BaseActivity() {
|
||||
}
|
||||
} ?: InstanceDatabaseEntity("", "")
|
||||
|
||||
binding.postTextInputLayout.counterMaxLength = instance.maxStatusChars
|
||||
val _model: PostCreationViewModel by viewModels { PostCreationViewModelFactory(application, intent.clipData!!, instance) }
|
||||
model = _model
|
||||
|
||||
// get image URIs
|
||||
intent.clipData?.let { addPossibleImages(it) }
|
||||
model.getPhotoData().observe(this) { newPhotoData ->
|
||||
// update UI
|
||||
binding.carousel.addData(
|
||||
newPhotoData.map {
|
||||
CarouselItem(it.imageUri, it.imageDescription, it.video, it.videoEncodeProgress)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val carousel: ImageCarousel = binding.carousel
|
||||
carousel.addData(photoData.map { CarouselItem(it.imageUri, video = it.video) })
|
||||
carousel.layoutCarouselCallback = {
|
||||
if(it){
|
||||
// Became a carousel
|
||||
binding.toolbarPostCreation.visibility = VISIBLE
|
||||
} else {
|
||||
// Became a grid
|
||||
binding.toolbarPostCreation.visibility = INVISIBLE
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
model.uiState.collect { uiState ->
|
||||
uiState.userMessage?.let {
|
||||
AlertDialog.Builder(binding.root.context).apply {
|
||||
setMessage(it)
|
||||
setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
}.show()
|
||||
|
||||
// Notify the ViewModel the message is displayed
|
||||
model.userMessageShown()
|
||||
}
|
||||
binding.addPhotoButton.isEnabled = uiState.addPhotoButtonEnabled
|
||||
enableButton(uiState.postCreationSendButtonEnabled)
|
||||
binding.uploadProgressBar.visibility = if(uiState.uploadProgressBarVisible) VISIBLE else INVISIBLE
|
||||
binding.uploadProgressBar.progress = uiState.uploadProgress
|
||||
binding.uploadCompletedTextview.visibility = if(uiState.uploadCompletedTextviewVisible) VISIBLE else INVISIBLE
|
||||
binding.removePhotoButton.isEnabled = uiState.removePhotoButtonEnabled
|
||||
binding.editPhotoButton.isEnabled = uiState.editPhotoButtonEnabled
|
||||
binding.uploadError.visibility = if(uiState.uploadErrorVisible) VISIBLE else INVISIBLE
|
||||
binding.uploadErrorTextExplanation.visibility = if(uiState.uploadErrorExplanationVisible) VISIBLE else INVISIBLE
|
||||
|
||||
binding.toolbarPostCreation.visibility = if(uiState.isCarousel) VISIBLE else INVISIBLE
|
||||
binding.carousel.layoutCarousel = uiState.isCarousel
|
||||
|
||||
|
||||
binding.uploadErrorTextExplanation.text = uiState.uploadErrorExplanationText
|
||||
|
||||
uiState.newEncodingJobPosition?.let { position ->
|
||||
uiState.newEncodingJobMuted?.let { muted ->
|
||||
uiState.newEncodingJobVideoStart?.let { videoStart ->
|
||||
uiState.newEncodingJobVideoEnd?.let { videoEnd ->
|
||||
startEncoding(position, muted, videoStart, videoEnd)
|
||||
model.encodingStarted()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
carousel.maxEntries = instance.albumLimit
|
||||
carousel.addPhotoButtonCallback = {
|
||||
addPhoto()
|
||||
}
|
||||
carousel.updateDescriptionCallback = { position: Int, description: String ->
|
||||
photoData.getOrNull(position)?.imageDescription = description
|
||||
binding.newPostDescriptionInputField.doAfterTextChanged {
|
||||
model.newPostDescriptionChanged(binding.newPostDescriptionInputField.text)
|
||||
}
|
||||
binding.postTextInputLayout.counterMaxLength = instance.maxStatusChars
|
||||
|
||||
binding.carousel.apply {
|
||||
layoutCarouselCallback = { model.becameCarousel(it)}
|
||||
maxEntries = instance.albumLimit
|
||||
addPhotoButtonCallback = {
|
||||
addPhoto()
|
||||
}
|
||||
updateDescriptionCallback = { position: Int, description: String ->
|
||||
model.updateDescription(position, description)
|
||||
}
|
||||
}
|
||||
// get the description and send the post
|
||||
binding.postCreationSendButton.setOnClickListener {
|
||||
if (validateDescription() && photoData.isNotEmpty()) upload()
|
||||
if (validatePost() && model.isNotEmpty()) model.upload()
|
||||
}
|
||||
|
||||
// Button to retry image upload when it fails
|
||||
binding.retryUploadButton.setOnClickListener {
|
||||
binding.uploadError.visibility = View.GONE
|
||||
photoData.forEach {
|
||||
it.uploadId = null
|
||||
it.progress = null
|
||||
}
|
||||
upload()
|
||||
model.resetUploadStatus()
|
||||
model.upload()
|
||||
}
|
||||
|
||||
binding.editPhotoButton.setOnClickListener {
|
||||
carousel.currentPosition.takeIf { it != -1 }?.let { currentPosition ->
|
||||
binding.carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition ->
|
||||
edit(currentPosition)
|
||||
}
|
||||
}
|
||||
@ -130,95 +173,25 @@ class PostCreationActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
binding.savePhotoButton.setOnClickListener {
|
||||
carousel.currentPosition.takeIf { it != -1 }?.let { currentPosition ->
|
||||
binding.carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition ->
|
||||
savePicture(it, currentPosition)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
binding.removePhotoButton.setOnClickListener {
|
||||
carousel.currentPosition.takeIf { it != -1 }?.let { currentPosition ->
|
||||
photoData.removeAt(currentPosition)
|
||||
carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription, it.video) })
|
||||
binding.addPhotoButton.isEnabled = true
|
||||
binding.carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition ->
|
||||
model.removeAt(currentPosition)
|
||||
model.cancelEncode(currentPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Will add as many images as possible to [photoData], from the [clipData], and if
|
||||
* ([photoData].size + [clipData].itemCount) > [albumLimit] then it will only add as many images
|
||||
* as are legal (if any) and a dialog will be shown to the user alerting them of this fact.
|
||||
*/
|
||||
private fun addPossibleImages(clipData: ClipData){
|
||||
var count = clipData.itemCount
|
||||
if(count + photoData.size > instance.albumLimit){
|
||||
AlertDialog.Builder(this).apply {
|
||||
setMessage(getString(R.string.total_exceeds_album_limit).format(instance.albumLimit))
|
||||
setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
}.show()
|
||||
count = count.coerceAtMost(instance.albumLimit - photoData.size)
|
||||
}
|
||||
if (count + photoData.size >= instance.albumLimit) {
|
||||
// Disable buttons to add more images
|
||||
binding.addPhotoButton.isEnabled = false
|
||||
}
|
||||
for (i in 0 until count) {
|
||||
clipData.getItemAt(i).uri.let {
|
||||
val sizeAndVideoPair: Pair<Long, Boolean> = it.getSizeAndVideoValidate()
|
||||
photoData.add(PhotoData(imageUri = it, size = sizeAndVideoPair.first, video = sizeAndVideoPair.second))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the size of the file of the Uri, and whether it is a video,
|
||||
* and opens a dialog in case it is too big or in case the file is unsupported.
|
||||
*/
|
||||
private fun Uri.getSizeAndVideoValidate(): Pair<Long, Boolean> {
|
||||
val size: Long =
|
||||
if (toString().startsWith("content")) {
|
||||
contentResolver.query(this, null, null, null, null)
|
||||
?.use { cursor ->
|
||||
/* Get the column indexes of the data in the Cursor,
|
||||
* move to the first row in the Cursor, get the data,
|
||||
* and display it.
|
||||
*/
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
cursor.moveToFirst()
|
||||
cursor.getLong(sizeIndex)
|
||||
} ?: 0
|
||||
} else {
|
||||
toFile().length()
|
||||
}
|
||||
|
||||
val sizeInkBytes = ceil(size.toDouble() / 1000).toLong()
|
||||
val type = contentResolver.getType(this)
|
||||
val isVideo = type?.startsWith("video/") == true
|
||||
|
||||
if(isVideo && !instance.videoEnabled){
|
||||
AlertDialog.Builder(this@PostCreationActivity).apply {
|
||||
setMessage(R.string.video_not_supported)
|
||||
setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
}.show()
|
||||
}
|
||||
|
||||
if (sizeInkBytes > instance.maxPhotoSize || sizeInkBytes > instance.maxVideoSize) {
|
||||
val maxSize = if (isVideo) instance.maxVideoSize else instance.maxPhotoSize
|
||||
AlertDialog.Builder(this@PostCreationActivity).apply {
|
||||
setMessage(getString(R.string.size_exceeds_instance_limit, photoData.size + 1, sizeInkBytes, maxSize))
|
||||
setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
}.show()
|
||||
}
|
||||
return Pair(size, isVideo)
|
||||
}
|
||||
|
||||
private val addPhotoResultContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK && result.data?.clipData != null) {
|
||||
result.data?.clipData?.let {
|
||||
addPossibleImages(it)
|
||||
model.setImages(model.addPossibleImages(it))
|
||||
}
|
||||
binding.carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription, it.video) })
|
||||
} else if (result.resultCode != Activity.RESULT_CANCELED) {
|
||||
Toast.makeText(applicationContext, "Error while adding images", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@ -231,13 +204,13 @@ class PostCreationActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
private fun savePicture(button: View, currentPosition: Int) {
|
||||
val name = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US)
|
||||
.format(System.currentTimeMillis()) + ".png"
|
||||
val pair = getOutputFile(name)
|
||||
val originalUri = model.getPhotoData().value!![currentPosition].imageUri
|
||||
|
||||
val pair = getOutputFile(originalUri)
|
||||
val outputStream: OutputStream = pair.first
|
||||
val path: String = pair.second
|
||||
|
||||
contentResolver.openInputStream(photoData[currentPosition].imageUri)!!.use { input ->
|
||||
contentResolver.openInputStream(originalUri)!!.use { input ->
|
||||
outputStream.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
@ -263,20 +236,28 @@ class PostCreationActivity : BaseActivity() {
|
||||
).show()
|
||||
}
|
||||
|
||||
private fun getOutputFile(name: String): Pair<OutputStream, String> {
|
||||
private fun getOutputFile(uri: Uri): Pair<OutputStream, String> {
|
||||
val extension = uri.fileExtension(contentResolver)
|
||||
|
||||
val name = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US)
|
||||
.format(System.currentTimeMillis()) + ".$extension"
|
||||
|
||||
val outputStream: OutputStream
|
||||
val path: String
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val resolver: ContentResolver = contentResolver
|
||||
val type = resolver.getType(uri)
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
|
||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/png")
|
||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, type)
|
||||
contentValues.put(
|
||||
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||
Environment.DIRECTORY_PICTURES
|
||||
)
|
||||
val imageUri: Uri =
|
||||
resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)!!
|
||||
val imageUri: Uri = resolver.insert(
|
||||
if (type?.startsWith("image") == true) MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
else MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
|
||||
contentValues)!!
|
||||
path = imageUri.toString()
|
||||
outputStream = resolver.openOutputStream(Objects.requireNonNull(imageUri))!!
|
||||
} else {
|
||||
@ -291,7 +272,7 @@ class PostCreationActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
|
||||
private fun validateDescription(): Boolean {
|
||||
private fun validatePost(): Boolean {
|
||||
binding.postTextInputLayout.run {
|
||||
val content = editText?.length() ?: 0
|
||||
if (content > counterMaxLength) {
|
||||
@ -300,121 +281,16 @@ class PostCreationActivity : BaseActivity() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if(model.getPhotoData().value?.all { it.videoEncodeProgress == null } == false){
|
||||
AlertDialog.Builder(this).apply {
|
||||
setMessage(R.string.still_encoding)
|
||||
setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
}.show()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the images that are in the [photoData] array.
|
||||
* Keeps track of them in the [PhotoData.progress] (for the upload progress), and the
|
||||
* [PhotoData.uploadId] (for the list of ids of the uploads).
|
||||
*/
|
||||
private fun upload() {
|
||||
enableButton(false)
|
||||
binding.uploadProgressBar.visibility = VISIBLE
|
||||
binding.uploadCompletedTextview.visibility = INVISIBLE
|
||||
binding.removePhotoButton.isEnabled = false
|
||||
binding.editPhotoButton.isEnabled = false
|
||||
binding.addPhotoButton.isEnabled = false
|
||||
|
||||
for (data: PhotoData in photoData) {
|
||||
val imageUri = data.imageUri
|
||||
val imageInputStream = try {
|
||||
contentResolver.openInputStream(imageUri)!!
|
||||
} catch (e: FileNotFoundException){
|
||||
AlertDialog.Builder(this).apply {
|
||||
setMessage(getString(R.string.file_not_found).format(imageUri))
|
||||
|
||||
setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
}.show()
|
||||
return
|
||||
}
|
||||
|
||||
val imagePart = ProgressRequestBody(imageInputStream, data.size)
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("file", System.currentTimeMillis().toString(), imagePart)
|
||||
.build()
|
||||
|
||||
val sub = imagePart.progressSubject
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe { percentage ->
|
||||
data.progress = percentage.toInt()
|
||||
binding.uploadProgressBar.progress =
|
||||
photoData.sumOf { it.progress ?: 0 } / photoData.size
|
||||
}
|
||||
|
||||
var postSub: Disposable? = null
|
||||
|
||||
val description = data.imageDescription?.let { MultipartBody.Part.createFormData("description", it) }
|
||||
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
val inter = api.mediaUpload(description, requestBody.parts[0])
|
||||
|
||||
postSub = inter
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ attachment: Attachment ->
|
||||
data.progress = 0
|
||||
data.uploadId = attachment.id!!
|
||||
},
|
||||
{ e: Throwable ->
|
||||
binding.uploadError.visibility = View.VISIBLE
|
||||
if(e is HttpException){
|
||||
binding.uploadErrorTextExplanation.text =
|
||||
getString(R.string.upload_error, e.code())
|
||||
binding.uploadErrorTextExplanation.visibility= VISIBLE
|
||||
} else {
|
||||
binding.uploadErrorTextExplanation.visibility= View.GONE
|
||||
}
|
||||
e.printStackTrace()
|
||||
postSub?.dispose()
|
||||
sub.dispose()
|
||||
},
|
||||
{
|
||||
data.progress = 100
|
||||
if (photoData.all { it.progress == 100 && it.uploadId != null }) {
|
||||
binding.uploadProgressBar.visibility = View.GONE
|
||||
binding.uploadCompletedTextview.visibility = View.VISIBLE
|
||||
post()
|
||||
}
|
||||
postSub?.dispose()
|
||||
sub.dispose()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun post() {
|
||||
val description = binding.newPostDescriptionInputField.text.toString()
|
||||
enableButton(false)
|
||||
lifecycleScope.launchWhenCreated {
|
||||
try {
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
|
||||
api.postStatus(
|
||||
statusText = description,
|
||||
media_ids = photoData.mapNotNull { it.uploadId }.toList()
|
||||
)
|
||||
Toast.makeText(applicationContext, getString(R.string.upload_post_success),
|
||||
Toast.LENGTH_SHORT).show()
|
||||
val intent = Intent(this@PostCreationActivity, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
startActivity(intent)
|
||||
} catch (exception: IOException) {
|
||||
Toast.makeText(applicationContext, getString(R.string.upload_post_error),
|
||||
Toast.LENGTH_SHORT).show()
|
||||
Log.e(TAG, exception.toString())
|
||||
enableButton(true)
|
||||
} catch (exception: HttpException) {
|
||||
Toast.makeText(applicationContext, getString(R.string.upload_post_failed),
|
||||
Toast.LENGTH_SHORT).show()
|
||||
Log.e(TAG, exception.response().toString() + exception.message().toString())
|
||||
enableButton(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableButton(enable: Boolean = true){
|
||||
binding.postCreationSendButton.isEnabled = enable
|
||||
if(enable){
|
||||
@ -431,32 +307,108 @@ class PostCreationActivity : BaseActivity() {
|
||||
result: ActivityResult? ->
|
||||
if (result?.resultCode == Activity.RESULT_OK && result.data != null) {
|
||||
val position: Int = result.data!!.getIntExtra(PhotoEditActivity.PICTURE_POSITION, 0)
|
||||
photoData.getOrNull(position)?.apply {
|
||||
imageUri = result.data!!.getStringExtra(PhotoEditActivity.PICTURE_URI)!!.toUri()
|
||||
val (imageSize, imageVideo) = imageUri.getSizeAndVideoValidate()
|
||||
size = imageSize
|
||||
video = imageVideo
|
||||
progress = null
|
||||
uploadId = null
|
||||
} ?: Toast.makeText(applicationContext, "Error while editing", Toast.LENGTH_SHORT).show()
|
||||
|
||||
binding.carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription, it.video) })
|
||||
model.modifyAt(position, result.data!!)
|
||||
?: Toast.makeText(applicationContext, "Error while editing", Toast.LENGTH_SHORT).show()
|
||||
} else if(result?.resultCode != Activity.RESULT_CANCELED){
|
||||
Toast.makeText(applicationContext, "Error while editing", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun edit(position: Int) {
|
||||
if(photoData[position].video){
|
||||
AlertDialog.Builder(this).apply {
|
||||
setMessage(R.string.video_edit_not_yet_supported)
|
||||
setNegativeButton(android.R.string.ok) { _, _ -> }
|
||||
}.show()
|
||||
} else {
|
||||
val intent = Intent(this, PhotoEditActivity::class.java)
|
||||
.putExtra(PhotoEditActivity.PICTURE_URI, photoData[position].imageUri)
|
||||
.putExtra(PhotoEditActivity.PICTURE_POSITION, position)
|
||||
editResultContract.launch(intent)
|
||||
/**
|
||||
* @param muted should audio tracks be removed in the output
|
||||
* @param videoStart when we want to start the video, in seconds, or null if we
|
||||
* don't want to remove the start
|
||||
* @param videoEnd when we want to end the video, in seconds, or null if we
|
||||
* don't want to remove the end
|
||||
*/
|
||||
private fun startEncoding(position: Int, muted: Boolean, videoStart: Float?, videoEnd: Float?) {
|
||||
val originalUri = model.getPhotoData().value!![position].imageUri
|
||||
|
||||
// Having a meaningful suffix is necessary so that ffmpeg knows what to put in output
|
||||
val suffix = originalUri.fileExtension(contentResolver)
|
||||
val file = File.createTempFile("temp_video", ".$suffix")
|
||||
//val file = File.createTempFile("temp_video", ".webm")
|
||||
model.trackTempFile(file)
|
||||
val fileUri = file.toUri()
|
||||
val outputVideoPath = ffmpegSafeUri(fileUri)
|
||||
|
||||
val inputUri = model.getPhotoData().value!![position].imageUri
|
||||
|
||||
val inputSafePath = ffmpegSafeUri(inputUri)
|
||||
|
||||
val mediaInformation: MediaInformation? = FFprobeKit.getMediaInformation(ffmpegSafeUri(inputUri)).mediaInformation
|
||||
val totalVideoDuration = mediaInformation?.duration?.toFloatOrNull()
|
||||
|
||||
val mutedString = if(muted) "-an" else ""
|
||||
val startString = if(videoStart != null) "-ss $videoStart" else ""
|
||||
|
||||
val endString = if(videoEnd != null) "-to ${videoEnd - (videoStart ?: 0f)}" else ""
|
||||
|
||||
val session: FFmpegSession = FFmpegKit.executeAsync("$startString -i $inputSafePath $endString -c copy $mutedString -y $outputVideoPath",
|
||||
//val session: FFmpegSession = FFmpegKit.executeAsync("$startString -i $inputSafePath $endString -c:v libvpx-vp9 -c:a copy -an -y $outputVideoPath",
|
||||
{ session ->
|
||||
val returnCode = session.returnCode
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
fun successResult() {
|
||||
// Hide progress indicator in carousel
|
||||
binding.carousel.updateProgress(null, position, false)
|
||||
val (imageSize, _) = outputVideoPath.toUri().let {
|
||||
model.setUriAtPosition(it, position)
|
||||
model.getSizeAndVideoValidate(it, position)
|
||||
}
|
||||
model.setVideoEncodeAtPosition(position, null)
|
||||
model.setSizeAtPosition(imageSize, position)
|
||||
}
|
||||
|
||||
val post = resultHandler.post {
|
||||
successResult()
|
||||
}
|
||||
if(!post) {
|
||||
Log.e(TAG, "Failed to post changes, trying to recover in 100ms")
|
||||
resultHandler.postDelayed({successResult()}, 100)
|
||||
}
|
||||
Log.d(TAG, "Encode completed successfully in ${session.duration} milliseconds")
|
||||
} else {
|
||||
resultHandler.post {
|
||||
binding.carousel.updateProgress(null, position, error = true)
|
||||
model.setVideoEncodeAtPosition(position, null)
|
||||
}
|
||||
Log.e(TAG, "Encode failed with state ${session.state} and rc $returnCode.${session.failStackTrace}")
|
||||
}
|
||||
},
|
||||
{ log -> Log.d("PostCreationActivityEncoding", log.message) }
|
||||
) { statistics: Statistics? ->
|
||||
|
||||
val timeInMilliseconds: Int? = statistics?.time
|
||||
timeInMilliseconds?.let {
|
||||
if (timeInMilliseconds > 0) {
|
||||
val completePercentage = totalVideoDuration?.let {
|
||||
val newTotalDuration = it - (videoStart ?: 0f) - (it - (videoEnd ?: it))
|
||||
timeInMilliseconds / (10*newTotalDuration)
|
||||
}
|
||||
resultHandler.post {
|
||||
completePercentage?.let {
|
||||
val rounded: Int = it.roundToInt()
|
||||
model.setVideoEncodeAtPosition(position, rounded)
|
||||
binding.carousel.updateProgress(rounded, position, false)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Encoding video: %$completePercentage.")
|
||||
}
|
||||
}
|
||||
}
|
||||
model.registerNewFFmpegSession(position, session.sessionId)
|
||||
}
|
||||
|
||||
private fun edit(position: Int) {
|
||||
val intent = Intent(
|
||||
this,
|
||||
if(model.getPhotoData().value!![position].video) VideoEditActivity::class.java else PhotoEditActivity::class.java
|
||||
)
|
||||
.putExtra(PhotoEditActivity.PICTURE_URI, model.getPhotoData().value!![position].imageUri)
|
||||
.putExtra(PhotoEditActivity.PICTURE_POSITION, position)
|
||||
|
||||
editResultContract.launch(intent)
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,436 @@
|
||||
package org.pixeldroid.app.postCreation
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.text.Editable
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.*
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MultipartBody
|
||||
import org.pixeldroid.app.MainActivity
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.postCreation.photoEdit.PhotoEditActivity
|
||||
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity
|
||||
import org.pixeldroid.app.utils.PixelDroidApplication
|
||||
import org.pixeldroid.app.utils.api.objects.Attachment
|
||||
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
|
||||
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
|
||||
import retrofit2.HttpException
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.ceil
|
||||
|
||||
// Models the UI state for the PostCreationActivity
|
||||
data class PostCreationActivityUiState(
|
||||
val userMessage: String? = null,
|
||||
|
||||
val addPhotoButtonEnabled: Boolean = true,
|
||||
val editPhotoButtonEnabled: Boolean = true,
|
||||
val removePhotoButtonEnabled: Boolean = true,
|
||||
val postCreationSendButtonEnabled: Boolean = true,
|
||||
|
||||
val isCarousel: Boolean = true,
|
||||
|
||||
val newPostDescriptionText: String = "",
|
||||
|
||||
val uploadProgressBarVisible: Boolean = false,
|
||||
val uploadProgress: Int = 0,
|
||||
val uploadCompletedTextviewVisible: Boolean = false,
|
||||
val uploadErrorVisible: Boolean = false,
|
||||
val uploadErrorExplanationText: String = "",
|
||||
val uploadErrorExplanationVisible: Boolean = false,
|
||||
|
||||
val newEncodingJobPosition: Int? = null,
|
||||
val newEncodingJobMuted: Boolean? = null,
|
||||
val newEncodingJobVideoStart: Float? = null,
|
||||
val newEncodingJobVideoEnd: Float? = null,
|
||||
)
|
||||
|
||||
class PostCreationViewModel(application: Application, clipdata: ClipData? = null, val instance: InstanceDatabaseEntity? = null) : AndroidViewModel(application) {
|
||||
private val photoData: MutableLiveData<MutableList<PhotoData>> by lazy {
|
||||
MutableLiveData<MutableList<PhotoData>>().also {
|
||||
it.value = clipdata?.let { it1 -> addPossibleImages(it1, mutableListOf()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var apiHolder: PixelfedAPIHolder
|
||||
|
||||
init {
|
||||
(application as PixelDroidApplication).getAppComponent().inject(this)
|
||||
}
|
||||
|
||||
// Map photoData indexes to FFmpeg Session IDs
|
||||
private val sessionMap: MutableMap<Int, Long> = mutableMapOf()
|
||||
// Keep track of temporary files to delete them (avoids filling cache super fast with videos)
|
||||
private val tempFiles: java.util.ArrayList<File> = java.util.ArrayList()
|
||||
|
||||
|
||||
private val _uiState = MutableStateFlow(PostCreationActivityUiState())
|
||||
val uiState: StateFlow<PostCreationActivityUiState> = _uiState
|
||||
|
||||
fun userMessageShown() {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(userMessage = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun encodingStarted() {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
newEncodingJobPosition = null,
|
||||
newEncodingJobMuted = null,
|
||||
newEncodingJobVideoStart = null,
|
||||
newEncodingJobVideoEnd = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPhotoData(): LiveData<MutableList<PhotoData>> = photoData
|
||||
|
||||
/**
|
||||
* Will add as many images as possible to [photoData], from the [clipData], and if
|
||||
* ([photoData].size + [clipData].itemCount) > [InstanceDatabaseEntity.albumLimit] then it will only add as many images
|
||||
* as are legal (if any) and a dialog will be shown to the user alerting them of this fact.
|
||||
*/
|
||||
fun addPossibleImages(clipData: ClipData, previousList: MutableList<PhotoData>? = photoData.value): MutableList<PhotoData> {
|
||||
val dataToAdd: ArrayList<PhotoData> = arrayListOf()
|
||||
var count = clipData.itemCount
|
||||
if(count + (previousList?.size ?: 0) > instance!!.albumLimit){
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(userMessage = getApplication<PixelDroidApplication>().getString(R.string.total_exceeds_album_limit).format(instance.albumLimit))
|
||||
}
|
||||
count = count.coerceAtMost(instance.albumLimit - (previousList?.size ?: 0))
|
||||
}
|
||||
if (count + (previousList?.size ?: 0) >= instance.albumLimit) {
|
||||
// Disable buttons to add more images
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(addPhotoButtonEnabled = false)
|
||||
}
|
||||
}
|
||||
for (i in 0 until count) {
|
||||
clipData.getItemAt(i).uri.let {
|
||||
val sizeAndVideoPair: Pair<Long, Boolean> =
|
||||
getSizeAndVideoValidate(it, (previousList?.size ?: 0) + dataToAdd.size + 1)
|
||||
dataToAdd.add(PhotoData(imageUri = it, size = sizeAndVideoPair.first, video = sizeAndVideoPair.second))
|
||||
}
|
||||
}
|
||||
return previousList?.plus(dataToAdd)?.toMutableList() ?: mutableListOf()
|
||||
}
|
||||
|
||||
fun setImages(addPossibleImages: MutableList<PhotoData>) {
|
||||
photoData.value = addPossibleImages
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the size of the file of the Uri, and whether it is a video,
|
||||
* and opens a dialog in case it is too big or in case the file is unsupported.
|
||||
*/
|
||||
fun getSizeAndVideoValidate(uri: Uri, editPosition: Int): Pair<Long, Boolean> {
|
||||
val size: Long =
|
||||
if (uri.scheme =="content") {
|
||||
getApplication<PixelDroidApplication>().contentResolver.query(uri, null, null, null, null)
|
||||
?.use { cursor ->
|
||||
/* Get the column indexes of the data in the Cursor,
|
||||
* move to the first row in the Cursor, get the data,
|
||||
* and display it.
|
||||
*/
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
cursor.moveToFirst()
|
||||
cursor.getLong(sizeIndex)
|
||||
} ?: 0
|
||||
} else {
|
||||
uri.toFile().length()
|
||||
}
|
||||
|
||||
val sizeInkBytes = ceil(size.toDouble() / 1000).toLong()
|
||||
val type = getApplication<PixelDroidApplication>().contentResolver.getType(uri)
|
||||
val isVideo = type?.startsWith("video/") == true
|
||||
|
||||
if(isVideo && !instance!!.videoEnabled){
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(userMessage = getApplication<PixelDroidApplication>().getString(R.string.video_not_supported))
|
||||
}
|
||||
}
|
||||
|
||||
if (sizeInkBytes > instance!!.maxPhotoSize || sizeInkBytes > instance.maxVideoSize) {
|
||||
val maxSize = if (isVideo) instance.maxVideoSize else instance.maxPhotoSize
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
userMessage = getApplication<PixelDroidApplication>().getString(R.string.size_exceeds_instance_limit, editPosition, sizeInkBytes, maxSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
return Pair(size, isVideo)
|
||||
}
|
||||
|
||||
fun isNotEmpty(): Boolean = photoData.value?.isNotEmpty() ?: false
|
||||
|
||||
fun updateDescription(position: Int, description: String) {
|
||||
photoData.value?.getOrNull(position)?.imageDescription = description
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
|
||||
fun resetUploadStatus() {
|
||||
photoData.value = photoData.value?.map { it.copy(uploadId = null, progress = null) }?.toMutableList()
|
||||
}
|
||||
|
||||
fun setVideoEncodeAtPosition(position: Int, progress: Int?) {
|
||||
photoData.value?.set(position, photoData.value!![position].copy(videoEncodeProgress = progress))
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
|
||||
fun setUriAtPosition(uri: Uri, position: Int) {
|
||||
photoData.value?.set(position, photoData.value!![position].copy(imageUri = uri))
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
|
||||
fun setSizeAtPosition(imageSize: Long, position: Int) {
|
||||
photoData.value?.set(position, photoData.value!![position].copy(size = imageSize))
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
|
||||
fun removeAt(currentPosition: Int) {
|
||||
photoData.value?.removeAt(currentPosition)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
addPhotoButtonEnabled = true
|
||||
)
|
||||
}
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the images that are in the [photoData] array.
|
||||
* Keeps track of them in the [PhotoData.progress] (for the upload progress), and the
|
||||
* [PhotoData.uploadId] (for the list of ids of the uploads).
|
||||
*/
|
||||
fun upload() {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
postCreationSendButtonEnabled = false,
|
||||
addPhotoButtonEnabled = false,
|
||||
editPhotoButtonEnabled = false,
|
||||
removePhotoButtonEnabled = false,
|
||||
uploadCompletedTextviewVisible = false,
|
||||
uploadErrorVisible = false,
|
||||
uploadProgressBarVisible = true
|
||||
)
|
||||
}
|
||||
|
||||
for (data: PhotoData in getPhotoData().value ?: emptyList()) {
|
||||
val imageUri = data.imageUri
|
||||
val imageInputStream = try {
|
||||
getApplication<PixelDroidApplication>().contentResolver.openInputStream(imageUri)!!
|
||||
} catch (e: FileNotFoundException){
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
userMessage = getApplication<PixelDroidApplication>().getString(R.string.file_not_found,
|
||||
imageUri)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val imagePart = ProgressRequestBody(imageInputStream, data.size)
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("file", System.currentTimeMillis().toString(), imagePart)
|
||||
.build()
|
||||
|
||||
val sub = imagePart.progressSubject
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe { percentage ->
|
||||
data.progress = percentage.toInt()
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
uploadProgress = getPhotoData().value!!.sumOf { it.progress ?: 0 } / getPhotoData().value!!.size
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var postSub: Disposable? = null
|
||||
|
||||
val description = data.imageDescription?.let { MultipartBody.Part.createFormData("description", it) }
|
||||
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
val inter = api.mediaUpload(description, requestBody.parts[0])
|
||||
|
||||
postSub = inter
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ attachment: Attachment ->
|
||||
data.progress = 0
|
||||
data.uploadId = attachment.id!!
|
||||
},
|
||||
{ e: Throwable ->
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
uploadErrorVisible = true,
|
||||
uploadErrorExplanationText = if(e is HttpException){
|
||||
getApplication<PixelDroidApplication>().getString(R.string.upload_error, e.code())
|
||||
} else "",
|
||||
uploadErrorExplanationVisible = e is HttpException,
|
||||
)
|
||||
}
|
||||
e.printStackTrace()
|
||||
postSub?.dispose()
|
||||
sub.dispose()
|
||||
},
|
||||
{
|
||||
data.progress = 100
|
||||
if (getPhotoData().value!!.all { it.progress == 100 && it.uploadId != null }) {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
uploadProgressBarVisible = false,
|
||||
uploadCompletedTextviewVisible = true
|
||||
)
|
||||
}
|
||||
post()
|
||||
}
|
||||
postSub?.dispose()
|
||||
sub.dispose()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun post() {
|
||||
val description = uiState.value.newPostDescriptionText
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
postCreationSendButtonEnabled = false
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
|
||||
|
||||
api.postStatus(
|
||||
statusText = description,
|
||||
media_ids = getPhotoData().value!!.mapNotNull { it.uploadId }.toList()
|
||||
)
|
||||
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_success),
|
||||
Toast.LENGTH_SHORT).show()
|
||||
val intent = Intent(getApplication(), MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
//TODO make the activity launch this instead (and surrounding toasts too)L
|
||||
getApplication<PixelDroidApplication>().startActivity(intent)
|
||||
} catch (exception: IOException) {
|
||||
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_error),
|
||||
Toast.LENGTH_SHORT).show()
|
||||
Log.e(TAG, exception.toString())
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
postCreationSendButtonEnabled = true
|
||||
)
|
||||
}
|
||||
} catch (exception: HttpException) {
|
||||
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_failed),
|
||||
Toast.LENGTH_SHORT).show()
|
||||
Log.e(TAG, exception.response().toString() + exception.message().toString())
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
postCreationSendButtonEnabled = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun modifyAt(position: Int, data: Intent): Unit? {
|
||||
val result: PhotoData = photoData.value?.get(position)?.run {
|
||||
if (video) {
|
||||
val muted: Boolean = data.getBooleanExtra(VideoEditActivity.MUTED, false)
|
||||
val videoStart: Float? = data.getFloatExtra(VideoEditActivity.VIDEO_START, -1f).let {
|
||||
if(it == -1f) null else it
|
||||
}
|
||||
val modified: Boolean = data.getBooleanExtra(VideoEditActivity.MODIFIED, false)
|
||||
val videoEnd: Float? = data.getFloatExtra(VideoEditActivity.VIDEO_END, -1f).let {
|
||||
if(it == -1f) null else it
|
||||
}
|
||||
if(modified){
|
||||
videoEncodeProgress = 0
|
||||
sessionMap[position]?.let { FFmpegKit.cancel(it) }
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
newEncodingJobPosition = position,
|
||||
newEncodingJobMuted = muted,
|
||||
newEncodingJobVideoStart = videoStart,
|
||||
newEncodingJobVideoEnd = videoEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
imageUri = data.getStringExtra(PhotoEditActivity.PICTURE_URI)!!.toUri()
|
||||
val (imageSize, imageVideo) = getSizeAndVideoValidate(imageUri, position)
|
||||
size = imageSize
|
||||
video = imageVideo
|
||||
}
|
||||
progress = null
|
||||
uploadId = null
|
||||
this
|
||||
} ?: return null
|
||||
result.let {
|
||||
photoData.value?.set(position, it)
|
||||
photoData.value = photoData.value
|
||||
}
|
||||
return Unit
|
||||
}
|
||||
|
||||
fun newPostDescriptionChanged(text: Editable?) {
|
||||
_uiState.update { it.copy(newPostDescriptionText = text.toString()) }
|
||||
}
|
||||
|
||||
fun trackTempFile(file: File) {
|
||||
tempFiles.add(file)
|
||||
}
|
||||
|
||||
fun cancelEncode(currentPosition: Int) {
|
||||
sessionMap[currentPosition]?.let { FFmpegKit.cancel(it) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
FFmpegKit.cancel()
|
||||
tempFiles.forEach {
|
||||
it.delete()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun registerNewFFmpegSession(position: Int, sessionId: Long) {
|
||||
sessionMap[position] = sessionId
|
||||
}
|
||||
|
||||
fun becameCarousel(became: Boolean) {
|
||||
_uiState.update { currentUiState ->
|
||||
currentUiState.copy(
|
||||
isCarousel = became
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class PostCreationViewModelFactory(val application: Application, val clipdata: ClipData, val instance: InstanceDatabaseEntity) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.getConstructor(Application::class.java, ClipData::class.java, InstanceDatabaseEntity::class.java).newInstance(application, clipdata, instance)
|
||||
}
|
||||
}
|
@ -5,5 +5,6 @@ import android.net.Uri
|
||||
data class CarouselItem constructor(
|
||||
val imageUrl: Uri,
|
||||
val caption: String? = null,
|
||||
val video: Boolean
|
||||
val video: Boolean,
|
||||
var encodeProgress: Int?
|
||||
)
|
@ -59,17 +59,9 @@ class ImageCarousel(
|
||||
|
||||
initIndicator()
|
||||
}
|
||||
|
||||
|
||||
private var btnPrevious: View? = null
|
||||
private var btnNext: View? = null
|
||||
|
||||
private var btnGrid: View? = null
|
||||
private var btnCarousel: View? = null
|
||||
|
||||
|
||||
|
||||
private var isBuiltInIndicator = false
|
||||
private var data: List<CarouselItem>? = null
|
||||
private var data: MutableList<CarouselItem>? = null
|
||||
|
||||
var onItemClickListener: OnItemClickListener? = this
|
||||
set(value) {
|
||||
@ -88,28 +80,34 @@ class ImageCarousel(
|
||||
/**
|
||||
* Get or set current item position
|
||||
*/
|
||||
var currentPosition = -1
|
||||
var currentPosition = RecyclerView.NO_POSITION
|
||||
get() {
|
||||
return snapHelper.getSnapPosition(recyclerView.layoutManager)
|
||||
}
|
||||
set(value) {
|
||||
val position = when {
|
||||
value >= data?.size ?: 0 -> {
|
||||
-1
|
||||
}
|
||||
value < 0 -> {
|
||||
-1
|
||||
}
|
||||
else -> {
|
||||
value
|
||||
}
|
||||
val position = when (value) {
|
||||
!in 0..((data?.size?.minus(1)) ?: 0) -> RecyclerView.NO_POSITION
|
||||
else -> value
|
||||
}
|
||||
|
||||
field = position
|
||||
if (position != RecyclerView.NO_POSITION && field != position) {
|
||||
val thisProgress = data?.get(position)?.encodeProgress
|
||||
if (thisProgress != null) {
|
||||
binding.encodeProgress.visibility = VISIBLE
|
||||
binding.encodeInfoText.visibility = VISIBLE
|
||||
binding.encodeInfoText.text =
|
||||
context.getString(R.string.encode_progress).format(thisProgress)
|
||||
binding.encodeProgress.progress = thisProgress
|
||||
} else {
|
||||
binding.encodeProgress.visibility = INVISIBLE
|
||||
binding.encodeInfoText.visibility = INVISIBLE
|
||||
}
|
||||
} else binding.encodeProgress.visibility = INVISIBLE
|
||||
|
||||
if (position != -1) {
|
||||
if (position != RecyclerView.NO_POSITION && recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
recyclerView.smoothScrollToPosition(position)
|
||||
}
|
||||
field = position
|
||||
}
|
||||
|
||||
/**
|
||||
@ -225,27 +223,24 @@ class ImageCarousel(
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
btnGrid = binding.switchToGridButton
|
||||
btnCarousel = binding.switchToCarouselButton
|
||||
|
||||
btnGrid?.setOnClickListener {
|
||||
binding.switchToGridButton.setOnClickListener {
|
||||
layoutCarousel = false
|
||||
}
|
||||
btnCarousel?.setOnClickListener {
|
||||
binding.switchToCarouselButton.setOnClickListener {
|
||||
layoutCarousel = true
|
||||
}
|
||||
|
||||
if(value){
|
||||
if(layoutCarousel){
|
||||
btnGrid?.visibility = VISIBLE
|
||||
btnCarousel?.visibility = GONE
|
||||
binding.switchToGridButton.visibility = VISIBLE
|
||||
binding.switchToCarouselButton.visibility = GONE
|
||||
} else {
|
||||
btnGrid?.visibility = GONE
|
||||
btnCarousel?.visibility = VISIBLE
|
||||
binding.switchToGridButton.visibility = GONE
|
||||
binding.switchToCarouselButton.visibility = VISIBLE
|
||||
}
|
||||
} else {
|
||||
btnGrid?.visibility = GONE
|
||||
btnCarousel?.visibility = GONE
|
||||
binding.switchToGridButton.visibility = GONE
|
||||
binding.switchToCarouselButton.visibility = GONE
|
||||
}
|
||||
}
|
||||
|
||||
@ -261,15 +256,15 @@ class ImageCarousel(
|
||||
if(value){
|
||||
recyclerView.layoutManager = CarouselLinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
|
||||
|
||||
btnNext?.visibility = VISIBLE
|
||||
btnPrevious?.visibility = VISIBLE
|
||||
binding.btnNext.visibility = VISIBLE
|
||||
binding.btnPrevious.visibility = VISIBLE
|
||||
|
||||
binding.editMediaDescriptionLayout.visibility = if(editingMediaDescription) VISIBLE else INVISIBLE
|
||||
tvCaption.visibility = if(editingMediaDescription) INVISIBLE else VISIBLE
|
||||
} else {
|
||||
recyclerView.layoutManager = GridLayoutManager(context, 3)
|
||||
btnNext?.visibility = GONE
|
||||
btnPrevious?.visibility = GONE
|
||||
binding.btnNext.visibility = GONE
|
||||
binding.btnPrevious.visibility = GONE
|
||||
|
||||
binding.editMediaDescriptionLayout.visibility = INVISIBLE
|
||||
tvCaption.visibility = INVISIBLE
|
||||
@ -450,10 +445,9 @@ class ImageCarousel(
|
||||
private fun initListeners() {
|
||||
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
val position = currentPosition
|
||||
|
||||
if (showCaption) {
|
||||
val position = snapHelper.getSnapPosition(recyclerView.layoutManager)
|
||||
|
||||
if (position >= 0) {
|
||||
val dataItem = adapter?.getItem(position)
|
||||
|
||||
@ -469,6 +463,8 @@ class ImageCarousel(
|
||||
}
|
||||
}
|
||||
|
||||
if(dx !=0 || dy != 0) currentPosition = position
|
||||
|
||||
onScrollListener?.onScrolled(recyclerView, dx, dy)
|
||||
|
||||
}
|
||||
@ -561,12 +557,37 @@ class ImageCarousel(
|
||||
adapter?.apply {
|
||||
addAll(data)
|
||||
|
||||
this@ImageCarousel.data = data
|
||||
this@ImageCarousel.data = data.toMutableList()
|
||||
|
||||
initOnScrollStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProgress(progress: Int?, position: Int, error: Boolean){
|
||||
data?.get(position)?.encodeProgress = progress
|
||||
if(currentPosition == position) {
|
||||
if (progress == null) {
|
||||
binding.encodeProgress.visibility = INVISIBLE
|
||||
binding.encodeInfoText.visibility = VISIBLE
|
||||
if(error){
|
||||
binding.encodeInfoText.setText(R.string.encode_error)
|
||||
binding.encodeInfoText.setCompoundDrawablesWithIntrinsicBounds(ContextCompat.getDrawable(context, R.drawable.error),
|
||||
null, null, null)
|
||||
|
||||
} else {
|
||||
binding.encodeInfoText.setText(R.string.encode_success)
|
||||
binding.encodeInfoText.setCompoundDrawablesWithIntrinsicBounds(ContextCompat.getDrawable(context, R.drawable.check_circle_24),
|
||||
null, null, null)
|
||||
}
|
||||
} else {
|
||||
binding.encodeProgress.visibility = VISIBLE
|
||||
binding.encodeProgress.progress = progress
|
||||
binding.encodeInfoText.visibility = VISIBLE
|
||||
binding.encodeInfoText.text = context.getString(R.string.encode_progress).format(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Goto previous item.
|
||||
*/
|
||||
|
@ -148,7 +148,7 @@ class PhotoEditActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.edit_photo_menu, menu)
|
||||
menuInflater.inflate(R.menu.edit_menu, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
@ -191,8 +191,8 @@ class PhotoEditActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
fun onFilterSelected(filter: Filter) {
|
||||
filteredImage = compressedOriginalImage!!.copy(BITMAP_CONFIG, true)
|
||||
|
@ -0,0 +1,273 @@
|
||||
package org.pixeldroid.app.postCreation.photoEdit
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.format.DateUtils
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.HandlerCompat
|
||||
import androidx.media.AudioAttributesCompat
|
||||
import androidx.media2.common.MediaMetadata
|
||||
import androidx.media2.common.UriMediaItem
|
||||
import androidx.media2.player.MediaPlayer
|
||||
import com.arthenica.ffmpegkit.*
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.material.slider.RangeSlider
|
||||
import org.pixeldroid.app.R
|
||||
import org.pixeldroid.app.databinding.ActivityVideoEditBinding
|
||||
import org.pixeldroid.app.postCreation.PostCreationActivity
|
||||
import org.pixeldroid.app.postCreation.carousel.dpToPx
|
||||
import org.pixeldroid.app.utils.BaseActivity
|
||||
import org.pixeldroid.app.utils.ffmpegSafeUri
|
||||
import java.io.File
|
||||
|
||||
|
||||
class VideoEditActivity : BaseActivity() {
|
||||
|
||||
private lateinit var mediaPlayer: MediaPlayer
|
||||
private var videoPosition: Int = -1
|
||||
private lateinit var binding: ActivityVideoEditBinding
|
||||
// Map photoData indexes to FFmpeg Session IDs
|
||||
private val sessionList: ArrayList<Long> = arrayListOf()
|
||||
private val tempFiles: ArrayList<File> = ArrayList()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityVideoEditBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
supportActionBar?.setTitle(R.string.toolbar_title_edit)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setHomeButtonEnabled(true)
|
||||
|
||||
|
||||
binding.videoRangeSeekBar.setCustomThumbDrawablesForValues(R.drawable.thumb_left,R.drawable.double_circle,R.drawable.thumb_right)
|
||||
binding.videoRangeSeekBar.thumbRadius = 20.dpToPx(this)
|
||||
|
||||
|
||||
val resultHandler: Handler = HandlerCompat.createAsync(Looper.getMainLooper())
|
||||
|
||||
val uri = intent.getParcelableExtra<Uri>(PhotoEditActivity.PICTURE_URI)!!
|
||||
videoPosition = intent.getIntExtra(PhotoEditActivity.PICTURE_POSITION, -1)
|
||||
|
||||
val inputVideoPath = ffmpegSafeUri(uri)
|
||||
val mediaInformation: MediaInformation? = FFprobeKit.getMediaInformation(inputVideoPath).mediaInformation
|
||||
|
||||
binding.muter.setOnClickListener {
|
||||
binding.muter.isSelected = !binding.muter.isSelected
|
||||
}
|
||||
|
||||
//Duration in seconds, or null
|
||||
val duration: Float? = mediaInformation?.duration?.toFloatOrNull()
|
||||
|
||||
binding.videoRangeSeekBar.valueFrom = 0f
|
||||
binding.videoRangeSeekBar.valueTo = duration ?: 100f
|
||||
binding.videoRangeSeekBar.values = listOf(0f,(duration?: 100f) / 2, duration ?: 100f)
|
||||
|
||||
|
||||
val mediaItem: UriMediaItem = UriMediaItem.Builder(uri).build()
|
||||
mediaItem.metadata = MediaMetadata.Builder()
|
||||
.putString(MediaMetadata.METADATA_KEY_TITLE, "")
|
||||
.build()
|
||||
|
||||
mediaPlayer = MediaPlayer(this)
|
||||
mediaPlayer.setMediaItem(mediaItem)
|
||||
|
||||
//binding.videoView.mediaControlView?.setMediaController()
|
||||
|
||||
// Configure audio
|
||||
mediaPlayer.setAudioAttributes(AudioAttributesCompat.Builder()
|
||||
.setLegacyStreamType(AudioManager.STREAM_MUSIC)
|
||||
.setUsage(AudioAttributesCompat.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributesCompat.CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
)
|
||||
|
||||
findViewById<FrameLayout?>(R.id.progress_bar)?.visibility = View.GONE
|
||||
|
||||
mediaPlayer.prepare()
|
||||
|
||||
binding.muter.setOnClickListener {
|
||||
if(!binding.muter.isSelected) mediaPlayer.playerVolume = 0f
|
||||
else mediaPlayer.playerVolume = 1f
|
||||
binding.muter.isSelected = !binding.muter.isSelected
|
||||
}
|
||||
|
||||
binding.videoView.setPlayer(mediaPlayer)
|
||||
|
||||
mediaPlayer.seekTo((binding.videoRangeSeekBar.values[1]*1000).toLong())
|
||||
|
||||
object : Runnable {
|
||||
override fun run() {
|
||||
val getCurrent = mediaPlayer.currentPosition / 1000f
|
||||
if(getCurrent >= binding.videoRangeSeekBar.values[0] && getCurrent <= binding.videoRangeSeekBar.values[2] ) {
|
||||
binding.videoRangeSeekBar.values = listOf(binding.videoRangeSeekBar.values[0],getCurrent, binding.videoRangeSeekBar.values[2])
|
||||
}
|
||||
Handler(Looper.getMainLooper()).postDelayed(this, 1000)
|
||||
}
|
||||
}.run()
|
||||
|
||||
binding.videoRangeSeekBar.addOnChangeListener { rangeSlider: RangeSlider, value, fromUser ->
|
||||
// Responds to when the middle slider's value is changed
|
||||
if(fromUser && value != rangeSlider.values[0] && value != rangeSlider.values[2]) {
|
||||
mediaPlayer.seekTo((rangeSlider.values[1]*1000).toLong())
|
||||
}
|
||||
}
|
||||
|
||||
binding.videoRangeSeekBar.setLabelFormatter { value: Float ->
|
||||
DateUtils.formatElapsedTime(value.toLong())
|
||||
}
|
||||
|
||||
|
||||
val thumbInterval: Float? = duration?.div(7)
|
||||
|
||||
thumbInterval?.let {
|
||||
thumbnail(uri, resultHandler, binding.thumbnail1, it)
|
||||
thumbnail(uri, resultHandler, binding.thumbnail2, it.times(2))
|
||||
thumbnail(uri, resultHandler, binding.thumbnail3, it.times(3))
|
||||
thumbnail(uri, resultHandler, binding.thumbnail4, it.times(4))
|
||||
thumbnail(uri, resultHandler, binding.thumbnail5, it.times(5))
|
||||
thumbnail(uri, resultHandler, binding.thumbnail6, it.times(6))
|
||||
thumbnail(uri, resultHandler, binding.thumbnail7, it.times(7))
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.edit_menu, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
|
||||
when(item.itemId) {
|
||||
android.R.id.home -> onBackPressed()
|
||||
R.id.action_save -> {
|
||||
returnWithValues()
|
||||
}
|
||||
R.id.action_reset -> {
|
||||
resetControls()
|
||||
}
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (noEdits()) super.onBackPressed()
|
||||
else {
|
||||
val builder = AlertDialog.Builder(this)
|
||||
builder.apply {
|
||||
setMessage(R.string.save_before_returning)
|
||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
returnWithValues()
|
||||
}
|
||||
setNegativeButton(R.string.no_cancel_edit) { _, _ ->
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
// Create the AlertDialog
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun noEdits(): Boolean {
|
||||
val videoPositions = binding.videoRangeSeekBar.values.let {
|
||||
it[0] == 0f && it[2] == binding.videoRangeSeekBar.valueTo
|
||||
}
|
||||
val muted = binding.muter.isSelected
|
||||
return !muted && videoPositions
|
||||
}
|
||||
|
||||
|
||||
private fun returnWithValues() {
|
||||
val intent = Intent(this, PostCreationActivity::class.java)
|
||||
.apply {
|
||||
putExtra(PhotoEditActivity.PICTURE_POSITION, videoPosition)
|
||||
putExtra(MUTED, binding.muter.isSelected)
|
||||
putExtra(MODIFIED, !noEdits())
|
||||
putExtra(VIDEO_START, binding.videoRangeSeekBar.values.first())
|
||||
putExtra(VIDEO_END, binding.videoRangeSeekBar.values[2])
|
||||
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
}
|
||||
|
||||
setResult(Activity.RESULT_OK, intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun resetControls() {
|
||||
binding.videoRangeSeekBar.values = listOf(0f, binding.videoRangeSeekBar.valueTo/2, binding.videoRangeSeekBar.valueTo)
|
||||
binding.muter.isSelected = false
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
sessionList.forEach {
|
||||
FFmpegKit.cancel(it)
|
||||
}
|
||||
tempFiles.forEach{
|
||||
it.delete()
|
||||
}
|
||||
mediaPlayer.close()
|
||||
}
|
||||
|
||||
private fun thumbnail(
|
||||
inputUri: Uri?,
|
||||
resultHandler: Handler,
|
||||
thumbnail: ImageView,
|
||||
thumbTime: Float,
|
||||
) {
|
||||
val file = File.createTempFile("temp_img", ".bmp")
|
||||
tempFiles.add(file)
|
||||
val fileUri = file.toUri()
|
||||
val inputSafePath = ffmpegSafeUri(inputUri)
|
||||
|
||||
val outputImagePath =
|
||||
if(fileUri.toString().startsWith("content://"))
|
||||
FFmpegKitConfig.getSafParameterForWrite(this, fileUri)
|
||||
else fileUri.toString()
|
||||
val session = FFmpegKit.executeAsync(
|
||||
"-noaccurate_seek -ss $thumbTime -i $inputSafePath -vf scale=${thumbnail.width}:${thumbnail.height} -frames:v 1 -f image2 -y $outputImagePath",
|
||||
{ session ->
|
||||
val state = session.state
|
||||
val returnCode = session.returnCode
|
||||
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
// SUCCESS
|
||||
resultHandler.post {
|
||||
if(!this.isFinishing)
|
||||
Glide.with(this).load(outputImagePath).centerCrop().into(thumbnail)
|
||||
}
|
||||
}
|
||||
// CALLED WHEN SESSION IS EXECUTED
|
||||
Log.d("VideoEditActivity", "FFmpeg process exited with state $state and rc $returnCode.${session.failStackTrace}")
|
||||
},
|
||||
{/* CALLED WHEN SESSION PRINTS LOGS */ }) { /*CALLED WHEN SESSION GENERATES STATISTICS*/ }
|
||||
sessionList.add(session.sessionId)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
mediaPlayer.pause()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VIDEO_TAG = "VideoEditTag"
|
||||
const val MUTED = "VideoEditMutedTag"
|
||||
const val VIDEO_START = "VideoEditVideoStartTag"
|
||||
const val VIDEO_END = "VideoEditVideoEndTag"
|
||||
const val MODIFIED = "VideoEditModifiedTag"
|
||||
}
|
||||
}
|
@ -19,6 +19,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import okhttp3.HttpUrl
|
||||
import org.pixeldroid.app.R
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
@ -44,6 +45,13 @@ fun validDomain(domain: String?): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
fun Uri.fileExtension(contentResolver: ContentResolver): String? {
|
||||
return if (scheme == "content") {
|
||||
contentResolver.getType(this)?.takeLastWhile { it != '/' }
|
||||
} else {
|
||||
toString().takeLastWhile { it != '/' }
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.displayDimensionsInPx(): Pair<Int, Int> {
|
||||
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@ -65,6 +73,12 @@ fun normalizeDomain(domain: String): String {
|
||||
.trim(Char::isWhitespace)
|
||||
}
|
||||
|
||||
fun Context.ffmpegSafeUri(inputUri: Uri?): String =
|
||||
if (inputUri?.scheme == "content")
|
||||
FFmpegKitConfig.getSafParameterForRead(this, inputUri)
|
||||
else inputUri.toString()
|
||||
|
||||
|
||||
fun bitmapFromUri(contentResolver: ContentResolver, uri: Uri?): Bitmap =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
ImageDecoder
|
||||
@ -107,7 +121,7 @@ fun Bitmap.flip(horizontal: Boolean, vertical: Boolean): Bitmap {
|
||||
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
|
||||
}
|
||||
|
||||
fun BaseActivity.openUrl(url: String): Boolean{
|
||||
fun BaseActivity.openUrl(url: String): Boolean {
|
||||
|
||||
val intent = CustomTabsIntent.Builder().build()
|
||||
|
||||
|
@ -7,6 +7,7 @@ import org.pixeldroid.app.utils.PixelDroidApplication
|
||||
import org.pixeldroid.app.utils.db.AppDatabase
|
||||
import org.pixeldroid.app.utils.BaseFragment
|
||||
import dagger.Component
|
||||
import org.pixeldroid.app.postCreation.PostCreationViewModel
|
||||
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker
|
||||
import javax.inject.Singleton
|
||||
|
||||
@ -18,6 +19,7 @@ interface ApplicationComponent {
|
||||
fun inject(activity: BaseActivity?)
|
||||
fun inject(feedFragment: BaseFragment)
|
||||
fun inject(notificationsWorker: NotificationsWorker)
|
||||
fun inject(postCreationViewModel: PostCreationViewModel)
|
||||
|
||||
val context: Context?
|
||||
val application: Application?
|
||||
|
11
app/src/main/res/drawable/double_circle.xml
Normal file
11
app/src/main/res/drawable/double_circle.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<vector android:height="10dp"
|
||||
android:viewportHeight="36" android:viewportWidth="36"
|
||||
android:width="10dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/black" android:pathData=
|
||||
"M 18 18
|
||||
m -9, 0
|
||||
a 9,9 0 1,0 18,0
|
||||
a 9,9 0 1,0 -18,0"/>
|
||||
<path android:fillColor="@android:color/white" android:pathData="M18,8C12.48,8,8,12.48,8,18s4.48,10,10,10s10,-4.48,10,-10S23.52,8,18,8zM18,26c-4.42,0,-8,-3.58,-8,-8s3.58,-8,8,-8s8,3.58,8,8s-3.58,8,-8,8z"/>
|
||||
<path android:fillColor="@android:color/white" android:pathData="M18,18m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"/>
|
||||
</vector>
|
5
app/src/main/res/drawable/error.xml
Normal file
5
app/src/main/res/drawable/error.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z"/>
|
||||
</vector>
|
9
app/src/main/res/drawable/selector_mute.xml
Normal file
9
app/src/main/res/drawable/selector_mute.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:drawable="@drawable/volume_up"
|
||||
android:state_selected="false" />
|
||||
<item
|
||||
android:drawable="@drawable/volume_off"
|
||||
android:state_selected="true"/>
|
||||
</selector>
|
25
app/src/main/res/drawable/thumb_left.xml
Normal file
25
app/src/main/res/drawable/thumb_left.xml
Normal file
@ -0,0 +1,25 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="17.586dp"
|
||||
android:height="20.915dp"
|
||||
android:viewportWidth="17.586"
|
||||
android:viewportHeight="20.915">
|
||||
<path
|
||||
android:pathData="m5.29,0h8.006v20.915h-8.006a1,1 45,0 1,-1 -1v-18.915a1,1 135,0 1,1 -1z"
|
||||
android:strokeWidth="0.264583"
|
||||
android:fillColor="#ffffff"/>
|
||||
<path
|
||||
android:pathData="m10.259,6.794 l-3.664,3.664 3.664,3.664z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="m8.434,12.251 l-1.79,-1.791 1.801,-1.8 1.801,-1.8l0,3.591c0,1.975 -0.005,3.591 -0.011,3.591 -0.006,0 -0.816,-0.806 -1.801,-1.791z"
|
||||
android:strokeWidth="0.042"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="m8.434,12.251 l-1.79,-1.791 1.801,-1.8 1.801,-1.8l0,3.591c0,1.975 -0.005,3.591 -0.011,3.591 -0.006,0 -0.816,-0.806 -1.801,-1.791z"
|
||||
android:strokeWidth="0.042"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="m8.434,12.251 l-1.79,-1.791 1.801,-1.8 1.801,-1.8l0,3.591c0,1.975 -0.005,3.591 -0.011,3.591 -0.006,0 -0.816,-0.806 -1.801,-1.791z"
|
||||
android:strokeWidth="0.042"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
29
app/src/main/res/drawable/thumb_right.xml
Normal file
29
app/src/main/res/drawable/thumb_right.xml
Normal file
@ -0,0 +1,29 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="17.586dp"
|
||||
android:height="20.915dp"
|
||||
android:viewportWidth="17.586"
|
||||
android:viewportHeight="20.915">
|
||||
<group
|
||||
android:scaleX="-1"
|
||||
android:translateX="17.586">
|
||||
<path
|
||||
android:pathData="m5.29,0h8.006v20.915h-8.006a1,1 45,0 1,-1 -1v-18.915a1,1 135,0 1,1 -1z"
|
||||
android:strokeWidth="0.264583"
|
||||
android:fillColor="#ffffff"/>
|
||||
<path
|
||||
android:pathData="m10.259,6.794 l-3.664,3.664 3.664,3.664z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="m8.434,12.251 l-1.79,-1.791 1.801,-1.8 1.801,-1.8l0,3.591c0,1.975 -0.005,3.591 -0.011,3.591 -0.006,0 -0.816,-0.806 -1.801,-1.791z"
|
||||
android:strokeWidth="0.042"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="m8.434,12.251 l-1.79,-1.791 1.801,-1.8 1.801,-1.8l0,3.591c0,1.975 -0.005,3.591 -0.011,3.591 -0.006,0 -0.816,-0.806 -1.801,-1.791z"
|
||||
android:strokeWidth="0.042"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="m8.434,12.251 l-1.79,-1.791 1.801,-1.8 1.801,-1.8l0,3.591c0,1.975 -0.005,3.591 -0.011,3.591 -0.006,0 -0.816,-0.806 -1.801,-1.791z"
|
||||
android:strokeWidth="0.042"
|
||||
android:fillColor="#000000"/>
|
||||
</group>
|
||||
</vector>
|
5
app/src/main/res/drawable/volume_off.xml
Normal file
5
app/src/main/res/drawable/volume_off.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v2.21l2.45,2.45c0.03,-0.2 0.05,-0.41 0.05,-0.63zM19,12c0,0.94 -0.2,1.82 -0.54,2.64l1.51,1.51C20.63,14.91 21,13.5 21,12c0,-4.28 -2.99,-7.86 -7,-8.77v2.06c2.89,0.86 5,3.54 5,6.71zM4.27,3L3,4.27 7.73,9L3,9v6h4l5,5v-6.73l4.25,4.25c-0.67,0.52 -1.42,0.93 -2.25,1.18v2.06c1.38,-0.31 2.63,-0.95 3.69,-1.81L19.73,21 21,19.73l-9,-9L4.27,3zM12,4L9.91,6.09 12,8.18L12,4z"/>
|
||||
</vector>
|
5
app/src/main/res/drawable/volume_up.xml
Normal file
5
app/src/main/res/drawable/volume_up.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M3,9v6h4l5,5L12,4L7,9L3,9zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"/>
|
||||
</vector>
|
112
app/src/main/res/layout/activity_video_edit.xml
Normal file
112
app/src/main/res/layout/activity_video_edit.xml
Normal file
@ -0,0 +1,112 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/black"
|
||||
android:scrollbarThumbHorizontal="@drawable/thumb_left">
|
||||
|
||||
|
||||
|
||||
<androidx.media2.widget.VideoView
|
||||
android:id="@+id/videoView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:background="#000000"
|
||||
app:layout_constraintBottom_toTopOf="@+id/thumbnail4"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/muter"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginBottom="48dp"
|
||||
android:contentDescription="@string/add_comment"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:padding="4dp"
|
||||
android:src="@drawable/selector_mute"
|
||||
app:layout_constraintBottom_toTopOf="@+id/thumbnail1"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<com.google.android.material.slider.RangeSlider
|
||||
android:id="@+id/videoRangeSeekBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:contentDescription="@string/select_video_range"
|
||||
android:elevation="5dp"
|
||||
android:layout_marginStart="-15dp"
|
||||
android:layout_marginEnd="-15dp"
|
||||
android:valueFrom="0.0"
|
||||
android:valueTo="100.0"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@+id/thumbnail1" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/thumbnail1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="80dp"
|
||||
android:contentDescription="@string/thumbnail_reel_video_edit"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toLeftOf="@+id/thumbnail2" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/thumbnail2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="80dp"
|
||||
android:contentDescription="@string/thumbnail_reel_video_edit"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toRightOf="@+id/thumbnail1"
|
||||
app:layout_constraintRight_toLeftOf="@+id/thumbnail3" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/thumbnail3"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="80dp"
|
||||
android:contentDescription="@string/thumbnail_reel_video_edit"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toRightOf="@+id/thumbnail2"
|
||||
app:layout_constraintRight_toLeftOf="@+id/thumbnail4" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/thumbnail4"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="80dp"
|
||||
android:contentDescription="@string/thumbnail_reel_video_edit"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toRightOf="@+id/thumbnail3"
|
||||
app:layout_constraintRight_toLeftOf="@+id/thumbnail5" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/thumbnail5"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="80dp"
|
||||
android:contentDescription="@string/thumbnail_reel_video_edit"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toRightOf="@+id/thumbnail4"
|
||||
app:layout_constraintRight_toLeftOf="@+id/thumbnail6" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/thumbnail6"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="80dp"
|
||||
android:contentDescription="@string/thumbnail_reel_video_edit"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toRightOf="@+id/thumbnail5"
|
||||
app:layout_constraintRight_toLeftOf="@+id/thumbnail7" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/thumbnail7"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="80dp"
|
||||
android:contentDescription="@string/thumbnail_reel_video_edit"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toRightOf="@+id/thumbnail6"
|
||||
app:layout_constraintRight_toRightOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
@ -162,4 +162,30 @@
|
||||
app:layout_constraintTop_toTopOf="@+id/indicator"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:visibility="invisible"
|
||||
tools:visibility="visible"
|
||||
tools:progress="30"
|
||||
android:id="@+id/encodeProgress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="64dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:indeterminate="false"
|
||||
android:max="100"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/encodeInfoText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
tools:drawableStartCompat="@drawable/ic_heart"
|
||||
android:drawablePadding="6dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/encodeProgress"
|
||||
tools:text="Encoding..." />
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
@ -7,13 +7,13 @@
|
||||
<item
|
||||
android:id="@+id/action_reset"
|
||||
android:orderInCategory="100"
|
||||
android:title="RESET"
|
||||
android:title="@string/reset_edit_menu"
|
||||
android:icon="@drawable/restore_24dp"
|
||||
app:showAsAction="ifRoom"/>
|
||||
<item
|
||||
android:id="@+id/action_save"
|
||||
android:orderInCategory="101"
|
||||
android:title="SAVE"
|
||||
android:title="@string/save_edit_menu"
|
||||
android:icon="@drawable/ic_save_24dp"
|
||||
app:showAsAction="ifRoom"/>
|
||||
|
@ -262,4 +262,12 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
|
||||
<string name="no_storage_permission">Storage permission not granted, grant the permission in settings if you want to let PixelDroid show the thumbnail</string>
|
||||
<string name="play_video">Play video</string>
|
||||
<string name="video_edit_not_yet_supported">Video editing is not yet supported</string>
|
||||
<string name="thumbnail_reel_video_edit">Reel showing thumbnails of the video you are editing</string>
|
||||
<string name="reset_edit_menu">RESET</string>
|
||||
<string name="save_edit_menu">SAVE</string>
|
||||
<string name="encode_error">Error encoding</string>
|
||||
<string name="encode_success">Encode success!</string>
|
||||
<string name="encode_progress">Encode %1$d%%</string>
|
||||
<string name="select_video_range">Select what to keep of the video</string>
|
||||
<string name="still_encoding">One or more videos are still encoding. Wait for them to finish before uploading</string>
|
||||
</resources>
|
@ -7,7 +7,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.1.3'
|
||||
classpath 'com.android.tools.build:gradle:7.2.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
4
gradle/wrapper/gradle-wrapper.properties
vendored
4
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,7 +1,7 @@
|
||||
#Tue Jun 07 20:42:16 CEST 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
distributionSha256Sum=f581709a9c35e9cb92e16f585d2c4bc99b2b1a5f85d2badbd3dc6bff59e1e6dd
|
||||
distributionSha256Sum=b586e04868a22fd817c8971330fec37e298f3242eb85c374181b12d637f80302
|
||||
|
Loading…
x
Reference in New Issue
Block a user