Video edit

This commit is contained in:
Matthieu 2022-06-18 22:21:19 +02:00
parent 1f03f96d7a
commit 8cecfa3de6
20 changed files with 744 additions and 87 deletions

View File

@ -28,8 +28,7 @@
android:theme="@style/AppTheme.ActionBar.Transparent"/>
<activity
android:name=".postCreation.photoEdit.VideoEditActivity"
android:exported="false"
android:theme="@style/AppTheme.ActionBar.Transparent"/>
android:exported="false"/>
<activity
android:name=".posts.MediaViewerActivity"

View File

@ -5,9 +5,7 @@ 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
@ -18,13 +16,19 @@ 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.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
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 okhttp3.MultipartBody
import org.pixeldroid.app.MainActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityPostCreationBinding
@ -32,23 +36,26 @@ 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 org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity
import org.pixeldroid.app.posts.PostActivity
import org.pixeldroid.app.utils.api.objects.Status
import org.pixeldroid.app.utils.ffmpegSafeUri
import retrofit2.HttpException
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.OutputStream
import java.text.SimpleDateFormat
import kotlin.math.ceil
import com.arthenica.ffmpegkit.FFprobeKit
import java.util.*
import kotlin.collections.ArrayList
import kotlin.math.ceil
import kotlin.math.roundToInt
private const val TAG = "Post Creation Activity"
@ -59,6 +66,7 @@ data class PhotoData(
var progress: Int? = null,
var imageDescription: String? = null,
var video: Boolean,
var videoEncodeProgress: Int? = null,
)
class PostCreationActivity : BaseActivity() {
@ -70,11 +78,22 @@ class PostCreationActivity : BaseActivity() {
private lateinit var binding: ActivityPostCreationBinding
private val resultHandler: Handler = HandlerCompat.createAsync(Looper.getMainLooper())
// 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: ArrayList<File> = ArrayList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityPostCreationBinding.inflate(layoutInflater)
setContentView(binding.root)
user = db.userDao().getActiveUser()
instance = user?.run {
@ -89,7 +108,7 @@ class PostCreationActivity : BaseActivity() {
intent.clipData?.let { addPossibleImages(it) }
val carousel: ImageCarousel = binding.carousel
carousel.addData(photoData.map { CarouselItem(it.imageUri, video = it.video) })
carousel.addData(photoData.map { CarouselItem(it.imageUri, video = it.video, encodeProgress = null) })
carousel.layoutCarouselCallback = {
if(it){
// Became a carousel
@ -109,7 +128,7 @@ class PostCreationActivity : BaseActivity() {
// get the description and send the post
binding.postCreationSendButton.setOnClickListener {
if (validateDescription() && photoData.isNotEmpty()) upload()
if (validatePost() && photoData.isNotEmpty()) upload()
}
// Button to retry image upload when it fails
@ -123,7 +142,7 @@ class PostCreationActivity : BaseActivity() {
}
binding.editPhotoButton.setOnClickListener {
carousel.currentPosition.takeIf { it != -1 }?.let { currentPosition ->
carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition ->
edit(currentPosition)
}
}
@ -133,27 +152,36 @@ class PostCreationActivity : BaseActivity() {
}
binding.savePhotoButton.setOnClickListener {
carousel.currentPosition.takeIf { it != -1 }?.let { currentPosition ->
carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition ->
savePicture(it, currentPosition)
}
}
binding.removePhotoButton.setOnClickListener {
carousel.currentPosition.takeIf { it != -1 }?.let { currentPosition ->
carousel.currentPosition.takeIf { it != RecyclerView.NO_POSITION }?.let { currentPosition ->
photoData.removeAt(currentPosition)
carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription, it.video) })
sessionMap[currentPosition]?.let { FFmpegKit.cancel(it) }
carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription, it.video, it.videoEncodeProgress) })
binding.addPhotoButton.isEnabled = true
}
}
}
override fun onDestroy() {
super.onDestroy()
FFmpegKit.cancel()
tempFiles.forEach {
it.delete()
}
}
/**
* 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
* ([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.
*/
private fun addPossibleImages(clipData: ClipData){
private fun addPossibleImages(clipData: ClipData) {
var count = clipData.itemCount
if(count + photoData.size > instance.albumLimit){
AlertDialog.Builder(this).apply {
@ -168,7 +196,7 @@ class PostCreationActivity : BaseActivity() {
}
for (i in 0 until count) {
clipData.getItemAt(i).uri.let {
val sizeAndVideoPair: Pair<Long, Boolean> = it.getSizeAndVideoValidate()
val sizeAndVideoPair: Pair<Long, Boolean> = it.getSizeAndVideoValidate(photoData.size + 1)
photoData.add(PhotoData(imageUri = it, size = sizeAndVideoPair.first, video = sizeAndVideoPair.second))
}
}
@ -178,7 +206,7 @@ class PostCreationActivity : BaseActivity() {
* 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> {
private fun Uri.getSizeAndVideoValidate(editPosition: Int): Pair<Long, Boolean> {
val size: Long =
if (toString().startsWith("content")) {
contentResolver.query(this, null, null, null, null)
@ -209,7 +237,7 @@ class PostCreationActivity : BaseActivity() {
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))
setMessage(getString(R.string.size_exceeds_instance_limit, editPosition, sizeInkBytes, maxSize))
setNegativeButton(android.R.string.ok) { _, _ -> }
}.show()
}
@ -221,7 +249,7 @@ class PostCreationActivity : BaseActivity() {
result.data?.clipData?.let {
addPossibleImages(it)
}
binding.carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription, it.video) })
binding.carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription, it.video, it.videoEncodeProgress) })
} else if (result.resultCode != Activity.RESULT_CANCELED) {
Toast.makeText(applicationContext, "Error while adding images", Toast.LENGTH_SHORT).show()
}
@ -294,7 +322,7 @@ class PostCreationActivity : BaseActivity() {
}
private fun validateDescription(): Boolean {
private fun validatePost(): Boolean {
binding.postTextInputLayout.run {
val content = editText?.length() ?: 0
if (content > counterMaxLength) {
@ -303,6 +331,13 @@ class PostCreationActivity : BaseActivity() {
return false
}
}
if(!photoData.all { it.videoEncodeProgress == null }){
AlertDialog.Builder(this).apply {
setMessage(R.string.still_encoding)
setNegativeButton(android.R.string.ok) { _, _ -> }
}.show()
return false
}
return true
}
@ -435,20 +470,132 @@ class PostCreationActivity : BaseActivity() {
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
if (video) {
val muted: Boolean = result.data!!.getBooleanExtra(VideoEditActivity.MUTED, false)
val videoStart: Float? = result.data!!.getFloatExtra(VideoEditActivity.VIDEO_START, -1f).let {
if(it == -1f) null else it
}
val modified: Boolean = result.data!!.getBooleanExtra(VideoEditActivity.MODIFIED, false)
val videoEnd: Float? = result.data!!.getFloatExtra(VideoEditActivity.VIDEO_END, -1f).let {
if(it == -1f) null else it
}
if(modified){
videoEncodeProgress = 0
sessionMap[position]?.let { FFmpegKit.cancel(it) }
startEncoding(position, muted, videoStart, videoEnd)
}
} else {
imageUri = result.data!!.getStringExtra(PhotoEditActivity.PICTURE_URI)!!.toUri()
val (imageSize, imageVideo) = imageUri.getSizeAndVideoValidate(position)
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) })
binding.carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription, it.video, it.videoEncodeProgress) })
} else if(result?.resultCode != Activity.RESULT_CANCELED){
Toast.makeText(applicationContext, "Error while editing", Toast.LENGTH_SHORT).show()
}
}
/**
* @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 = photoData[position].imageUri
// Having a meaningful suffix is necessary so that ffmpeg knows what to put in output
val suffix = if(originalUri.scheme == "content") {
contentResolver.getType(photoData[position].imageUri)?.takeLastWhile { it != '/' }
} else {
originalUri.toString().takeLastWhile { it != '/' }
}
val file = File.createTempFile("temp_video", ".$suffix")
//val file = File.createTempFile("temp_video", ".webm")
tempFiles.add(file)
val fileUri = file.toUri()
val outputVideoPath = ffmpegSafeUri(fileUri)
val inputUri = photoData[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, imageVideo) = outputVideoPath.toUri().let {
photoData[position].imageUri = it
it.getSizeAndVideoValidate(position)
}
photoData[position].videoEncodeProgress = null
photoData[position].size = imageSize
binding.carousel.addData(photoData.map {
CarouselItem(it.imageUri,
it.imageDescription,
it.video,
it.videoEncodeProgress)
})
}
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)
photoData[position].videoEncodeProgress = 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 = it.roundToInt()
photoData[position].videoEncodeProgress = rounded
binding.carousel.updateProgress(rounded, position, false)
}
}
Log.d(TAG, "Encoding video: %$completePercentage.")
}
}
}
sessionMap[position] = session.sessionId
}
private fun edit(position: Int) {
val intent = Intent(
this,

View File

@ -0,0 +1,30 @@
package org.pixeldroid.app.postCreation
import android.content.ClipData
import android.os.Bundle
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
class PostCreationViewModel : ViewModel() {
private val photoData: MutableLiveData<List<PhotoData>> by lazy {
MutableLiveData<List<PhotoData>>().also {
loadUsers()
}
}
fun getUsers(): LiveData<List<PhotoData>> {
return photoData
}
private fun loadUsers() {
// Do an asynchronous operation to fetch users.
}
}
class PostCreationViewModelFactory(val bundle: ClipData? = null) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.getConstructor(ClipData::class.java).newInstance(bundle)
}
}

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

@ -69,7 +69,7 @@ class ImageCarousel(
private var isBuiltInIndicator = false
private var data: List<CarouselItem>? = null
private var data: MutableList<CarouselItem>? = null
var onItemClickListener: OnItemClickListener? = this
set(value) {
@ -88,28 +88,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
}
/**
@ -450,10 +456,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 +474,8 @@ class ImageCarousel(
}
}
if(dx !=0 || dy != 0) currentPosition = position
onScrollListener?.onScrolled(recyclerView, dx, dy)
}
@ -561,12 +568,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

@ -1,57 +1,279 @@
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 android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener
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.arthenica.ffmpegkit.MediaInformation.KEY_DURATION
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
import java.text.NumberFormat
import java.time.format.DateTimeFormatter
import java.util.*
import kotlin.collections.ArrayList
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)
val binding = ActivityVideoEditBinding.inflate(layoutInflater)
binding = ActivityVideoEditBinding.inflate(layoutInflater)
setContentView(binding.root)
val uri = intent.getParcelableExtra(PhotoEditActivity.PICTURE_URI) as Uri?
val videoPosition = intent.getIntExtra(PhotoEditActivity.PICTURE_POSITION, -1)
val inputVideoPath =if(uri.toString().startsWith("content://")) FFmpegKitConfig.getSafParameterForRead(this, uri) else uri.toString()
val inputVideoPath2 =if(uri.toString().startsWith("content://")) FFmpegKitConfig.getSafParameterForRead(this, uri) else uri.toString()
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
val duration: Long? = mediaInformation?.getNumberProperty(KEY_DURATION)
val file = File.createTempFile("temp_img", ".png").toUri()
val outputImagePath =if(file.toString().startsWith("content://")) FFmpegKitConfig.getSafParameterForWrite(this, file) else file.toString()
val session = FFmpegKit.execute(
"-i $inputVideoPath2 -filter_complex \"select='not(mod(n,1000))',scale=240:-1,tile=layout=4x1\" -vframes 1 -q:v 2 -y $outputImagePath"
)
if (ReturnCode.isSuccess(session.returnCode)) {
Glide.with(this).load(file).into(binding.thumbnails)
// SUCCESS
} else if (ReturnCode.isCancel(session.returnCode)) {
// CANCEL
} else {
// FAILURE
Log.d("VideoEditActivity",
String.format("Command failed with state %s and rc %s.%s",
session.state,
session.returnCode,
session.failStackTrace))
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
@ -65,6 +66,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 +114,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

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

@ -1,15 +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: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/thumbnails"
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="wrap_content"
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_constraintTop_toTopOf="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

@ -263,4 +263,11 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</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

@ -4,4 +4,4 @@ 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