From f23b0f3a94b90b38717159b145aaf244b994638e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C5=BDegklitz?= Date: Tue, 15 Mar 2022 09:17:41 +0100 Subject: [PATCH] Saving to gallery. * Added delete button to player. * Updated readme. * Bump version number. Fixes #5 --- README.md | 6 +- app/build.gradle | 4 +- .../fragments/HighSpeedCameraFragment.kt | 15 ++-- .../fragments/NormalSpeedCameraFragment.kt | 17 ++-- .../videoreferee/fragments/PlayerFragment.kt | 17 ++-- .../zegkljan/videoreferee/utils/MediaUtils.kt | 88 ++++++++++++------- .../res/drawable/ic_baseline_delete_24.xml | 5 ++ app/src/main/res/layout/fragment_player.xml | 13 +++ app/src/main/res/values-b+cs/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 10 files changed, 106 insertions(+), 61 deletions(-) create mode 100644 app/src/main/res/drawable/ic_baseline_delete_24.xml diff --git a/README.md b/README.md index 0884c66..05bcf03 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,15 @@ An extremely simple app whose only purpose is to take a (high speed) video and then lets the user to analyze it - slow it down, step one frame at a time. The use case is mainly as a poor-man's eagle eye, i.e. for video review of sport situations, especially HEMA (Historical European Martial Arts) fencing actions. -The app does not save the video (or, more precisely, once the user is done with the analysis and returns to the viewfinder, the video file is deleted). - ## Workflow The app has the following workflow: -0. Lanuch the app. +0. Launch the app. 1. Ask for permissions (if not already granted). 2. Select capture resolution and frame rate. 3. Capture video. 4. Seek through, slow down... -5. Tap the ✓ button to go back to step 3, or press the native back button to go to step 2. +5. Tap the ✓ button to go back to step 3 (or the bin button to delete the video and then go to step 3), or press the native back button to go to step 2. ## Download and installation Scan this QR code to download the APK: diff --git a/app/build.gradle b/app/build.gradle index 8e86892..b8ed400 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -36,8 +36,8 @@ android { compileSdkVersion 31 defaultConfig { applicationId "cz.zegkljan.videoreferee" - versionCode 3 - versionName "0.3.0" + versionCode 4 + versionName "0.4.0" minSdkVersion 24 targetSdkVersion 31 testInstrumentationRunner "android.test.InstrumentationTestRunner" diff --git a/app/src/main/java/cz/zegkljan/videoreferee/fragments/HighSpeedCameraFragment.kt b/app/src/main/java/cz/zegkljan/videoreferee/fragments/HighSpeedCameraFragment.kt index e6ff97c..2a81574 100644 --- a/app/src/main/java/cz/zegkljan/videoreferee/fragments/HighSpeedCameraFragment.kt +++ b/app/src/main/java/cz/zegkljan/videoreferee/fragments/HighSpeedCameraFragment.kt @@ -38,10 +38,9 @@ import androidx.navigation.Navigation import androidx.navigation.fragment.navArgs import cz.zegkljan.videoreferee.R import cz.zegkljan.videoreferee.databinding.FragmentCameraBinding -import cz.zegkljan.videoreferee.utils.MediaItem +import cz.zegkljan.videoreferee.utils.Medium import cz.zegkljan.videoreferee.utils.OrientationLiveData import cz.zegkljan.videoreferee.utils.createDummyFile -import cz.zegkljan.videoreferee.utils.prepareMediaItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -112,7 +111,7 @@ class HighSpeedCameraFragment : Fragment() { /** The [CameraDevice] that will be opened in this fragment */ private lateinit var camera: CameraDevice - private var mediaItem: MediaItem? = null + private var medium: Medium? = null /** Requests used for preview only in the [CameraConstrainedHighSpeedCaptureSession] */ private val previewRequestList: List by lazy { @@ -256,8 +255,8 @@ class HighSpeedCameraFragment : Fragment() { // Sets the output file val ctx = requireContext() - mediaItem = prepareMediaItem(ctx, "mp4") - setOutputFile(mediaItem!!.getWriteFileDescriptor(ctx)) + medium = Medium.create(ctx, "mp4") + setOutputFile(medium!!.getWriteFileDescriptor(ctx)) prepare() start() @@ -280,8 +279,8 @@ class HighSpeedCameraFragment : Fragment() { recorder.stop() // Finalize output file - mediaItem!!.closeFileDescriptor() - mediaItem!!.finalize(requireContext()) + medium!!.closeFileDescriptor() + medium!!.finalize(requireContext()) // Unlocks screen rotation after recording finished requireActivity().requestedOrientation = @@ -291,7 +290,7 @@ class HighSpeedCameraFragment : Fragment() { requireActivity().runOnUiThread { navController.navigate( HighSpeedCameraFragmentDirections.actionHighSpeedCameraToPlayer( - mediaItem!!.getUriString(), + medium!!.getUriString(), args.cameraId, args.width, args.height, diff --git a/app/src/main/java/cz/zegkljan/videoreferee/fragments/NormalSpeedCameraFragment.kt b/app/src/main/java/cz/zegkljan/videoreferee/fragments/NormalSpeedCameraFragment.kt index 2e9f0e0..445899d 100644 --- a/app/src/main/java/cz/zegkljan/videoreferee/fragments/NormalSpeedCameraFragment.kt +++ b/app/src/main/java/cz/zegkljan/videoreferee/fragments/NormalSpeedCameraFragment.kt @@ -37,10 +37,9 @@ import androidx.navigation.Navigation import androidx.navigation.fragment.navArgs import cz.zegkljan.videoreferee.R import cz.zegkljan.videoreferee.databinding.FragmentCameraBinding -import cz.zegkljan.videoreferee.utils.MediaItem +import cz.zegkljan.videoreferee.utils.Medium import cz.zegkljan.videoreferee.utils.OrientationLiveData import cz.zegkljan.videoreferee.utils.createDummyFile -import cz.zegkljan.videoreferee.utils.prepareMediaItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -111,7 +110,7 @@ class NormalSpeedCameraFragment : Fragment() { /** The [CameraDevice] that will be opened in this fragment */ private lateinit var camera: CameraDevice - private var mediaItem: MediaItem? = null + private var medium: Medium? = null /** Request used for preview only in the [CameraCaptureSession] */ private val previewRequest: CaptureRequest by lazy { @@ -236,9 +235,9 @@ class NormalSpeedCameraFragment : Fragment() { relativeOrientation.value?.let { setOrientationHint(it) } // Sets the output file val ctx = requireContext() - mediaItem = prepareMediaItem(ctx, "mp4") - Log.d(TAG, mediaItem.toString()) - setOutputFile(mediaItem!!.getWriteFileDescriptor(ctx)) + medium = Medium.create(ctx, "mp4") + Log.d(TAG, medium.toString()) + setOutputFile(medium!!.getWriteFileDescriptor(ctx)) prepare() start() @@ -261,8 +260,8 @@ class NormalSpeedCameraFragment : Fragment() { recorder.stop() // Finalize output file - mediaItem!!.closeFileDescriptor() - mediaItem!!.finalize(requireContext()) + medium!!.closeFileDescriptor() + medium!!.finalize(requireContext()) // Unlocks screen rotation after recording finished requireActivity().requestedOrientation = @@ -272,7 +271,7 @@ class NormalSpeedCameraFragment : Fragment() { requireActivity().runOnUiThread { navController.navigate( NormalSpeedCameraFragmentDirections.actionNormalSpeedCameraToPlayer( - mediaItem!!.getUriString(), + medium!!.getUriString(), args.cameraId, args.width, args.height, diff --git a/app/src/main/java/cz/zegkljan/videoreferee/fragments/PlayerFragment.kt b/app/src/main/java/cz/zegkljan/videoreferee/fragments/PlayerFragment.kt index 61d10a7..f23dff0 100644 --- a/app/src/main/java/cz/zegkljan/videoreferee/fragments/PlayerFragment.kt +++ b/app/src/main/java/cz/zegkljan/videoreferee/fragments/PlayerFragment.kt @@ -34,6 +34,7 @@ import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.SimpleExoPlayer import cz.zegkljan.videoreferee.R import cz.zegkljan.videoreferee.databinding.FragmentPlayerBinding +import cz.zegkljan.videoreferee.utils.Medium import kotlin.math.roundToInt class PlayerFragment : Fragment() { @@ -167,12 +168,18 @@ class PlayerFragment : Fragment() { // navigation out fragmentPlayerBinding.doneButton.setOnClickListener { - /* - val file = File(args.filename) - if (!file.delete()) { - // Log.e(TAG, "Failed to delete file $file") + val navDirections: NavDirections = if (args.isHighSpeed) { + PlayerFragmentDirections.actionPlayerToHighSpeedCamera(args.cameraId, args.width, args.height, args.fps) + } else { + PlayerFragmentDirections.actionPlayerToNormalSpeedCamera(args.cameraId, args.width, args.height, args.fps) + } + navController.navigate(navDirections) + } + fragmentPlayerBinding.deleteButton.setOnClickListener { + val medium = Medium.fromUri(Uri.parse(args.fileuri)) + if (!medium.remove(requireContext())) { + // Log.e(TAG, "Failed to delete file $medium") } - */ val navDirections: NavDirections = if (args.isHighSpeed) { PlayerFragmentDirections.actionPlayerToHighSpeedCamera(args.cameraId, args.width, args.height, args.fps) } else { diff --git a/app/src/main/java/cz/zegkljan/videoreferee/utils/MediaUtils.kt b/app/src/main/java/cz/zegkljan/videoreferee/utils/MediaUtils.kt index f597186..964424b 100644 --- a/app/src/main/java/cz/zegkljan/videoreferee/utils/MediaUtils.kt +++ b/app/src/main/java/cz/zegkljan/videoreferee/utils/MediaUtils.kt @@ -18,6 +18,7 @@ package cz.zegkljan.videoreferee.utils import android.annotation.SuppressLint +import android.content.ContentUris import android.content.ContentValues import android.content.Context import android.media.MediaScannerConnection @@ -27,6 +28,7 @@ import android.os.Environment import android.os.ParcelFileDescriptor import android.provider.MediaStore import android.util.Log +import androidx.core.net.toFile import androidx.core.net.toUri import java.io.File import java.io.FileDescriptor @@ -42,14 +44,49 @@ fun createDummyFile(context: Context): File { return File(context.filesDir, "dummyfile") } -abstract class MediaItem { +abstract class Medium { abstract fun getUriString(): String abstract fun getWriteFileDescriptor(context: Context): FileDescriptor abstract fun closeFileDescriptor() open fun finalize(context: Context) = Unit + abstract fun remove(context: Context): Boolean + + companion object { + /** Creates a [Medium] named with the current date and time */ + fun create(context: Context, extension: String): Medium { + // Log.d(TAG, "createFile") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = context.contentResolver + val videoCollection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val videoDetails = ContentValues().apply { + put(MediaStore.Video.Media.DISPLAY_NAME, "VID_${SDF.format(Date())}.$extension") + put(MediaStore.MediaColumns.RELATIVE_PATH, "DCIM/VideoReferee/") + put(MediaStore.Video.Media.IS_PENDING, 1) + } + val videoUri = resolver.insert(videoCollection, videoDetails) + Log.d(TAG, videoUri.toString()) + + return MediaStoreMedium(videoUri!!) + } else { + val externalFilesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM) + val videoRefereeDir = File(externalFilesDir, "VideoReferee") + videoRefereeDir.mkdirs() + val file = File(videoRefereeDir, "VID_${SDF.format(Date())}.$extension") + return FileMedium(file) + } + } + + /** Creates a [Medium] from the given [Uri] */ + fun fromUri(uri: Uri): Medium = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStoreMedium(uri) + } else { + FileMedium(uri.toFile()) + } + } } -private class MediaStoreItem(val uri: Uri) : MediaItem() { +private class MediaStoreMedium(val uri: Uri) : Medium() { var fd: ParcelFileDescriptor? = null override fun getUriString(): String { @@ -76,12 +113,21 @@ private class MediaStoreItem(val uri: Uri) : MediaItem() { }, null, null) } + @SuppressLint("InlinedApi") + override fun remove(context: Context): Boolean { + val resolver = context.contentResolver + + val videoCollection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + + return 0 < resolver.delete(videoCollection, "${MediaStore.Audio.Media._ID} = ?", arrayOf(ContentUris.parseId(uri).toString())) + } + override fun toString(): String { return "MediaStoreItem($uri)" } } -private class FileItem(val file: File) : MediaItem() { +private class FileMedium(val file: File) : Medium() { var fis: FileOutputStream? = null override fun getUriString(): String { @@ -99,41 +145,17 @@ private class FileItem(val file: File) : MediaItem() { } fis!!.close() } - - override fun toString(): String { - return "FileItem($file)" + override fun remove(context: Context): Boolean { + val deleted = file.delete() + MediaScannerConnection.scanFile(context, arrayOf(file.absolutePath), arrayOfNulls(1), null) + return deleted } override fun finalize(context: Context) { MediaScannerConnection.scanFile(context, arrayOf(file.absolutePath), arrayOfNulls(1), null) } -} - -/** Creates a media [Uri] named with the current date and time */ -fun prepareMediaItem(context: Context, extension: String): MediaItem { - // Log.d(TAG, "createFile") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val resolver = context.contentResolver - val videoCollection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - } else { - MediaStore.Video.Media.EXTERNAL_CONTENT_URI - } - val videoDetails = ContentValues().apply { - put(MediaStore.Video.Media.DISPLAY_NAME, "VID_${SDF.format(Date())}.$extension") - put(MediaStore.MediaColumns.RELATIVE_PATH, "DCIM/VideoReferee/") - put(MediaStore.Video.Media.IS_PENDING, 1) - } - val videoUri = resolver.insert(videoCollection, videoDetails) - Log.d(TAG, videoUri.toString()) - - return MediaStoreItem(videoUri!!) - } else { - val externalFilesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM) - val videoRefereeDir = File(externalFilesDir, "VideoReferee") - videoRefereeDir.mkdirs() - val file = File(videoRefereeDir, "VID_${SDF.format(Date())}.$extension") - return FileItem(file) + override fun toString(): String { + return "FileItem($file)" } } diff --git a/app/src/main/res/drawable/ic_baseline_delete_24.xml b/app/src/main/res/drawable/ic_baseline_delete_24.xml new file mode 100644 index 0000000..282594c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_delete_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/fragment_player.xml b/app/src/main/res/layout/fragment_player.xml index 0911e10..9e0b44a 100644 --- a/app/src/main/res/layout/fragment_player.xml +++ b/app/src/main/res/layout/fragment_player.xml @@ -39,6 +39,19 @@ app:layout_constraintRight_toRightOf="parent" tools:ignore="RtlHardcoded" /> +