diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 89f1821d..4ba866d4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,8 +28,7 @@ android:theme="@style/AppTheme.ActionBar.Transparent"/> + android:exported="false"/> = mutableMapOf() + // Keep track of temporary files to delete them (avoids filling cache super fast with videos) + private val tempFiles: ArrayList = 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 = it.getSizeAndVideoValidate() + val sizeAndVideoPair: Pair = 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 { + private fun Uri.getSizeAndVideoValidate(editPosition: Int): Pair { 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, diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt new file mode 100644 index 00000000..8b7f1d1b --- /dev/null +++ b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt @@ -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> by lazy { + MutableLiveData>().also { + loadUsers() + } + } + + fun getUsers(): LiveData> { + return photoData + } + + private fun loadUsers() { + // Do an asynchronous operation to fetch users. + } +} +class PostCreationViewModelFactory(val bundle: ClipData? = null) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.getConstructor(ClipData::class.java).newInstance(bundle) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/carousel/CarouselItem.kt b/app/src/main/java/org/pixeldroid/app/postCreation/carousel/CarouselItem.kt index 37dfda86..bbd4b8c7 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/carousel/CarouselItem.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/carousel/CarouselItem.kt @@ -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? ) \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/carousel/ImageCarousel.kt b/app/src/main/java/org/pixeldroid/app/postCreation/carousel/ImageCarousel.kt index 5b628b3f..bb2473aa 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/carousel/ImageCarousel.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/carousel/ImageCarousel.kt @@ -69,7 +69,7 @@ class ImageCarousel( private var isBuiltInIndicator = false - private var data: List? = null + private var data: MutableList? = 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. */ diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/PhotoEditActivity.kt b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/PhotoEditActivity.kt index d32789f9..04566a8b 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/PhotoEditActivity.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/PhotoEditActivity.kt @@ -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) diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt index 53c3240d..11d0526a 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt @@ -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 = arrayListOf() + private val tempFiles: ArrayList = 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(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(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" } } \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/utils/Utils.kt b/app/src/main/java/org/pixeldroid/app/utils/Utils.kt index c724c15b..b2ef5187 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/Utils.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/Utils.kt @@ -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() diff --git a/app/src/main/res/drawable/double_circle.xml b/app/src/main/res/drawable/double_circle.xml new file mode 100644 index 00000000..907d1235 --- /dev/null +++ b/app/src/main/res/drawable/double_circle.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/error.xml b/app/src/main/res/drawable/error.xml new file mode 100644 index 00000000..17575711 --- /dev/null +++ b/app/src/main/res/drawable/error.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/selector_mute.xml b/app/src/main/res/drawable/selector_mute.xml new file mode 100644 index 00000000..7103cc0c --- /dev/null +++ b/app/src/main/res/drawable/selector_mute.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/thumb_left.xml b/app/src/main/res/drawable/thumb_left.xml new file mode 100644 index 00000000..d6c10419 --- /dev/null +++ b/app/src/main/res/drawable/thumb_left.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/app/src/main/res/drawable/thumb_right.xml b/app/src/main/res/drawable/thumb_right.xml new file mode 100644 index 00000000..6b5f6222 --- /dev/null +++ b/app/src/main/res/drawable/thumb_right.xml @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/volume_off.xml b/app/src/main/res/drawable/volume_off.xml new file mode 100644 index 00000000..b71ede34 --- /dev/null +++ b/app/src/main/res/drawable/volume_off.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/volume_up.xml b/app/src/main/res/drawable/volume_up.xml new file mode 100644 index 00000000..836cad86 --- /dev/null +++ b/app/src/main/res/drawable/volume_up.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_video_edit.xml b/app/src/main/res/layout/activity_video_edit.xml index d7c9300f..fe3feb98 100644 --- a/app/src/main/res/layout/activity_video_edit.xml +++ b/app/src/main/res/layout/activity_video_edit.xml @@ -1,15 +1,112 @@ + android:background="@android:color/black" + android:scrollbarThumbHorizontal="@drawable/thumb_left"> + + + + + + + + + app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintRight_toLeftOf="@+id/thumbnail2" /> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/image_carousel.xml b/app/src/main/res/layout/image_carousel.xml index 3bbd86fe..dee9a908 100644 --- a/app/src/main/res/layout/image_carousel.xml +++ b/app/src/main/res/layout/image_carousel.xml @@ -162,4 +162,30 @@ app:layout_constraintTop_toTopOf="@+id/indicator" tools:visibility="visible" /> + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/edit_photo_menu.xml b/app/src/main/res/menu/edit_menu.xml similarity index 83% rename from app/src/main/res/menu/edit_photo_menu.xml rename to app/src/main/res/menu/edit_menu.xml index 31194bd7..4730db3b 100644 --- a/app/src/main/res/menu/edit_photo_menu.xml +++ b/app/src/main/res/menu/edit_menu.xml @@ -7,13 +7,13 @@ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e756cb5..7a9f17e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -263,4 +263,11 @@ For more info about Pixelfed, you can check here: https://pixelfed.org" Play video Video editing is not yet supported Reel showing thumbnails of the video you are editing + RESET + SAVE + Error encoding + Encode success! + Encode %1$d%% + Select what to keep of the video + One or more videos are still encoding. Wait for them to finish before uploading \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 02b00d99..8e59e764 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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