diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 142f9647..527aa74f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,6 +45,7 @@ android { } dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.7.1") implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.core:core-ktx:1.12.0") @@ -52,8 +53,6 @@ dependencies { implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") implementation("androidx.recyclerview:recyclerview:1.3.2") - implementation("androidx.media3:media3-exoplayer:1.2.1") - implementation("androidx.navigation:navigation-fragment-ktx:2.7.7") implementation("androidx.navigation:navigation-ui-ktx:2.7.7") @@ -62,12 +61,14 @@ dependencies { implementation("com.google.android.material:material:1.12.0-alpha03") implementation("com.google.dagger:hilt-android:2.48.1") + + implementation("androidx.media3:media3-exoplayer:1.2.1") implementation("androidx.media3:media3-session:1.2.1") + kapt("com.google.dagger:hilt-android-compiler:2.48.1") implementation("com.github.bumptech.glide:glide:4.16.0") - implementation("androidx.room:room-ktx:2.6.1") implementation("androidx.paging:paging-runtime-ktx:3.2.1") implementation("androidx.paging:paging-common-ktx:3.2.1") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1b0359c6..05295947 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,6 +38,9 @@ android:permission="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"> + + + diff --git a/app/src/main/java/dev/brahmkshatriya/echo/PlaybackService.kt b/app/src/main/java/dev/brahmkshatriya/echo/PlaybackService.kt index b9060259..b232dcb2 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/PlaybackService.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/PlaybackService.kt @@ -5,15 +5,38 @@ import android.content.Intent import androidx.annotation.OptIn import androidx.media3.common.AudioAttributes import androidx.media3.common.C +import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSessionService +import androidx.paging.AsyncPagingDataDiffer +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import dagger.hilt.android.AndroidEntryPoint +import dev.brahmkshatriya.echo.data.clients.SearchClient +import dev.brahmkshatriya.echo.data.clients.TrackClient +import dev.brahmkshatriya.echo.data.extensions.OfflineExtension +import dev.brahmkshatriya.echo.data.models.MediaItemsContainer +import dev.brahmkshatriya.echo.ui.adapters.MediaItemsContainerAdapter +import dev.brahmkshatriya.echo.ui.player.PlayerHelper.Companion.mediaItemBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.guava.future +import kotlinx.coroutines.plus +import javax.inject.Inject +@AndroidEntryPoint +class PlaybackService : MediaLibraryService() { -class PlaybackService : MediaSessionService() { - private var mediaSession: MediaSession? = null + @Inject lateinit var extension: OfflineExtension + + private var mediaLibrarySession: MediaLibrarySession? = null @OptIn(UnstableApi::class) override fun onCreate() { @@ -36,7 +59,7 @@ class PlaybackService : MediaSessionService() { val pendingIntent = PendingIntent .getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) - mediaSession = MediaSession.Builder(this, player) + mediaLibrarySession = MediaLibrarySession.Builder(this, player, Callback(extension)) .setSessionActivity(pendingIntent) .build() @@ -50,14 +73,68 @@ class PlaybackService : MediaSessionService() { } override fun onDestroy() { - mediaSession?.run { + mediaLibrarySession?.run { player.release() release() - mediaSession = null + mediaLibrarySession = null } super.onDestroy() } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = - mediaSession + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = + mediaLibrarySession + + inner class Callback( + private val extension: Any + ) : MediaLibrarySession.Callback { + + private val scope = CoroutineScope(Dispatchers.IO) + Job() + + private fun notSupported() = Futures.immediateFuture( + LibraryResult.ofError(LibraryResult.RESULT_ERROR_NOT_SUPPORTED) + ) + + override fun onGetSearchResult( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + if (extension !is SearchClient) return notSupported() + if (extension !is TrackClient) return notSupported() + + return scope.future(Dispatchers.IO) { + val differ = AsyncPagingDataDiffer( + MediaItemsContainerAdapter.MediaItemsContainerComparator, + MediaItemsContainerAdapter.ListCallback(), + ) + extension.search(query).map { + differ.submitData(it) + } + val list = differ.snapshot().items.map { + val track = (it as? MediaItemsContainer.TrackItem)?.track ?: return@map null + val stream = extension.getStreamable(track) + mediaItemBuilder(track, stream) + }.filterNotNull() + LibraryResult.ofItemList(list, params) + } + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + if (extension !is TrackClient) return notSupported() + return scope.future(Dispatchers.IO) { + val track = extension.getTrack(mediaId) + ?: return@future LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN) + val stream = extension.getStreamable(track) + val item = mediaItemBuilder(track, stream) + LibraryResult.ofItem(item, null) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/data/clients/TrackClient.kt b/app/src/main/java/dev/brahmkshatriya/echo/data/clients/TrackClient.kt index ec8db892..c92173ed 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/data/clients/TrackClient.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/data/clients/TrackClient.kt @@ -4,6 +4,7 @@ import dev.brahmkshatriya.echo.data.models.StreamableAudio import dev.brahmkshatriya.echo.data.models.Track interface TrackClient { + suspend fun getTrack(uri: String): Track? suspend fun getStreamable(track: Track): StreamableAudio } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/data/extensions/OfflineExtension.kt b/app/src/main/java/dev/brahmkshatriya/echo/data/extensions/OfflineExtension.kt index 175c7637..6236ed53 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/data/extensions/OfflineExtension.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/data/extensions/OfflineExtension.kt @@ -88,6 +88,10 @@ class OfflineExtension(val context: Context) : SearchClient, TrackClient, HomeFe } } + override suspend fun getTrack(uri: String): Track { + return LocalTrack.get(context, uri) ?: throw IOException("Track not found") + } + override suspend fun getStreamable(track: Track): StreamableAudio { return LocalStream.getFromTrack(context, track)?.toAudio() ?: throw IOException("Track not found") diff --git a/app/src/main/java/dev/brahmkshatriya/echo/data/offline/LocalTrack.kt b/app/src/main/java/dev/brahmkshatriya/echo/data/offline/LocalTrack.kt index 1d74d98d..e3918d31 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/data/offline/LocalTrack.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/data/offline/LocalTrack.kt @@ -37,13 +37,23 @@ interface LocalTrack { return context.queryTracks(whereCondition, selectionArgs, page, pageSize).shuffled() } - fun getByArtist(context: Context, artist: Artist.Small, page: Int, pageSize: Int): List { + fun getByArtist( + context: Context, + artist: Artist.Small, + page: Int, + pageSize: Int + ): List { val whereCondition = "${MediaStore.Audio.Media.ARTIST_ID} = ?" val selectionArgs = arrayOf(artist.uri.lastPathSegment!!) return context.queryTracks(whereCondition, selectionArgs, page, pageSize) } - fun getByAlbum(context: Context, album: Album.Small, page: Int, pageSize: Int): List { + fun getByAlbum( + context: Context, + album: Album.Small, + page: Int, + pageSize: Int + ): List { val whereCondition = "${MediaStore.Audio.Media.ALBUM_ID} = ?" val selectionArgs = arrayOf(album.uri.lastPathSegment!!) return context.queryTracks(whereCondition, selectionArgs, page, pageSize) @@ -116,6 +126,13 @@ interface LocalTrack { } return tracks } + + fun get(context: Context, uri: String): Track? { + val id = uri.substringAfterLast('/') + val whereCondition = "${MediaStore.Audio.Media._ID} = ?" + val selectionArgs = arrayOf(id) + return context.queryTracks(whereCondition, selectionArgs, 0, 1).firstOrNull() + } } } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/di/PluginModule.kt b/app/src/main/java/dev/brahmkshatriya/echo/di/PluginModule.kt index e123f98a..1efc803b 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/di/PluginModule.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/di/PluginModule.kt @@ -20,6 +20,10 @@ class PluginModule { offline = it } + @Provides + @Singleton + fun provideExtension(app: Application) : OfflineExtension = getOfflineExtension(app) + @Provides @Singleton fun provideSearchClient(app: Application) : SearchClient = getOfflineExtension(app) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapters/MediaItemsContainerAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapters/MediaItemsContainerAdapter.kt index 4683271f..4c7d52b4 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapters/MediaItemsContainerAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapters/MediaItemsContainerAdapter.kt @@ -10,6 +10,7 @@ import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL +import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.RecyclerView import dev.brahmkshatriya.echo.data.models.MediaItemsContainer import dev.brahmkshatriya.echo.data.models.Track @@ -169,4 +170,11 @@ class MediaItemsContainerAdapter( return true } } + + class ListCallback : ListUpdateCallback { + override fun onChanged(position: Int, count: Int, payload: Any?) {} + override fun onMoved(fromPosition: Int, toPosition: Int) {} + override fun onInserted(position: Int, count: Int) {} + override fun onRemoved(position: Int, count: Int) {} + } } \ No newline at end of file