You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
407 lines
15 KiB
407 lines
15 KiB
package io.lowrespalmtree.harvestdawn
|
|
|
|
import android.Manifest
|
|
import android.app.Activity
|
|
import android.app.AlertDialog
|
|
import android.content.ContentValues
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.pm.PackageManager
|
|
import android.net.Uri
|
|
import android.os.*
|
|
import android.provider.MediaStore
|
|
import android.util.Log
|
|
import android.view.Menu
|
|
import android.view.MenuItem
|
|
import android.view.View
|
|
import android.widget.MediaController
|
|
import android.widget.ProgressBar
|
|
import android.widget.Toast
|
|
import android.widget.VideoView
|
|
import androidx.appcompat.app.AppCompatActivity
|
|
import androidx.core.content.FileProvider
|
|
import androidx.core.net.toUri
|
|
import com.arthenica.ffmpegkit.FFmpegKit
|
|
import com.arthenica.ffmpegkit.FFprobeKit
|
|
import com.arthenica.ffmpegkit.ReturnCode
|
|
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
|
import java.io.File
|
|
import java.io.FileInputStream
|
|
import java.io.FileOutputStream
|
|
import java.lang.ref.WeakReference
|
|
import kotlin.math.roundToLong
|
|
|
|
class MainActivity : AppCompatActivity() {
|
|
private val spinner: ProgressBar? get() = findViewById(R.id.progressBar)
|
|
private val videoView: VideoView? get() = findViewById(R.id.videoView)
|
|
private var hasWritePermission = false
|
|
private var currentVideoPath: String? = null
|
|
private var currentVideoUri: Uri? = null
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
setContentView(R.layout.activity_main)
|
|
setSupportActionBar(findViewById(R.id.toolbar))
|
|
|
|
ensureWritePermission()
|
|
|
|
findViewById<FloatingActionButton>(R.id.fab).setOnClickListener {
|
|
if (hasWritePermission) {
|
|
openCamera()
|
|
} else {
|
|
ensureWritePermission()
|
|
}
|
|
}
|
|
|
|
if (intent.action == Intent.ACTION_SEND) {
|
|
intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM)?.also {
|
|
processVideoUri(it as Uri)
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
|
// Inflate the menu; this adds items to the action bar if it is present.
|
|
menuInflater.inflate(R.menu.menu_main, menu)
|
|
return true
|
|
}
|
|
|
|
override fun onSaveInstanceState(outState: Bundle) {
|
|
outState.putString("cvp", currentVideoPath)
|
|
outState.putParcelable("cvi", currentVideoUri)
|
|
super.onSaveInstanceState(outState)
|
|
}
|
|
|
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
|
currentVideoPath = savedInstanceState.getString("cvp")
|
|
currentVideoUri = savedInstanceState.getParcelable("cvi")
|
|
super.onRestoreInstanceState(savedInstanceState)
|
|
}
|
|
|
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
// Handle action bar item clicks here. The action bar will
|
|
// automatically handle clicks on the Home/Up button, so long
|
|
// as you specify a parent activity in AndroidManifest.xml.
|
|
return when (item.itemId) {
|
|
R.id.action_share -> { share(); true }
|
|
R.id.action_save -> { save(); true }
|
|
R.id.action_clear_cache -> { clearVideoCache(); true }
|
|
R.id.action_pick_theme -> { showThemeDialog(); true }
|
|
else -> super.onOptionsItemSelected(item)
|
|
}
|
|
}
|
|
|
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
|
when (requestCode) {
|
|
REQ_TAKE_VID -> {
|
|
if (resultCode != Activity.RESULT_OK)
|
|
return
|
|
data?.data?.let {
|
|
processVideoUri(it)
|
|
}
|
|
}
|
|
else -> super.onActivityResult(requestCode, resultCode, data)
|
|
}
|
|
}
|
|
|
|
private fun ensureWritePermission() {
|
|
val writePermission = Manifest.permission.WRITE_EXTERNAL_STORAGE
|
|
val permissionStatus = checkSelfPermission(writePermission)
|
|
if (permissionStatus == PackageManager.PERMISSION_GRANTED) {
|
|
hasWritePermission = true
|
|
} else {
|
|
requestPermissions(arrayOf(writePermission), REQ_WRITE_PERM)
|
|
}
|
|
}
|
|
|
|
override fun onRequestPermissionsResult(
|
|
requestCode: Int,
|
|
permissions: Array<out String>,
|
|
grantResults: IntArray
|
|
) {
|
|
if (requestCode == REQ_WRITE_PERM) {
|
|
if (permissions[0] == Manifest.permission.WRITE_EXTERNAL_STORAGE) {
|
|
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
|
hasWritePermission = true
|
|
}
|
|
}
|
|
}
|
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
}
|
|
|
|
private fun openCamera() {
|
|
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) {
|
|
Log.e(TAG, "No camera available.")
|
|
return
|
|
}
|
|
|
|
// Provide output URI pointing to our shareable cache folder, so it is not written to the
|
|
// user's public camera dir.
|
|
val videoUri = FileProvider.getUriForFile(
|
|
this,
|
|
"dev.lowrespalmtree.harvestdawn.fileprovider",
|
|
File.createTempFile("input", ".mp4", getShareableDir() ?: return)
|
|
)
|
|
val intent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply {
|
|
putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 1) // might wanna make 0 an option
|
|
putExtra(MediaStore.EXTRA_OUTPUT, videoUri)
|
|
}
|
|
startActivityForResult(intent, REQ_TAKE_VID)
|
|
}
|
|
|
|
private fun processVideoUri(inputVideoUri: Uri) {
|
|
// Save captured video for FFMPEG. Use MP4 extension, completely arbitrary.
|
|
val inputFile = File.createTempFile("input", ".mp4", cacheDir)
|
|
try {
|
|
contentResolver.openInputStream(inputVideoUri).use { inputStream ->
|
|
if (inputStream == null)
|
|
return Unit.also { Log.e(TAG, "Could not open input file") }
|
|
FileOutputStream(inputFile).use { fos ->
|
|
fos.buffered().write(inputStream.buffered().readBytes())
|
|
}
|
|
}
|
|
} catch (e: OutOfMemoryError) {
|
|
toast("Video is too large, Android broke!")
|
|
return
|
|
}
|
|
processVideo(inputFile.canonicalPath)
|
|
}
|
|
|
|
private fun processVideo(inputVideoPath: String) {
|
|
currentVideoPath = null
|
|
currentVideoUri = null
|
|
|
|
// Detect vertical/horizontal resolution, because at the moment we downscale everything to
|
|
// fit Telegram's media preview requirements. If FFprobe fails to returns data, we consider
|
|
// by default the video to be vertical.
|
|
val session = FFprobeKit.getMediaInformation(inputVideoPath)
|
|
var isVertical = true
|
|
var duration: Double = 0.0
|
|
if (ReturnCode.isSuccess(session.returnCode)) {
|
|
session.mediaInformation.streams.getOrNull(0)?.let { streamInformation ->
|
|
if (streamInformation.height != null && streamInformation.width != null)
|
|
isVertical = streamInformation.height > streamInformation.width
|
|
}
|
|
duration = session.mediaInformation.duration.toDouble()
|
|
}
|
|
|
|
// Save the soundtrack as well cause we can't pass a resource to FFMPEG.
|
|
// Should be done only once, then it's kept in app internal storage.
|
|
val themeId = getThemeId()
|
|
val soundFile = File(filesDir, "$themeId.m4a")
|
|
if (!soundFile.exists()) {
|
|
Log.i(TAG, "Prepare sound file with a filesystem path.")
|
|
resources.openRawResource(getThemeRawResource(themeId)).use { resource ->
|
|
FileOutputStream(soundFile).use { fos ->
|
|
fos.buffered().write(resource.buffered().readBytes())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mix both.
|
|
val outputDir = getShareableDir() ?: return
|
|
val outputFile = File.createTempFile("npc", ".mp4", outputDir)
|
|
MixTask(WeakReference(this), duration, isVertical)
|
|
.execute(inputVideoPath, soundFile.canonicalPath, outputFile.canonicalPath)
|
|
}
|
|
|
|
private fun getShareableDir(): File? {
|
|
val outputDir = File(cacheDir, "media")
|
|
if (!outputDir.exists() && !outputDir.mkdir())
|
|
return null .also { toast("Can't create output directory.") }
|
|
return outputDir
|
|
}
|
|
|
|
private fun clearVideoCache() {
|
|
val currentFileName = currentVideoPath?.let { File(it).name }
|
|
getShareableDir()?.apply {
|
|
listFiles()?.forEach { file ->
|
|
// Don't delete current file.
|
|
if (currentFileName == null || currentFileName != file.name)
|
|
file.delete()
|
|
}
|
|
}
|
|
}
|
|
|
|
private class MixTask(
|
|
private val activity: WeakReference<MainActivity>,
|
|
private val duration: Double,
|
|
private val videoIsVertical: Boolean
|
|
): AsyncTask<String, Void, Boolean>() {
|
|
private lateinit var videoPath: String
|
|
private lateinit var outputPath: String
|
|
|
|
override fun onPreExecute() {
|
|
activity.get()?.setLoading(true)
|
|
}
|
|
|
|
override fun doInBackground(vararg params: String?): Boolean {
|
|
videoPath = params[0]!!
|
|
val audioPath = params[1]!!
|
|
outputPath = params[2]!!
|
|
//val width = if (videoIsVertical) 480 else 640
|
|
val durationOpt = if (duration > 0) {
|
|
"-t ${(duration * 1000).roundToLong()}ms"
|
|
} else {
|
|
""
|
|
}
|
|
val command = (
|
|
"-i $videoPath -i $audioPath"
|
|
+ " -filter_complex amix=duration=longest"
|
|
+ " -preset ultrafast"
|
|
+ " -c:v mpeg4" // -crf 26 -vf scale=$width:trunc(ow/a/2)*2 -pix_fmt yuv420p
|
|
+ " $durationOpt -y $outputPath"
|
|
)
|
|
Log.d(TAG, "Calling FFmpeg with command: $command")
|
|
val rc = FFmpegKit.execute(command).returnCode
|
|
if (ReturnCode.isSuccess(rc)) {
|
|
Log.i(TAG, "Mix succeeded.")
|
|
return true
|
|
}
|
|
if (ReturnCode.isCancel(rc))
|
|
Log.i(TAG, "Mix cancelled.")
|
|
else
|
|
Log.e(TAG, "Mix failed! rc = $rc")
|
|
return false
|
|
}
|
|
|
|
override fun onPostExecute(result: Boolean?) {
|
|
File(videoPath).delete()
|
|
activity.get()?.let {
|
|
if (result == true) {
|
|
it.handleFfmpegOutput(outputPath)
|
|
} else {
|
|
it.toast("Crasse de brin !!!")
|
|
it.setLoading(false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun setLoading(isLoading: Boolean) {
|
|
if (isLoading) {
|
|
spinner?.visibility = View.VISIBLE
|
|
videoView?.visibility = View.INVISIBLE
|
|
} else {
|
|
spinner?.visibility = View.INVISIBLE
|
|
}
|
|
}
|
|
|
|
private fun handleFfmpegOutput(outputPath: String) {
|
|
setLoading(false)
|
|
currentVideoPath = outputPath
|
|
videoView?.let {
|
|
it.visibility = View.VISIBLE
|
|
it.setVideoPath(outputPath)
|
|
it.setOnPreparedListener { mp ->
|
|
val mediaController = MediaController(this)
|
|
mp.setOnVideoSizeChangedListener { _, _, _ ->
|
|
it.setMediaController(mediaController)
|
|
mediaController.setAnchorView(it)
|
|
}
|
|
mediaController.show(0) }
|
|
it.start()
|
|
}
|
|
}
|
|
|
|
@Suppress("DEPRECATION")
|
|
private fun save(): Boolean {
|
|
if (currentVideoPath == null)
|
|
return false
|
|
val currentFile = File(currentVideoPath!!)
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
val store = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
|
val videoDetails = ContentValues()
|
|
videoDetails.put(MediaStore.Video.Media.IS_PENDING, 1)
|
|
val uri = contentResolver.insert(store, videoDetails)
|
|
?: return false .also { toast("Could not put video to media store.") }
|
|
contentResolver.openFileDescriptor(uri, "w").use { pfd ->
|
|
FileOutputStream(pfd?.fileDescriptor).use { fos ->
|
|
FileInputStream(currentVideoPath).use { fis ->
|
|
fos.buffered().write(fis.buffered().readBytes())
|
|
}
|
|
}
|
|
}
|
|
videoDetails.clear()
|
|
videoDetails.put(MediaStore.Video.Media.IS_PENDING, 0)
|
|
contentResolver.update(uri, videoDetails, null, null)
|
|
currentVideoUri = uri
|
|
toast("Video saved to media store!")
|
|
} else {
|
|
val externalDir = File(
|
|
Environment.getExternalStorageDirectory(),
|
|
"Movies/HarvestDawn"
|
|
)
|
|
if (!externalDir.isDirectory)
|
|
externalDir.mkdirs()
|
|
val savedFile = File(externalDir, currentFile.name)
|
|
FileOutputStream(savedFile).use { fos ->
|
|
FileInputStream(currentFile).use { fis ->
|
|
fos.buffered().write(fis.buffered().readBytes())
|
|
}
|
|
}
|
|
currentVideoUri = savedFile.toUri()
|
|
toast("Video saved to external storage!")
|
|
}
|
|
return true
|
|
}
|
|
|
|
private fun share() {
|
|
if (currentVideoUri == null) {
|
|
if (currentVideoPath == null)
|
|
return
|
|
if (!save())
|
|
return
|
|
}
|
|
Log.i(TAG, "Sharing with URI $currentVideoUri")
|
|
val shareIntent = Intent().also {
|
|
it.action = Intent.ACTION_SEND
|
|
it.type = "video/mp4"
|
|
it.putExtra(Intent.EXTRA_STREAM, currentVideoUri)
|
|
}
|
|
startActivity(Intent.createChooser(shareIntent, getString(R.string.send)))
|
|
}
|
|
|
|
private fun getThemeId() =
|
|
getSharedPreferences("app", Context.MODE_PRIVATE).getInt("theme", 0)
|
|
|
|
private fun setThemeId(id: Int) =
|
|
getSharedPreferences("app", Context.MODE_PRIVATE).edit().apply {
|
|
putInt("theme", id)
|
|
apply()
|
|
}
|
|
|
|
private fun getThemeRawResource(id: Int) =
|
|
when (id) {
|
|
0 -> R.raw.harvestdawn
|
|
1 -> R.raw.sunriseofflutes
|
|
2 -> R.raw.deathknell
|
|
else -> R.raw.harvestdawn
|
|
}
|
|
|
|
private fun showThemeDialog() {
|
|
val currentId = getThemeId()
|
|
val dialog = AlertDialog.Builder(this).run {
|
|
var selection = currentId
|
|
setTitle(R.string.action_theme)
|
|
setSingleChoiceItems(R.array.pick_theme, currentId) { _, which -> selection = which }
|
|
setPositiveButton(android.R.string.ok) { dialog, _ ->
|
|
setThemeId(selection)
|
|
dialog.dismiss()
|
|
}
|
|
setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() }
|
|
create()
|
|
}
|
|
dialog.show()
|
|
}
|
|
|
|
private fun toast(text: String) {
|
|
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
|
}
|
|
|
|
companion object {
|
|
private const val TAG = "HarvestDawn"
|
|
private const val REQ_TAKE_VID = 1
|
|
private const val REQ_WRITE_PERM = 2
|
|
}
|
|
} |