Merge branch 'edit_video' into 'master'

Video edit functionality

See merge request pixeldroid/PixelDroid!429
This commit is contained in:
Matthieu 2022-06-20 13:51:56 +00:00
commit cc37bfefce
25 changed files with 1280 additions and 326 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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