diff --git a/app/src/main/java/dev/brahmkshatriya/echo/di/ExtensionModule.kt b/app/src/main/java/dev/brahmkshatriya/echo/di/ExtensionModule.kt index e7881735..651a687d 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/di/ExtensionModule.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/di/ExtensionModule.kt @@ -2,6 +2,9 @@ package dev.brahmkshatriya.echo.di import android.app.Application import android.content.SharedPreferences +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.SimpleCache import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -21,9 +24,11 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) class ExtensionModule { + @OptIn(UnstableApi::class) @Provides @Singleton - fun provideOfflineExtension(context: Application) = OfflineExtension(context) + fun provideOfflineExtension(context: Application, cache: SimpleCache) = + OfflineExtension(context, cache) @Provides @Singleton diff --git a/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt b/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt index 97946cfc..7ba11cc8 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt @@ -1,6 +1,9 @@ package dev.brahmkshatriya.echo.offline import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.SimpleCache import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.common.clients.AlbumClient import dev.brahmkshatriya.echo.common.clients.ArtistClient @@ -45,14 +48,19 @@ import dev.brahmkshatriya.echo.offline.MediaStoreUtils.editPlaylist import dev.brahmkshatriya.echo.offline.MediaStoreUtils.moveSongInPlaylist import dev.brahmkshatriya.echo.offline.MediaStoreUtils.removeSongFromPlaylist import dev.brahmkshatriya.echo.offline.MediaStoreUtils.searchBy +import dev.brahmkshatriya.echo.playback.MediaItemUtils.toIdAndIsVideo import dev.brahmkshatriya.echo.utils.getFromCache import dev.brahmkshatriya.echo.utils.saveToCache import dev.brahmkshatriya.echo.utils.toData import dev.brahmkshatriya.echo.utils.toJson -class OfflineExtension(val context: Context) : ExtensionClient, HomeFeedClient, TrackClient, - AlbumClient, ArtistClient, PlaylistClient, RadioClient, SearchClient, LibraryClient, - TrackLikeClient, PlaylistEditorListenerClient, SettingsChangeListenerClient { +@OptIn(UnstableApi::class) +class OfflineExtension( + val context: Context, + val cache: SimpleCache +) : ExtensionClient, HomeFeedClient, TrackClient, AlbumClient, ArtistClient, PlaylistClient, + RadioClient, SearchClient, LibraryClient, TrackLikeClient, PlaylistEditorListenerClient, + SettingsChangeListenerClient { companion object { val metadata = Metadata( @@ -107,8 +115,15 @@ class OfflineExtension(val context: Context) : ExtensionClient, HomeFeedClient, var library = MediaStoreUtils.getAllSongs(context, settings) private fun refreshLibrary() { library = MediaStoreUtils.getAllSongs(context, settings) + cachedTracks = getCachedTracks() } + @OptIn(UnstableApi::class) + private fun getCachedTracks() = cache.keys.mapNotNull { key -> + val (id, _) = key.toIdAndIsVideo() ?: return@mapNotNull null + context.getFromCache>(id, "track") + }.reversed() + override fun setSettings(settings: Settings) {} private fun find(artist: Artist) = @@ -237,10 +252,11 @@ class OfflineExtension(val context: Context) : ExtensionClient, HomeFeedClient, } override suspend fun loadPlaylist(playlist: Playlist) = - find(playlist)!!.toPlaylist() + if (playlist.id == "cached") playlist else find(playlist)!!.toPlaylist() override fun loadTracks(playlist: Playlist): PagedData = PagedData.Single { - find(playlist)!!.songList.map { it } + if (playlist.id == "cached") cachedTracks.map { it.second } + else find(playlist)!!.songList.map { it } } override fun getShelves(playlist: Playlist) = PagedData.Single { @@ -378,6 +394,7 @@ class OfflineExtension(val context: Context) : ExtensionClient, HomeFeedClient, "Playlists", "Folders" ).map { Tab(it, it) } + private var cachedTracks = listOf>() override fun getLibraryFeed(tab: Tab?): PagedData { if (refreshLibrary) refreshLibrary() return when (tab?.id) { @@ -385,8 +402,16 @@ class OfflineExtension(val context: Context) : ExtensionClient, HomeFeedClient, .toShelf(context, null).items!! else -> { - library.playlistList.map { it.toPlaylist().toMediaItem().toShelf() } - .toPaged() + val cached = if (cachedTracks.isNotEmpty()) Playlist( + id = "cached", + title = context.getString(R.string.cached_songs), + isEditable = false, + cover = cachedTracks.first().second.cover, + description = context.getString(R.string.cache_playlist_warning), + tracks = cachedTracks.size + ).toMediaItem().toShelf() else null + val playlists = library.playlistList.map { it.toPlaylist().toMediaItem().toShelf() } + (listOfNotNull(cached) + playlists).toPaged() } } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt index 20686ccf..b950e85c 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt @@ -1,7 +1,6 @@ package dev.brahmkshatriya.echo.playback import android.content.SharedPreferences -import android.net.Uri import android.os.Bundle import androidx.core.net.toUri import androidx.core.os.bundleOf @@ -79,9 +78,8 @@ object MediaItemUtils { item.build() } - fun Uri.toIdAndIsVideo() = runCatching { - val string = toString() - if (string.startsWith('{')) string.toData>() + fun String.toIdAndIsVideo() = runCatching { + if (startsWith('{')) toData>() else null }.getOrNull() diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/listeners/PlayerEventListener.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/listeners/PlayerEventListener.kt index c9c09abe..b840d793 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/listeners/PlayerEventListener.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/listeners/PlayerEventListener.kt @@ -52,7 +52,8 @@ class PlayerEventListener( private fun updateCurrentFlow() { currentFlow.value = player.currentMediaItem?.let { - Current(player.currentMediaItemIndex, it, it.isLoaded, player.isPlaying) + val isPlaying = player.isPlaying && player.playbackState == Player.STATE_READY + Current(player.currentMediaItemIndex, it, it.isLoaded, isPlaying) } } @@ -75,6 +76,11 @@ class PlayerEventListener( updateCustomLayout() } + override fun onPlaybackStateChanged(playbackState: Int) { + updateCurrentFlow() + updateCustomLayout() + } + override fun onIsPlayingChanged(isPlaying: Boolean) { updateCurrentFlow() updateCurrent() diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/CustomCacheDataSource.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/CustomCacheDataSource.kt new file mode 100644 index 00000000..4efa1407 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/CustomCacheDataSource.kt @@ -0,0 +1,50 @@ +package dev.brahmkshatriya.echo.playback.source + +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.BaseDataSource +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.SimpleCache +import dev.brahmkshatriya.echo.playback.source.MediaResolver.Companion.LOCAL + +@OptIn(UnstableApi::class) +class CustomCacheDataSource( + cache: SimpleCache, + private val upstream: DataSource.Factory +) : BaseDataSource(true) { + + class Factory( + private val cache: SimpleCache, + private val upstream: DataSource.Factory + ) : DataSource.Factory { + override fun createDataSource() = CustomCacheDataSource(cache, upstream) + } + + private val cacheFactory = CacheDataSource + .Factory().setCache(cache) + .setUpstreamDataSourceFactory(upstream) + + var source: DataSource? = null + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + return source?.read(buffer, offset, length) ?: throw Exception("Source not opened") + } + + override fun open(dataSpec: DataSpec): Long { + val source = if (dataSpec.uri == LOCAL) upstream.createDataSource() + else cacheFactory.createDataSource() + this.source = source + return source.open(dataSpec) + } + + override fun getUri(): Uri? { + return source?.uri + } + + override fun close() { + source?.close() + source = null + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaFactory.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaFactory.kt index d04917ce..6c99f51b 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaFactory.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaFactory.kt @@ -7,7 +7,6 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.ResolvingDataSource -import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.SimpleCache import androidx.media3.exoplayer.dash.DashMediaSource import androidx.media3.exoplayer.drm.DrmSessionManagerProvider @@ -50,9 +49,7 @@ class MediaFactory( private val mediaResolver = MediaResolver(context, extListFlow) private val dataSource = ResolvingDataSource.Factory( - CacheDataSource - .Factory().setCache(cache) - .setUpstreamDataSourceFactory(MediaDataSource.Factory(context)), + CustomCacheDataSource.Factory(cache, MediaDataSource.Factory(context)), mediaResolver ) private val default = lazily { DefaultMediaSourceFactory(dataSource) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaResolver.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaResolver.kt index ca370efc..048521fe 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaResolver.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/MediaResolver.kt @@ -16,13 +16,16 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.common.MusicExtension import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.offline.OfflineExtension import dev.brahmkshatriya.echo.playback.MediaItemUtils.audioIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.clientId import dev.brahmkshatriya.echo.playback.MediaItemUtils.toIdAndIsVideo import dev.brahmkshatriya.echo.playback.MediaItemUtils.track import dev.brahmkshatriya.echo.playback.MediaItemUtils.video import dev.brahmkshatriya.echo.playback.source.DelayedSource.Companion.getMediaItemById import dev.brahmkshatriya.echo.playback.source.DelayedSource.Companion.getTrackClient import dev.brahmkshatriya.echo.playback.source.MediaDataSource.Companion.copy +import dev.brahmkshatriya.echo.utils.saveToCache import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking @@ -36,7 +39,7 @@ class MediaResolver( @UnstableApi override fun resolveDataSpec(dataSpec: DataSpec): DataSpec { - val (id, isVideo) = dataSpec.uri.toIdAndIsVideo() ?: return dataSpec + val (id, isVideo) = dataSpec.uri.toString().toIdAndIsVideo() ?: return dataSpec val (_, mediaItem) = runBlocking(Dispatchers.Main) { player.getMediaItemById(id) @@ -45,8 +48,16 @@ class MediaResolver( val streamable = if (isVideo) mediaItem.video!! else runBlocking(Dispatchers.IO) { runCatching { loadAudio(mediaItem) } }.getOrThrow() + val uri = if (mediaItem.clientId == OfflineExtension.metadata.id) LOCAL + else { + if(!isVideo) { + val track = mediaItem.track + context.saveToCache(track.id, mediaItem.clientId to track, "track") + } + dataSpec.uri + } return dataSpec.copy( - uri = streamable.hashCode().toString().toUri(), + uri = uri, customData = streamable ) } @@ -66,6 +77,8 @@ class MediaResolver( companion object { + val LOCAL = "local".toUri() + @OptIn(UnstableApi::class) fun getPlayer( context: Context, cache: SimpleCache, video: Streamable.Media.WithVideo.Only diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/GridViewHolder.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/GridViewHolder.kt index fa8aa1a8..391d0f3d 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/GridViewHolder.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/GridViewHolder.kt @@ -16,6 +16,7 @@ import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.bind import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.icon import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.placeHolder import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.toolTipOnClick +import dev.brahmkshatriya.echo.utils.animateVisibility import dev.brahmkshatriya.echo.utils.loadInto import dev.brahmkshatriya.echo.utils.observe @@ -64,7 +65,7 @@ class GridViewHolder( binding.isPlaying.toolTipOnClick() observe(listener.current) { val playing = it.isPlaying(media.id) - binding.isPlaying.isVisible = playing + binding.isPlaying.animateVisibility(playing) if (playing) (binding.isPlaying.icon as Animatable).start() } media diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/MediaItemViewHolder.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/MediaItemViewHolder.kt index d3c68573..dbde3bae 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/MediaItemViewHolder.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/MediaItemViewHolder.kt @@ -15,6 +15,7 @@ import dev.brahmkshatriya.echo.databinding.NewItemMediaProfileBinding import dev.brahmkshatriya.echo.databinding.NewItemMediaTitleBinding import dev.brahmkshatriya.echo.databinding.NewItemMediaTrackBinding import dev.brahmkshatriya.echo.playback.Current.Companion.isPlaying +import dev.brahmkshatriya.echo.utils.animateVisibility import dev.brahmkshatriya.echo.utils.loadInto import dev.brahmkshatriya.echo.utils.loadWith import dev.brahmkshatriya.echo.utils.observe @@ -173,7 +174,7 @@ sealed class MediaItemViewHolder( this.icon.setImageResource(item.icon()) isPlaying.toolTipOnClick() return { playing: Boolean -> - isPlaying.isVisible = playing + isPlaying.animateVisibility(playing) if (playing) (isPlaying.icon as Animatable).start() } } @@ -193,7 +194,7 @@ sealed class MediaItemViewHolder( albumImage(item.size, listImageContainer1, listImageContainer2) isPlaying.toolTipOnClick() return { playing: Boolean -> - isPlaying.isVisible = playing + isPlaying.animateVisibility(playing) if (playing) (isPlaying.icon as Animatable).start() } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/TrackViewHolder.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/TrackViewHolder.kt index 9e47daee..6ced1d5c 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/TrackViewHolder.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/TrackViewHolder.kt @@ -8,8 +8,10 @@ import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.common.models.EchoMediaItem import dev.brahmkshatriya.echo.common.models.Shelf import dev.brahmkshatriya.echo.databinding.ItemTrackBinding +import dev.brahmkshatriya.echo.playback.Current.Companion.isPlaying import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.toolTipOnClick import dev.brahmkshatriya.echo.ui.item.TrackAdapter +import dev.brahmkshatriya.echo.utils.animateVisibility import dev.brahmkshatriya.echo.utils.loadInto import dev.brahmkshatriya.echo.utils.observe import dev.brahmkshatriya.echo.utils.toTimeString @@ -53,8 +55,8 @@ class TrackViewHolder( } binding.isPlaying.toolTipOnClick() observe(listener.current) { - val playing = it?.mediaItem?.mediaId == track.id - binding.isPlaying.isVisible = playing + val playing = it.isPlaying(track.id) + binding.isPlaying.animateVisibility(playing) if(playing) (binding.isPlaying.icon as Animatable).start() } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/utils/AnimationUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/AnimationUtils.kt index 19f1286c..bc78ec52 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/utils/AnimationUtils.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/utils/AnimationUtils.kt @@ -66,7 +66,7 @@ fun NavigationBarView.animateTranslation( } } -fun View.animateVisibility(visible: Boolean, animate: Boolean) { +fun View.animateVisibility(visible: Boolean, animate: Boolean = true) { if (animations && animate && isVisible != visible) { isVisible = true startAnimation( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 17005baa..13a26880 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -237,6 +237,8 @@ Unhide Hidden %1$s Unhidden %1$s + Experimental Feature.\nAnything except listening to them is not supported. + Cached Songs Highest Medium