diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b02650a07..987ef7aba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -53,8 +53,9 @@ dependencies { implementation(project(":core:common")) implementation(project(":core:data")) - implementation(project(":core:ui")) + implementation(project(":core:media")) implementation(project(":core:model")) + implementation(project(":core:ui")) implementation(project(":feature:videopicker")) implementation(project(":feature:player")) implementation(project(":feature:settings")) diff --git a/app/src/main/java/dev/anilbeesetti/nextplayer/MainActivity.kt b/app/src/main/java/dev/anilbeesetti/nextplayer/MainActivity.kt index d2ab7a024..589f8cf9b 100644 --- a/app/src/main/java/dev/anilbeesetti/nextplayer/MainActivity.kt +++ b/app/src/main/java/dev/anilbeesetti/nextplayer/MainActivity.kt @@ -30,7 +30,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import dagger.hilt.android.AndroidEntryPoint -import dev.anilbeesetti.nextplayer.core.data.MediaSynchronizer +import dev.anilbeesetti.nextplayer.core.media.sync.MediaSynchronizer import dev.anilbeesetti.nextplayer.core.model.ThemeConfig import dev.anilbeesetti.nextplayer.core.ui.theme.NextPlayerTheme import dev.anilbeesetti.nextplayer.navigation.settingsNavGraph @@ -93,7 +93,7 @@ class MainActivity : ComponentActivity() { LaunchedEffect(key1 = storagePermissionState.status.isGranted) { if (storagePermissionState.status.isGranted) { - synchronizer.sync() + synchronizer.startSync() } } diff --git a/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/MediaSynchronizer.kt b/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/MediaSynchronizer.kt deleted file mode 100644 index 018fe5641..000000000 --- a/core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/MediaSynchronizer.kt +++ /dev/null @@ -1,98 +0,0 @@ -package dev.anilbeesetti.nextplayer.core.data - -import dev.anilbeesetti.nextplayer.core.common.di.ApplicationScope -import dev.anilbeesetti.nextplayer.core.common.extensions.prettyName -import dev.anilbeesetti.nextplayer.core.database.dao.DirectoryDao -import dev.anilbeesetti.nextplayer.core.database.dao.MediumDao -import dev.anilbeesetti.nextplayer.core.database.entities.DirectoryEntity -import dev.anilbeesetti.nextplayer.core.database.entities.MediumEntity -import dev.anilbeesetti.nextplayer.core.media.mediasource.MediaSource -import dev.anilbeesetti.nextplayer.core.media.model.MediaVideo -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import timber.log.Timber - -@Singleton -class MediaSynchronizer @Inject constructor( - private val mediumDao: MediumDao, - private val directoryDao: DirectoryDao, - private val mediaSource: MediaSource, - @ApplicationScope private val applicationScope: CoroutineScope -) { - - private var mediaSyncingJob: Job? = null - - fun sync(scope: CoroutineScope = applicationScope) { - if (mediaSyncingJob != null) return - mediaSyncingJob = mediaSource.getMediaVideosFlow().onEach { media -> - Timber.d("Syncing ${media.size} media ${this.hashCode()}") - scope.launch { updateDirectories(media) } - scope.launch { updateMedia(media) } - }.launchIn(scope) - } - - private suspend fun updateDirectories(media: List) = withContext( - Dispatchers.Default - ) { - val directories = media.groupBy { File(it.data).parentFile!! }.map { (file, videos) -> - DirectoryEntity( - path = file.path, - name = file.prettyName, - mediaCount = videos.size, - size = videos.sumOf { it.size }, - modified = file.lastModified() - ) - } - directoryDao.upsertAll(directories) - - val currentDirectoryPaths = directories.map { it.path } - - val unwantedDirectories = directoryDao.getAll().first() - .map { it.path } - .filterNot { it in currentDirectoryPaths } - - directoryDao.delete(unwantedDirectories) - } - - private suspend fun updateMedia(media: List) = withContext(Dispatchers.Default) { - val mediumEntities = media.map { - val file = File(it.data) - val mediumEntity = mediumDao.get(it.data) - MediumEntity( - path = it.data, - uriString = it.uri.toString(), - name = file.name, - parentPath = file.parent!!, - modified = it.dateModified, - size = it.size, - width = it.width, - height = it.height, - duration = it.duration, - mediaStoreId = it.id, - playbackPosition = mediumEntity?.playbackPosition ?: 0, - audioTrackIndex = mediumEntity?.audioTrackIndex, - subtitleTrackIndex = mediumEntity?.subtitleTrackIndex, - playbackSpeed = mediumEntity?.playbackSpeed - ) - } - - mediumDao.upsertAll(mediumEntities) - - val currentMediaPaths = mediumEntities.map { it.path } - - val unwantedMedia = mediumDao.getAll().first() - .map { it.path } - .filterNot { it in currentMediaPaths } - - mediumDao.delete(unwantedMedia) - } -} diff --git a/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/MediaModule.kt b/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/MediaModule.kt new file mode 100644 index 000000000..b5fd29a22 --- /dev/null +++ b/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/MediaModule.kt @@ -0,0 +1,20 @@ +package dev.anilbeesetti.nextplayer.core.media + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.anilbeesetti.nextplayer.core.media.sync.LocalMediaSynchronizer +import dev.anilbeesetti.nextplayer.core.media.sync.MediaSynchronizer +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface MediaModule { + + @Binds + @Singleton + fun bindsMediaSynchronizer( + mediaSynchronizer: LocalMediaSynchronizer + ): MediaSynchronizer +} diff --git a/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/MediaSourceModule.kt b/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/MediaSourceModule.kt deleted file mode 100644 index 4a2b1a3b8..000000000 --- a/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/MediaSourceModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.anilbeesetti.nextplayer.core.media - -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import dev.anilbeesetti.nextplayer.core.media.mediasource.LocalMediaSource -import dev.anilbeesetti.nextplayer.core.media.mediasource.MediaSource - -@Module -@InstallIn(SingletonComponent::class) -interface MediaSourceModule { - - @Binds - fun bindsMediaSource( - mediaSource: LocalMediaSource - ): MediaSource -} diff --git a/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/mediasource/LocalMediaSource.kt b/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/mediasource/LocalMediaSource.kt deleted file mode 100644 index b99ee209a..000000000 --- a/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/mediasource/LocalMediaSource.kt +++ /dev/null @@ -1,93 +0,0 @@ -package dev.anilbeesetti.nextplayer.core.media.mediasource - -import android.content.ContentUris -import android.content.Context -import android.database.ContentObserver -import android.database.Cursor -import android.provider.MediaStore -import dagger.hilt.android.qualifiers.ApplicationContext -import dev.anilbeesetti.nextplayer.core.common.Dispatcher -import dev.anilbeesetti.nextplayer.core.common.NextDispatchers -import dev.anilbeesetti.nextplayer.core.common.extensions.VIDEO_COLLECTION_URI -import dev.anilbeesetti.nextplayer.core.media.model.MediaVideo -import java.io.File -import javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOn - -class LocalMediaSource @Inject constructor( - @ApplicationContext private val context: Context, - @Dispatcher(NextDispatchers.IO) private val dispatcher: CoroutineDispatcher -) : MediaSource { - - override fun getMediaVideosFlow( - selection: String?, - selectionArgs: Array?, - sortOrder: String? - ): Flow> = callbackFlow { - val observer = object : ContentObserver(null) { - override fun onChange(selfChange: Boolean) { - trySend(getMediaVideo(selection, selectionArgs, sortOrder)) - } - } - context.contentResolver.registerContentObserver(VIDEO_COLLECTION_URI, true, observer) - // initial value - trySend(getMediaVideo(selection, selectionArgs, sortOrder)) - // close - awaitClose { context.contentResolver.unregisterContentObserver(observer) } - }.flowOn(dispatcher).distinctUntilChanged() - - override fun getMediaVideo( - selection: String?, - selectionArgs: Array?, - sortOrder: String? - ): List { - val mediaVideos = mutableListOf() - context.contentResolver.query( - VIDEO_COLLECTION_URI, - VIDEO_PROJECTION, - selection, - selectionArgs, - sortOrder - )?.use { cursor -> - while (cursor.moveToNext()) { - mediaVideos.add(cursor.toMediaVideo) - } - } - return mediaVideos.filter { File(it.data).exists() } - } -} - -private val VIDEO_PROJECTION - get() = arrayOf( - MediaStore.Video.Media._ID, - MediaStore.Video.Media.DATA, - MediaStore.Video.Media.DURATION, - MediaStore.Video.Media.HEIGHT, - MediaStore.Video.Media.WIDTH, - MediaStore.Video.Media.SIZE, - MediaStore.Video.Media.DATE_MODIFIED - ) - -/** - * convert cursor to video item - * @see MediaVideo - */ -private inline val Cursor.toMediaVideo: MediaVideo - get() { - val id = getLong(this.getColumnIndexOrThrow(MediaStore.Video.Media._ID)) - return MediaVideo( - id = id, - data = getString(this.getColumnIndexOrThrow(MediaStore.Video.Media.DATA)), - duration = getLong(this.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)), - uri = ContentUris.withAppendedId(VIDEO_COLLECTION_URI, id), - width = getInt(this.getColumnIndexOrThrow(MediaStore.Video.Media.WIDTH)), - height = getInt(this.getColumnIndexOrThrow(MediaStore.Video.Media.HEIGHT)), - size = getLong(this.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE)), - dateModified = getLong(this.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_MODIFIED)) - ) - } diff --git a/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/mediasource/MediaSource.kt b/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/mediasource/MediaSource.kt deleted file mode 100644 index 985f6cca2..000000000 --- a/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/mediasource/MediaSource.kt +++ /dev/null @@ -1,32 +0,0 @@ -package dev.anilbeesetti.nextplayer.core.media.mediasource - -import android.provider.MediaStore -import dev.anilbeesetti.nextplayer.core.media.model.MediaVideo -import kotlinx.coroutines.flow.Flow - -interface MediaSource { - - /** - * Get list of [MediaVideo]s as flow - * @param selection selection of the query - * @param selectionArgs selection arguments of the query - * @param sortOrder sort order of the query - * @return flow of list of [MediaVideo] - * @see [android.content.ContentResolver.query] - */ - fun getMediaVideosFlow( - selection: String? = null, - selectionArgs: Array? = null, - sortOrder: String? = "${MediaStore.Video.Media.DISPLAY_NAME} ASC" - ): Flow> - - /** - * Get list of [MediaVideo]s - * @return list of video items - */ - fun getMediaVideo( - selection: String? = null, - selectionArgs: Array? = null, - sortOrder: String? = null - ): List -} diff --git a/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/sync/LocalMediaSynchronizer.kt b/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/sync/LocalMediaSynchronizer.kt new file mode 100644 index 000000000..2528a6657 --- /dev/null +++ b/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/sync/LocalMediaSynchronizer.kt @@ -0,0 +1,182 @@ +package dev.anilbeesetti.nextplayer.core.media.sync + +import android.content.ContentUris +import android.content.Context +import android.database.ContentObserver +import android.provider.MediaStore +import dagger.hilt.android.qualifiers.ApplicationContext +import dev.anilbeesetti.nextplayer.core.common.Dispatcher +import dev.anilbeesetti.nextplayer.core.common.NextDispatchers +import dev.anilbeesetti.nextplayer.core.common.di.ApplicationScope +import dev.anilbeesetti.nextplayer.core.common.extensions.VIDEO_COLLECTION_URI +import dev.anilbeesetti.nextplayer.core.common.extensions.prettyName +import dev.anilbeesetti.nextplayer.core.database.dao.DirectoryDao +import dev.anilbeesetti.nextplayer.core.database.dao.MediumDao +import dev.anilbeesetti.nextplayer.core.database.entities.DirectoryEntity +import dev.anilbeesetti.nextplayer.core.database.entities.MediumEntity +import dev.anilbeesetti.nextplayer.core.media.model.MediaVideo +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class LocalMediaSynchronizer @Inject constructor( + private val mediumDao: MediumDao, + private val directoryDao: DirectoryDao, + @ApplicationScope private val applicationScope: CoroutineScope, + @ApplicationContext private val context: Context, + @Dispatcher(NextDispatchers.IO) private val dispatcher: CoroutineDispatcher +) : MediaSynchronizer { + + private var mediaSyncingJob: Job? = null + + override fun startSync() { + if (mediaSyncingJob != null) return + mediaSyncingJob = getMediaVideosFlow().onEach { media -> + applicationScope.launch { updateDirectories(media) } + applicationScope.launch { updateMedia(media) } + }.launchIn(applicationScope) + } + + override fun stopSync() { + mediaSyncingJob?.cancel() + } + + private suspend fun updateDirectories(media: List) = withContext( + Dispatchers.Default + ) { + val directories = media.groupBy { File(it.data).parentFile!! }.map { (file, videos) -> + DirectoryEntity( + path = file.path, + name = file.prettyName, + mediaCount = videos.size, + size = videos.sumOf { it.size }, + modified = file.lastModified() + ) + } + directoryDao.upsertAll(directories) + + val currentDirectoryPaths = directories.map { it.path } + + val unwantedDirectories = directoryDao.getAll().first() + .map { it.path } + .filterNot { it in currentDirectoryPaths } + + directoryDao.delete(unwantedDirectories) + } + + private suspend fun updateMedia(media: List) = withContext(Dispatchers.Default) { + val mediumEntities = media.map { + val file = File(it.data) + val mediumEntity = mediumDao.get(it.data) + MediumEntity( + path = it.data, + uriString = it.uri.toString(), + name = file.name, + parentPath = file.parent!!, + modified = it.dateModified, + size = it.size, + width = it.width, + height = it.height, + duration = it.duration, + mediaStoreId = it.id, + playbackPosition = mediumEntity?.playbackPosition ?: 0, + audioTrackIndex = mediumEntity?.audioTrackIndex, + subtitleTrackIndex = mediumEntity?.subtitleTrackIndex, + playbackSpeed = mediumEntity?.playbackSpeed + ) + } + + mediumDao.upsertAll(mediumEntities) + + val currentMediaPaths = mediumEntities.map { it.path } + + val unwantedMedia = mediumDao.getAll().first() + .map { it.path } + .filterNot { it in currentMediaPaths } + + mediumDao.delete(unwantedMedia) + } + + private fun getMediaVideosFlow( + selection: String? = null, + selectionArgs: Array? = null, + sortOrder: String? = "${MediaStore.Video.Media.DISPLAY_NAME} ASC" + ): Flow> = callbackFlow { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(getMediaVideo(selection, selectionArgs, sortOrder)) + } + } + context.contentResolver.registerContentObserver(VIDEO_COLLECTION_URI, true, observer) + // initial value + trySend(getMediaVideo(selection, selectionArgs, sortOrder)) + // close + awaitClose { context.contentResolver.unregisterContentObserver(observer) } + }.flowOn(dispatcher).distinctUntilChanged() + + private fun getMediaVideo( + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): List { + val mediaVideos = mutableListOf() + context.contentResolver.query( + VIDEO_COLLECTION_URI, + VIDEO_PROJECTION, + selection, + selectionArgs, + sortOrder + )?.use { cursor -> + + val idColumn = cursor.getColumnIndex(MediaStore.Video.Media._ID) + val dataColumn = cursor.getColumnIndex(MediaStore.Video.Media.DATA) + val durationColumn = cursor.getColumnIndex(MediaStore.Video.Media.DURATION) + val widthColumn = cursor.getColumnIndex(MediaStore.Video.Media.WIDTH) + val heightColumn = cursor.getColumnIndex(MediaStore.Video.Media.HEIGHT) + val sizeColumn = cursor.getColumnIndex(MediaStore.Video.Media.SIZE) + val dateModifiedColumn = cursor.getColumnIndex(MediaStore.Video.Media.DATE_MODIFIED) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + mediaVideos.add( + MediaVideo( + id = id, + data = cursor.getString(dataColumn), + duration = cursor.getLong(durationColumn), + uri = ContentUris.withAppendedId(VIDEO_COLLECTION_URI, id), + width = cursor.getInt(widthColumn), + height = cursor.getInt(heightColumn), + size = cursor.getLong(sizeColumn), + dateModified = cursor.getLong(dateModifiedColumn) + ) + ) + } + } + return mediaVideos.filter { File(it.data).exists() } + } + + companion object { + val VIDEO_PROJECTION = arrayOf( + MediaStore.Video.Media._ID, + MediaStore.Video.Media.DATA, + MediaStore.Video.Media.DURATION, + MediaStore.Video.Media.HEIGHT, + MediaStore.Video.Media.WIDTH, + MediaStore.Video.Media.SIZE, + MediaStore.Video.Media.DATE_MODIFIED + ) + } +} diff --git a/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/sync/MediaSynchronizer.kt b/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/sync/MediaSynchronizer.kt new file mode 100644 index 000000000..6772ff0de --- /dev/null +++ b/core/media/src/main/java/dev/anilbeesetti/nextplayer/core/media/sync/MediaSynchronizer.kt @@ -0,0 +1,7 @@ +package dev.anilbeesetti.nextplayer.core.media.sync + +interface MediaSynchronizer { + fun startSync() + + fun stopSync() +}