diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef3..4d1c7bc 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,14 @@ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..103e00c --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,32 @@ + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1196820..a575e3c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,8 +29,8 @@ android { applicationId = "com.prime.player" minSdk = 21 targetSdk = 34 - versionCode = 87 - versionName = "2.10.1" + versionCode = 88 + versionName = "2.11.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } //Load secrets into BuildConfig @@ -118,6 +118,7 @@ dependencies { implementation(libs.media3.ui) implementation(libs.mp3agic) implementation(libs.play.feature.delivery) + implementation(libs.coil.video) //TODO - Updating dependencies caused the app not to compile becasue of some issue with // internal below dependency and hence this. Remove this in next update. implementation("com.google.j2objc:j2objc-annotations:3.0.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f0f7591..a366ac7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/app/src/main/java/com/prime/media/Audiofy.kt b/app/src/main/java/com/prime/media/Audiofy.kt index 114c442..c25942e 100644 --- a/app/src/main/java/com/prime/media/Audiofy.kt +++ b/app/src/main/java/com/prime/media/Audiofy.kt @@ -7,6 +7,7 @@ import android.os.Build import android.provider.MediaStore import coil.ImageLoader import coil.ImageLoaderFactory +import coil.decode.VideoFrameDecoder import coil.disk.DiskCache import coil.fetch.Fetcher import coil.fetch.Fetcher.Factory @@ -50,12 +51,6 @@ class Audiofy : Application(), ImageLoaderFactory { * https://developers.google.com/android/reference/com/google/android/gms/common/GooglePlayServicesUtil.html#GOOGLE_PLAY_STORE_PACKAGE */ const val PKG_GOOGLE_PLAY_STORE = "com.android.vending" - - val STORAGE_PERMISSION = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - android.Manifest.permission.READ_MEDIA_AUDIO - else - android.Manifest.permission.WRITE_EXTERNAL_STORAGE } /** @@ -96,11 +91,13 @@ class Audiofy : Application(), ImageLoaderFactory { } } - override fun newImageLoader(): ImageLoader { return ImageLoader.Builder(this) // Add the MediaMetaDataArtFactory - .components { add(MediaMetaDataArtFactory()) } + .components { + add(MediaMetaDataArtFactory()) + add(VideoFrameDecoder.Factory()) + } .memoryCache { MemoryCache.Builder(this) .strongReferencesEnabled(true) diff --git a/app/src/main/java/com/prime/media/Home.kt b/app/src/main/java/com/prime/media/Home.kt index dbe4a5c..210d919 100644 --- a/app/src/main/java/com/prime/media/Home.kt +++ b/app/src/main/java/com/prime/media/Home.kt @@ -5,9 +5,8 @@ package com.prime.media import android.app.Activity import android.content.Intent import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween @@ -85,7 +84,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.dialog import androidx.navigation.compose.rememberNavController import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.firebase.Firebase import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.analytics @@ -130,8 +129,10 @@ import com.prime.media.impl.ConsoleViewModel import com.prime.media.impl.LibraryViewModel import com.prime.media.impl.SettingsViewModel import com.prime.media.impl.TagEditorViewModel +import com.prime.media.impl.VideosViewModel import com.prime.media.library.Library import com.prime.media.settings.Settings +import com.prime.media.videos.Videos import com.primex.core.Amber import com.primex.core.BlueLilac import com.primex.core.DahliaYellow @@ -280,6 +281,15 @@ private val ExitTransition = fadeOut(tween(700)) */ private const val PERMISSION_ROUTE = "_route_storage_permission" +private val STORAGE_PERMISSIONS = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + listOf( + android.Manifest.permission.READ_MEDIA_AUDIO, + android.Manifest.permission.READ_MEDIA_VIDEO + ) + else + listOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + /** * The permission screen. */ @@ -290,8 +300,8 @@ private fun Permission() { // Compose the permission state. // Once granted set different route like folders as start. // Navigate from here to there. - val permission = rememberPermissionState(permission = Audiofy.STORAGE_PERMISSION) { - if (!it) return@rememberPermissionState + val permission = rememberMultiplePermissionsState(permissions = STORAGE_PERMISSIONS) { + if (!it.all { it.value }) return@rememberMultiplePermissionsState controller.graph.setStartDestination(Library.route) controller.navigate(Library.route) { popUpTo(PERMISSION_ROUTE) { inclusive = true } } } @@ -302,7 +312,7 @@ private fun Permission() { vertical = LocalWindowSize.current.widthRange == Range.Compact ) { OutlinedButton( - onClick = { permission.launchPermissionRequest() }, + onClick = { permission.launchMultiplePermissionRequest() }, modifier = Modifier.size(width = 200.dp, height = 46.dp), elevation = null, label = stringResource(R.string.allow), @@ -408,8 +418,10 @@ private fun NavGraph( val context = LocalContext.current // Load start destination based on if storage permission is set or not. val startDestination = - when (ContextCompat.checkSelfPermission(context, Audiofy.STORAGE_PERMISSION)) { - PackageManager.PERMISSION_GRANTED -> Library.route + when (STORAGE_PERMISSIONS.all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + }) { + true -> Library.route else -> PERMISSION_ROUTE } // In order to navigate and remove the need to pass controller below UI components. @@ -482,6 +494,11 @@ private fun NavGraph( val viewModel = hiltViewModel() Console(state = viewModel) } + + composable(Videos.route) { + val viewModel = hiltViewModel() + Videos(state = viewModel) + } }, ) } @@ -521,10 +538,10 @@ private fun NavController.toRoute(route: String) { private const val MIME_TYPE_VIDEO = "video/*" -// The different NavTypes shown in differnrt screen sizes. -private val TYPE_RAIL_NAV = 0 -private val TYPE_DRAWER_NAV = 1 -private val TYPE_BOTTOM_NAV = 2 +// The different NavTypes shown in different screen sizes. +private const val TYPE_RAIL_NAV = 0 +private const val TYPE_DRAWER_NAV = 1 +private const val TYPE_BOTTOM_NAV = 2 /** * return the navigation type based on the window size. @@ -533,7 +550,8 @@ private inline val WindowSize.navType get() = when { widthRange < Range.Medium -> TYPE_BOTTOM_NAV widthRange < Range.xLarge -> TYPE_RAIL_NAV - else -> TYPE_DRAWER_NAV + // FixME - For now return only rail as drawer looks pretty bad. + else -> TYPE_RAIL_NAV //TYPE_DRAWER_NAV } private val NAV_RAIL_WIDTH = 96.dp @@ -660,21 +678,22 @@ private fun NavBar( ) // Videos - val context = LocalContext.current as MainActivity - val launcher = - rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { - if (it == null) return@rememberLauncherForActivityResult - val intnet = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(it, MIME_TYPE_VIDEO) - this.`package` = context.packageName - } - context.startActivity(intnet) - } +// val context = LocalContext.current as MainActivity +// val launcher = +// rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { +// if (it == null) return@rememberLauncherForActivityResult +// val intnet = Intent(Intent.ACTION_VIEW).apply { +// setDataAndType(it, MIME_TYPE_VIDEO) +// this.`package` = context.packageName +// } +// context.startActivity(intnet) +// } +// launcher.launch(arrayOf(MIME_TYPE_VIDEO)) NavigationItem( label = textResource(id = R.string.videos), icon = Icons.Outlined.VideoLibrary, - checked = false, - onClick = { launcher.launch(arrayOf(MIME_TYPE_VIDEO)) }, + checked = current?.destination?.route == Videos.route, + onClick = { navController.navigate(Videos.direction()) }, type = type, colors = colors ) @@ -834,13 +853,13 @@ fun Home(channel: Channel) { val navDestChangeListener = { _: NavController, destination: NavDestination, _: Bundle? -> // create params for the event. - val params = Bundle().apply { - putString(FirebaseAnalytics.Param.SCREEN_NAME, destination.route as String?) - //putString(FirebaseAnalytics.Param.SCREEN_CLASS, destination.label as String?) + val params = Bundle().apply { + putString(FirebaseAnalytics.Param.SCREEN_NAME, destination.route as String?) + //putString(FirebaseAnalytics.Param.SCREEN_CLASS, destination.label as String?) + } + // Log the event. + firebase.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW, params) } - // Log the event. - firebase.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW, params) - } // Register the intent listener with the activity. activity.addOnNewIntentListener(listener) navController.addOnDestinationChangedListener(navDestChangeListener) diff --git a/app/src/main/java/com/prime/media/core/db/ContentResolver.kt b/app/src/main/java/com/prime/media/core/db/ContentResolver.kt index 0e9a07a..087328c 100644 --- a/app/src/main/java/com/prime/media/core/db/ContentResolver.kt +++ b/app/src/main/java/com/prime/media/core/db/ContentResolver.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.map import java.io.File import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +import kotlin.math.absoluteValue import kotlinx.coroutines.withContext as using @@ -362,7 +363,6 @@ value class Folder( val Folder.name: String get() = PathUtils.name(path) - suspend fun ContentResolver.getFolders( filter: String? = null, ascending: Boolean = true, offset: Int = 0, limit: Int = Int.MAX_VALUE ): List { @@ -945,4 +945,113 @@ fun ContentResolver.trash(activity: Activity, vararg uri: Uri): Int { val deleteRequest = MediaStore.createTrashRequest(this, uri.toList(), true).intentSender activity.startIntentSenderForResult(deleteRequest, 100, null, 0, 0, 0) return -2 // dialog is about to be shown. -} \ No newline at end of file +} + +/** + * Represents a video file. + * + * @param id The unique ID of the video in the MediaStore. + * @param name The name of the video file. + * @param mimeType The MIME type of the video file. + * @param path The path to the video file. + * @param dateAdded The date the video was added to the MediaStore. + * @param dateModified The date the video was last modified. + * @param size The size of the video file in bytes. + * @param dateTaken The date the video was taken. + * @param orientation The orientation of the video file. + * @param height The height of the video file in pixels. + * @param width The width of the video file in pixels. + */ +@Stable +data class Video( + @JvmField val id: Long, + @JvmField val name: String, + @JvmField val mimeType: String, + @JvmField val path: String, + @JvmField val dateAdded: Long, + @JvmField val dateModified: Long, + @JvmField val size: Long, + @JvmField val dateTaken: Long, + @JvmField val orientation: Int, + @JvmField val height: Int, + @JvmField val width: Int, + @JvmField val duration: Long, +) + +/** + * Constructs a [Video] object from the current position of the cursor. + * + * The cursor must be positioned on a row that represents a video in the MediaStore. + * + * @return A [Video] object representing the video at the current cursor position. + */ +private val Cursor.toVideo: Video + inline get() { + return Video( + id = getLong(0), + name = getString(1) ?: MediaStore.UNKNOWN_STRING, + dateAdded = getLong(2) * 1000, + dateModified = getLong(3) * 1000, + size = getLong(4).absoluteValue, + mimeType = getString(5) ?: "video/*", + orientation = getInt(6), + height = getInt(7), + width = getInt(8), + path = getString(9), + dateTaken = getLong(10) * 1000, + duration = getLong(11) + ) + } + +private val VIDEO_PROJECTION = arrayOf( + MediaStore.Video.Media._ID, // 0 + MediaStore.Video.Media.TITLE, // 1 + MediaStore.Video.Media.DATE_ADDED, // 2 + MediaStore.Video.Media.DATE_MODIFIED, // 3 + MediaStore.Video.Media.SIZE, // 4 + MediaStore.Video.Media.MIME_TYPE, // 5 + MediaStore.Video.Media.ORIENTATION, // 6 + MediaStore.Video.Media.HEIGHT,// 7 + MediaStore.Video.Media.WIDTH, // 8 + MediaStore.Video.Media.DATA, // 9 + MediaStore.Video.Media.DATE_TAKEN, // 10 + MediaStore.Video.Media.DURATION, // 11 +) + +/** + * Queries the MediaStore for videos. + * + * @param filter A SQL-like filter to apply to the query. + * @param order The sort order to apply to the query. + * @param ascending Whether to sort in ascending order. + * @param offset The offset of the first item to return. + * @param limit The maximum number of items to return. + * + * @return A list of [Video] objects representing the videos found. + */ +suspend fun ContentResolver.getVideos( + filter: String? = null, + order: String = MediaStore.Video.DEFAULT_SORT_ORDER, + ascending: Boolean = true, + offset: Int = 0, + limit: Int = Int.MAX_VALUE +): List