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