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

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