From 475733e46faf17fa3b11973992b4835d948412e0 Mon Sep 17 00:00:00 2001 From: brahmkshatriya <69040506+brahmkshatriya@users.noreply.github.com> Date: Tue, 8 Oct 2024 14:31:03 +0530 Subject: [PATCH 1/7] Add now playing icon --- .../echo/playback/source/DelayedSource.kt | 11 +-- .../echo/ui/adapter/GridViewHolder.kt | 11 ++- .../echo/ui/adapter/MediaItemViewHolder.kt | 35 ++++++-- .../echo/ui/adapter/PlaylistAdapter.kt | 3 + .../echo/ui/adapter/ShelfClickListener.kt | 5 ++ .../echo/ui/adapter/ShelfViewHolder.kt | 12 ++- .../echo/ui/adapter/TrackViewHolder.kt | 9 ++ .../echo/ui/item/ArtistHeaderAdapter.kt | 4 +- .../echo/ui/item/ItemBottomSheet.kt | 10 ++- .../echo/ui/item/TrackAdapter.kt | 26 +++++- .../echo/ui/settings/AboutFragment.kt | 3 + .../main/res/drawable/anim_now_playing.xml | 87 +++++++++++++++++++ app/src/main/res/layout/item_lists_cover.xml | 4 +- .../main/res/layout/item_playlist_item.xml | 38 +++++--- app/src/main/res/layout/item_shelf_media.xml | 8 -- .../main/res/layout/item_shelf_media_grid.xml | 4 + app/src/main/res/layout/item_track.xml | 24 +++-- app/src/main/res/layout/item_track_cover.xml | 3 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/styles.xml | 21 ++++- 20 files changed, 266 insertions(+), 53 deletions(-) create mode 100644 app/src/main/res/drawable/anim_now_playing.xml diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/DelayedSource.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/DelayedSource.kt index af3e3fe6..7f8cce2a 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/source/DelayedSource.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/source/DelayedSource.kt @@ -3,14 +3,12 @@ package dev.brahmkshatriya.echo.playback.source import android.content.Context import android.content.SharedPreferences import androidx.annotation.OptIn -import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Timeline import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.TransferListener import androidx.media3.exoplayer.source.CompositeMediaSource -import androidx.media3.exoplayer.source.FilteringMediaSource import androidx.media3.exoplayer.source.MediaPeriod import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MergingMediaSource @@ -68,13 +66,10 @@ class DelayedSource( mediaItem = new actualSource = when (new.isAudioAndVideoMerged()) { true -> mediaFactory.create(new, true) - null -> FilteringMediaSource(mediaFactory.create(new, false), C.TRACK_TYPE_AUDIO) + null -> mediaFactory.create(new, false) false -> MergingMediaSource( - FilteringMediaSource( - mediaFactory.create(new, true), - setOf(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_TEXT) - ), - FilteringMediaSource(mediaFactory.create(new, false), C.TRACK_TYPE_AUDIO) + mediaFactory.create(new, true), + mediaFactory.create(new, false) ) } runCatching { prepareChildSource(null, actualSource) } 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 82ee7324..8622a3b5 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 @@ -1,6 +1,7 @@ package dev.brahmkshatriya.echo.ui.adapter import android.annotation.SuppressLint +import android.graphics.drawable.Animatable import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isVisible @@ -13,7 +14,9 @@ import dev.brahmkshatriya.echo.databinding.NewItemMediaTitleBinding 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.loadInto +import dev.brahmkshatriya.echo.utils.observe class GridViewHolder( val listener: ShelfAdapter.Listener, @@ -22,7 +25,7 @@ class GridViewHolder( ) : ShelfListItemViewHolder(binding.root) { @SuppressLint("SetTextI18n") - override fun bind(item:Any) { + override fun bind(item: Any) { val media = when (item) { is EchoMediaItem -> { binding.iconContainer.isVisible = true @@ -57,6 +60,12 @@ class GridViewHolder( if (isNumbered) listener.onLongClick(clientId, null, tracks, pos, it) else listener.onLongClick(clientId, media, it) } + binding.isPlaying.toolTipOnClick() + observe(listener.current) { + val playing = it?.mediaItem?.mediaId == media.id + binding.isPlaying.isVisible = 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 a22499a9..3951d958 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 @@ -1,5 +1,6 @@ package dev.brahmkshatriya.echo.ui.adapter +import android.graphics.drawable.Animatable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -13,8 +14,10 @@ import dev.brahmkshatriya.echo.databinding.NewItemMediaListsBinding import dev.brahmkshatriya.echo.databinding.NewItemMediaProfileBinding import dev.brahmkshatriya.echo.databinding.NewItemMediaTitleBinding import dev.brahmkshatriya.echo.databinding.NewItemMediaTrackBinding +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context import dev.brahmkshatriya.echo.utils.loadInto import dev.brahmkshatriya.echo.utils.loadWith +import dev.brahmkshatriya.echo.utils.observe sealed class MediaItemViewHolder( val listener: ShelfAdapter.Listener, @@ -49,7 +52,10 @@ sealed class MediaItemViewHolder( override fun bind(item: EchoMediaItem) { item as EchoMediaItem.Lists titleBinding.bind(item) - binding.cover.bind(item) + val isPlaying = binding.cover.bind(item) + observe(listener.current) { + isPlaying(it?.mediaItem?.context?.id == item.id) + } } companion object { @@ -79,7 +85,11 @@ sealed class MediaItemViewHolder( override fun bind(item: EchoMediaItem) { titleBinding.bind(item) - binding.cover.bind(item) + val isPlaying = binding.cover.bind(item) + observe(listener.current) { + val media = it?.mediaItem + isPlaying(media?.mediaId == item.id) + } } companion object { @@ -154,17 +164,27 @@ sealed class MediaItemViewHolder( subtitle.text = item.subtitle } - fun ItemTrackCoverBinding.bind(item: EchoMediaItem) { + fun View.toolTipOnClick() { + setOnClickListener { performLongClick() } + } + + fun ItemTrackCoverBinding.bind(item: EchoMediaItem): (Boolean) -> Unit { item.cover.loadInto(trackImageView, item.placeHolder()) this.iconContainer.isVisible = item !is EchoMediaItem.TrackItem this.icon.setImageResource(item.icon()) + isPlaying.toolTipOnClick() + return { playing: Boolean -> + isPlaying.isVisible = playing + if (playing) (isPlaying.icon as Animatable).start() + } } - fun ItemProfileCoverBinding.bind(item: EchoMediaItem) { + fun ItemProfileCoverBinding.bind(item: EchoMediaItem): (Boolean) -> Unit { item.cover.loadInto(profileImageView, item.placeHolder()) + return { } } - fun ItemListsCoverBinding.bind(item: EchoMediaItem.Lists) { + fun ItemListsCoverBinding.bind(item: EchoMediaItem.Lists): (Boolean) -> Unit { playlist.isVisible = item is EchoMediaItem.Lists.PlaylistItem val cover = item.cover cover.loadWith(listImageView) { @@ -172,6 +192,11 @@ sealed class MediaItemViewHolder( cover.loadInto(listImageView2) } albumImage(item.size, listImageContainer1, listImageContainer2) + isPlaying.toolTipOnClick() + return { playing: Boolean -> + isPlaying.isVisible = playing + if (playing) (isPlaying.icon as Animatable).start() + } } private fun albumImage(size: Int?, view1: View, view2: View) { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/PlaylistAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/PlaylistAdapter.kt index 560f8410..9d6029eb 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/PlaylistAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/PlaylistAdapter.kt @@ -1,6 +1,7 @@ package dev.brahmkshatriya.echo.ui.adapter import android.annotation.SuppressLint +import android.graphics.drawable.Animatable import android.view.LayoutInflater import android.view.MotionEvent import android.view.ViewGroup @@ -89,6 +90,8 @@ class PlaylistAdapter( binding.playlistCurrentItem.isVisible = isCurrent binding.playlistProgressBar.isVisible = isCurrent && !item.isLoaded + binding.playlistItemNowPlaying.isVisible = isCurrent && item.isLoaded + (binding.playlistItemNowPlaying.drawable as Animatable).start() } class Loader : RecyclerView.Adapter() { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfClickListener.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfClickListener.kt index ddcbcf5a..90abfc2d 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfClickListener.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfClickListener.kt @@ -17,6 +17,7 @@ import dev.brahmkshatriya.echo.common.models.Shelf.Item import dev.brahmkshatriya.echo.common.models.Track import dev.brahmkshatriya.echo.extensions.getExtension import dev.brahmkshatriya.echo.extensions.isClient +import dev.brahmkshatriya.echo.playback.Current import dev.brahmkshatriya.echo.ui.common.openFragment import dev.brahmkshatriya.echo.ui.container.ContainerFragment import dev.brahmkshatriya.echo.ui.container.ContainerViewModel @@ -30,6 +31,7 @@ import dev.brahmkshatriya.echo.viewmodels.PlayerViewModel import dev.brahmkshatriya.echo.viewmodels.SnackBar import dev.brahmkshatriya.echo.viewmodels.SnackBar.Companion.createSnack import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map open class ShelfClickListener( @@ -131,6 +133,9 @@ open class ShelfClickListener( return true } + override val current: StateFlow + get() = fragment.activityViewModels().value.currentFlow + override fun onClick(clientId: String, shelf: Shelf, transitionView: View) { when (shelf) { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt index ab58fdf9..f240f1cc 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt @@ -15,10 +15,12 @@ import dev.brahmkshatriya.echo.common.models.Shelf import dev.brahmkshatriya.echo.databinding.ItemShelfCategoryBinding import dev.brahmkshatriya.echo.databinding.ItemShelfListsBinding import dev.brahmkshatriya.echo.databinding.ItemShelfMediaBinding +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context import dev.brahmkshatriya.echo.ui.adapter.GridViewHolder.Companion.ifGrid import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.bind import dev.brahmkshatriya.echo.ui.adapter.ShowButtonViewHolder.Companion.ifShowingButton import dev.brahmkshatriya.echo.utils.dpToPx +import dev.brahmkshatriya.echo.utils.observe import java.lang.ref.WeakReference sealed class ShelfViewHolder( @@ -129,7 +131,11 @@ sealed class ShelfViewHolder( ) : ShelfViewHolder(binding.root) { override fun bind(item: Shelf) { val media = (item as? Shelf.Item)?.media ?: return - binding.bind(media) + val isPlaying = binding.bind(media) + observe(listener.current) { + val mediaItem = it?.mediaItem + isPlaying(mediaItem?.mediaId == media.id || mediaItem?.context?.id == media.id) + } binding.more.setOnClickListener { listener.onLongClick(clientId, media, transitionView) } @@ -151,7 +157,7 @@ sealed class ShelfViewHolder( ) } - fun ItemShelfMediaBinding.bind(item: EchoMediaItem) { + fun ItemShelfMediaBinding.bind(item: EchoMediaItem): (Boolean) -> Unit { title.text = item.title subtitle.text = item.subtitle subtitle.isVisible = item.subtitle.isNullOrBlank().not() @@ -160,7 +166,7 @@ sealed class ShelfViewHolder( listsImageContainer.root.isVisible = item is EchoMediaItem.Lists profileImageContainer.root.isVisible = item is EchoMediaItem.Profile - when (item) { + return when (item) { is EchoMediaItem.TrackItem -> trackImageContainer.bind(item) is EchoMediaItem.Lists -> listsImageContainer.bind(item) is EchoMediaItem.Profile -> profileImageContainer.bind(item) 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 ea545355..9e47daee 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 @@ -1,5 +1,6 @@ package dev.brahmkshatriya.echo.ui.adapter +import android.graphics.drawable.Animatable import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isVisible @@ -7,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.ui.adapter.MediaItemViewHolder.Companion.toolTipOnClick import dev.brahmkshatriya.echo.ui.item.TrackAdapter import dev.brahmkshatriya.echo.utils.loadInto +import dev.brahmkshatriya.echo.utils.observe import dev.brahmkshatriya.echo.utils.toTimeString class TrackViewHolder( @@ -48,6 +51,12 @@ class TrackViewHolder( binding.itemMore.setOnClickListener { listener.onLongClick(clientId, context, list, pos, binding.root) } + binding.isPlaying.toolTipOnClick() + observe(listener.current) { + val playing = it?.mediaItem?.mediaId == track.id + binding.isPlaying.isVisible = playing + if(playing) (binding.isPlaying.icon as Animatable).start() + } } override val transitionView = binding.root diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ArtistHeaderAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ArtistHeaderAdapter.kt index 2d883cef..20953be9 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ArtistHeaderAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ArtistHeaderAdapter.kt @@ -71,7 +71,9 @@ class ArtistHeaderAdapter(private val listener: Listener) : binding.root.resources.getQuantityString(R.plurals.number_followers, it, it) } ?: "" - description += artist.description?.let { if (description.isBlank()) it else "\n\n$it" } + description += + artist.description?.let { if (description.isBlank()) it else "\n\n$it" } ?: "" + binding.artistDescriptionContainer.isVisible = if (description.isBlank()) false else { binding.artistDescription.text = description true diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt index 2537bba3..9b41743f 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt @@ -30,6 +30,7 @@ import dev.brahmkshatriya.echo.databinding.ItemDialogButtonBinding import dev.brahmkshatriya.echo.databinding.ItemDialogButtonLoadingBinding import dev.brahmkshatriya.echo.extensions.getExtension import dev.brahmkshatriya.echo.offline.OfflineExtension +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.Media.Companion.bind import dev.brahmkshatriya.echo.ui.common.openFragment import dev.brahmkshatriya.echo.ui.editplaylist.AddToPlaylistBottomSheet @@ -81,13 +82,18 @@ class ItemBottomSheet : BottomSheetDialogFragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + var isPlaying: (Boolean) -> Unit = {} + observe(playerViewModel.currentFlow) { + val mediaItem = it?.mediaItem + isPlaying(mediaItem?.mediaId == item.id || mediaItem?.context?.id == item.id) + } binding.itemContainer.run { more.run { setOnClickListener { dismiss() } setIconResource(R.drawable.ic_close) contentDescription = context.getString(R.string.close) } - bind(item) + isPlaying = bind(item) if (!loaded) root.setOnClickListener { openItemFragment(item) dismiss() @@ -103,7 +109,7 @@ class ItemBottomSheet : BottomSheetDialogFragment() { viewModel.initialize() observe(viewModel.itemFlow) { if (it != null) { - binding.itemContainer.bind(it) + isPlaying = binding.itemContainer.bind(it) binding.recyclerView.adapter = ActionAdapter(getActions(it, true)) } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/TrackAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/TrackAdapter.kt index a90273e0..30c91c30 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/TrackAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/TrackAdapter.kt @@ -2,13 +2,18 @@ package dev.brahmkshatriya.echo.ui.item import android.view.View import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry import androidx.paging.PagingData import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import dev.brahmkshatriya.echo.common.models.EchoMediaItem import dev.brahmkshatriya.echo.common.models.Shelf import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.playback.Current import dev.brahmkshatriya.echo.ui.adapter.TrackViewHolder +import kotlinx.coroutines.flow.StateFlow class TrackAdapter( private val clientId: String, @@ -24,6 +29,7 @@ class TrackAdapter( } interface Listener { + val current: StateFlow fun onClick( clientId: String, context: EchoMediaItem?, list: List, pos: Int, view: View ) @@ -37,10 +43,26 @@ class TrackAdapter( submitData(pagingData ?: PagingData.empty()) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - TrackViewHolder.create(parent, listener, clientId, context) + private fun destroyLifeCycle(holder: TrackViewHolder) { + if (holder.lifecycleRegistry.currentState.isAtLeast(Lifecycle.State.STARTED)) + holder.lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + } + + @CallSuper + override fun onViewRecycled(holder: TrackViewHolder) { + destroyLifeCycle(holder) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackViewHolder { + val holder = TrackViewHolder.create(parent, listener, clientId, context) + holder.lifecycleRegistry = LifecycleRegistry(holder) + return holder + } override fun onBindViewHolder(holder: TrackViewHolder, position: Int) { + destroyLifeCycle(holder) + holder.lifecycleRegistry = LifecycleRegistry(holder) + holder.lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) val binding = holder.binding val track = getItem(position) ?: return binding.root.transitionName = (transition + track.id).hashCode().toString() diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AboutFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AboutFragment.kt index 49cfa1f8..ed560f3d 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AboutFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AboutFragment.kt @@ -54,12 +54,15 @@ class AboutFragment : BaseSettingsFragment() { "fr" to "Français", "hi" to "हिन्दी", "hng" to "Hinglish", + "hu" to "Magyar", "ja" to "日本語", "nb-rNO" to "Norsk bokmål", "nl" to "Nederlands", "pl" to "Polski", "pt" to "Português", + "ru" to "Русский", "sa" to "संस्कृतम्", + "sr" to "Српски", "tr" to "Türkçe", "zh-rCN" to "中文 (简体)", ) diff --git a/app/src/main/res/drawable/anim_now_playing.xml b/app/src/main/res/drawable/anim_now_playing.xml new file mode 100644 index 00000000..395aa8f8 --- /dev/null +++ b/app/src/main/res/drawable/anim_now_playing.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_lists_cover.xml b/app/src/main/res/layout/item_lists_cover.xml index e6acd30b..3af91c5a 100644 --- a/app/src/main/res/layout/item_lists_cover.xml +++ b/app/src/main/res/layout/item_lists_cover.xml @@ -83,6 +83,8 @@ app:tint="?attr/colorOnSurface" /> - + \ No newline at end of file diff --git a/app/src/main/res/layout/item_playlist_item.xml b/app/src/main/res/layout/item_playlist_item.xml index c1abe89b..d865dbed 100644 --- a/app/src/main/res/layout/item_playlist_item.xml +++ b/app/src/main/res/layout/item_playlist_item.xml @@ -36,14 +36,32 @@ app:srcCompat="@drawable/ic_drag_20dp" app:tint="@color/button_player" /> - + app:cardCornerRadius="8dp"> + + + + + @@ -60,12 +78,12 @@ android:layout_marginStart="16dp" android:layout_weight="1" android:baselineAligned="false" - android:gravity="center" + android:gravity="center_vertical" android:orientation="vertical"> diff --git a/app/src/main/res/layout/item_shelf_media.xml b/app/src/main/res/layout/item_shelf_media.xml index bc63f0ff..5fe5e0c8 100644 --- a/app/src/main/res/layout/item_shelf_media.xml +++ b/app/src/main/res/layout/item_shelf_media.xml @@ -75,13 +75,5 @@ android:contentDescription="@string/more" app:icon="@drawable/ic_more_horiz" /> - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_shelf_media_grid.xml b/app/src/main/res/layout/item_shelf_media_grid.xml index f64038e6..be91e130 100644 --- a/app/src/main/res/layout/item_shelf_media_grid.xml +++ b/app/src/main/res/layout/item_shelf_media_grid.xml @@ -60,6 +60,10 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9196a62c..17005baa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -232,6 +232,7 @@ Saved %1$s to library Liked %1$s Unliked %1$s + Back Hide Unhide Hidden %1$s diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 9864a43c..6dccb9c4 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,5 +1,5 @@ - + - + - Back + + + \ No newline at end of file From fb45014c23b49db4e187d2b64fc2800e7f79c431 Mon Sep 17 00:00:00 2001 From: brahmkshatriya <69040506+brahmkshatriya@users.noreply.github.com> Date: Tue, 8 Oct 2024 19:18:28 +0530 Subject: [PATCH 2/7] Add support to load tracks in shelves --- .../dev/brahmkshatriya/echo/PlayerService.kt | 1 - .../echo/offline/OfflineExtension.kt | 2 +- .../echo/ui/adapter/ShelfAdapter.kt | 4 +- .../echo/ui/adapter/ShelfViewHolder.kt | 102 ++++++++++++++++++ .../echo/viewmodels/PlayerViewModel.kt | 5 +- .../res/layout/item_shelf_media_lists.xml | 29 +++++ app/src/main/res/layout/item_track.xml | 1 + .../echo/common/models/EchoMediaItem.kt | 5 +- .../echo/common/models/Radio.kt | 1 + .../echo/common/models/Shelf.kt | 3 +- 10 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 app/src/main/res/layout/item_shelf_media_lists.xml diff --git a/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt b/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt index 7279b3c4..013c4d7f 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt @@ -137,7 +137,6 @@ class PlayerService : MediaLibraryService() { } } - //TODO: Radio Item //TODO: Open .eapk files //TODO: extension updater //TODO: Spotify 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 6b0e19cd..97946cfc 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt @@ -132,7 +132,7 @@ class OfflineExtension(val context: Context) : ExtensionClient, HomeFeedClient, override fun getHomeFeed(tab: Tab?): PagedData { if (refreshLibrary) refreshLibrary() fun List.sorted() = sortedBy { it.title.lowercase() } - .map { it.toShelf() }.toPaged() + .map { it.toShelf(true) }.toPaged() return when (tab?.id) { "Songs" -> library.songList.map { it.toMediaItem() }.sorted() "Albums" -> library.albumList.map { it.toAlbum().toMediaItem() }.sorted() diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfAdapter.kt index c4e99a70..438c55cc 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfAdapter.kt @@ -20,6 +20,7 @@ import dev.brahmkshatriya.echo.common.models.Shelf import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.Category import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.Lists import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.Media +import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.MediaLists import dev.brahmkshatriya.echo.ui.editplaylist.SearchForPlaylistClickListener import dev.brahmkshatriya.echo.ui.editplaylist.SearchForPlaylistFragment import dev.brahmkshatriya.echo.ui.item.TrackAdapter @@ -157,7 +158,7 @@ class ShelfAdapter( val item = getItem(position) ?: return 0 return when (item) { is Shelf.Lists<*> -> 0 - is Shelf.Item -> 1 + is Shelf.Item -> if (item.media is EchoMediaItem.Lists && item.loadTracks) 3 else 1 is Shelf.Category -> 2 } } @@ -168,6 +169,7 @@ class ShelfAdapter( 0 -> Lists.create(parent, stateViewModel, sharedPool, extension.id, listener) 1 -> Media.create(parent, extension.id, listener) 2 -> Category.create(parent) + 3 -> MediaLists.create(parent, extension.id, listener, fragment) else -> throw IllegalArgumentException("Unknown viewType: $viewType") } holder.lifecycleRegistry = LifecycleRegistry(holder) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt index f240f1cc..d8f328cc 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt @@ -1,27 +1,47 @@ package dev.brahmkshatriya.echo.ui.adapter +import android.app.Application import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.core.view.updatePaddingRelative +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.ViewModel import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.flexbox.FlexboxLayoutManager import com.google.android.flexbox.JustifyContent.SPACE_BETWEEN +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.MusicExtension +import dev.brahmkshatriya.echo.common.clients.AlbumClient +import dev.brahmkshatriya.echo.common.clients.PlaylistClient +import dev.brahmkshatriya.echo.common.clients.RadioClient +import dev.brahmkshatriya.echo.common.helpers.PagedData import dev.brahmkshatriya.echo.common.models.EchoMediaItem import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Track import dev.brahmkshatriya.echo.databinding.ItemShelfCategoryBinding import dev.brahmkshatriya.echo.databinding.ItemShelfListsBinding import dev.brahmkshatriya.echo.databinding.ItemShelfMediaBinding +import dev.brahmkshatriya.echo.databinding.ItemShelfMediaListsBinding +import dev.brahmkshatriya.echo.extensions.getExtension import dev.brahmkshatriya.echo.playback.MediaItemUtils.context import dev.brahmkshatriya.echo.ui.adapter.GridViewHolder.Companion.ifGrid import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.bind +import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.Media.Companion.bind import dev.brahmkshatriya.echo.ui.adapter.ShowButtonViewHolder.Companion.ifShowingButton +import dev.brahmkshatriya.echo.ui.item.TrackAdapter +import dev.brahmkshatriya.echo.ui.paging.toFlow import dev.brahmkshatriya.echo.utils.dpToPx import dev.brahmkshatriya.echo.utils.observe +import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel.Companion.noClient +import kotlinx.coroutines.flow.MutableStateFlow import java.lang.ref.WeakReference +import javax.inject.Inject sealed class ShelfViewHolder( itemView: View, @@ -174,4 +194,86 @@ sealed class ShelfViewHolder( } } } + + class MediaLists( + val binding: ItemShelfMediaListsBinding, + private val clientId: String, + val listener: ShelfAdapter.Listener, + val viewModel: ListViewModel + ) : ShelfViewHolder(binding.root) { + override val transitionView: View + get() = binding.root + + override fun bind(item: Shelf) { + val media = (item as? Shelf.Item)?.media ?: return + if (media !is EchoMediaItem.Lists) return + val isPlaying = binding.listsInfo.bind(media) + observe(listener.current) { + val mediaItem = it?.mediaItem + isPlaying(mediaItem?.mediaId == media.id || mediaItem?.context?.id == media.id) + } + binding.listsInfo.more.setOnClickListener { + listener.onLongClick(clientId, media, transitionView) + } + binding.listsTracks.title.setText(R.string.songs) + binding.listsTracks.shuffle.isVisible = false + val transition = transitionView.transitionName + media.id + val adapter = TrackAdapter(clientId, transition, listener, media, false) + binding.listsTracks.recyclerView.adapter = adapter + val tracks = viewModel.loadTracks(clientId, media) + observe(tracks.toFlow()) { + adapter.submitData(it) + } + } + + companion object { + fun create( + parent: ViewGroup, + clientId: String, + listener: ShelfAdapter.Listener, + fragment: Fragment + ): ShelfViewHolder { + val viewModel by fragment.activityViewModels() + val layoutInflater = LayoutInflater.from(parent.context) + return MediaLists( + ItemShelfMediaListsBinding.inflate(layoutInflater, parent, false), + clientId, + listener, + viewModel + ) + } + } + + @HiltViewModel + class ListViewModel @Inject constructor( + val app: Application, + val extensionList: MutableStateFlow?>, + ) : ViewModel() { + val map = hashMapOf>() + fun loadTracks(clientId: String, lists: EchoMediaItem.Lists) = map.getOrPut(lists) { + PagedData.Single { + val client = extensionList.getExtension(clientId)?.instance?.value?.getOrNull() + ?: throw Exception(app.noClient().message) + when (lists) { + is EchoMediaItem.Lists.AlbumItem -> { + client as AlbumClient + val album = client.loadAlbum(lists.album) + client.loadTracks(album) + } + + is EchoMediaItem.Lists.PlaylistItem -> { + client as PlaylistClient + val playlist = client.loadPlaylist(lists.playlist) + client.loadTracks(playlist) + } + + is EchoMediaItem.Lists.RadioItem -> { + client as RadioClient + client.loadTracks(lists.radio) + } + }.loadFirst().take(3) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/PlayerViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/PlayerViewModel.kt index bc01ab40..9fb03260 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/PlayerViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/PlayerViewModel.kt @@ -190,7 +190,10 @@ class PlayerViewModel @Inject constructor( val mediaItems = tracks.map { track -> MediaItemUtils.build(settings, track, clientId, context) } - val index = if (end) it.mediaItemCount else 1 + val index = if (end) it.mediaItemCount else { + val curr = currentFlow.value?.index ?: 0 + curr + 1 + } it.addMediaItems(index, mediaItems) it.prepare() } diff --git a/app/src/main/res/layout/item_shelf_media_lists.xml b/app/src/main/res/layout/item_shelf_media_lists.xml new file mode 100644 index 00000000..894fe730 --- /dev/null +++ b/app/src/main/res/layout/item_shelf_media_lists.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_track.xml b/app/src/main/res/layout/item_track.xml index b7c6889a..c53103ba 100644 --- a/app/src/main/res/layout/item_track.xml +++ b/app/src/main/res/layout/item_track.xml @@ -16,6 +16,7 @@ android:layout_marginEnd="-8dp" android:gravity="center" android:textStyle="bold" + android:visibility="gone" tools:text="1." /> track.toMediaItem() is Profile.ArtistItem -> artist.toMediaItem() @@ -57,7 +57,8 @@ sealed class EchoMediaItem { is Lists.AlbumItem -> album.toMediaItem() is Lists.PlaylistItem -> playlist.toMediaItem() is Lists.RadioItem -> radio.toMediaItem() - } + }, + loadTrack ) fun sameAs(other: EchoMediaItem) = when (this) { diff --git a/common/src/main/java/dev/brahmkshatriya/echo/common/models/Radio.kt b/common/src/main/java/dev/brahmkshatriya/echo/common/models/Radio.kt index 851334c6..25a30778 100644 --- a/common/src/main/java/dev/brahmkshatriya/echo/common/models/Radio.kt +++ b/common/src/main/java/dev/brahmkshatriya/echo/common/models/Radio.kt @@ -7,6 +7,7 @@ data class Radio( val id: String, val title: String, val cover: ImageHolder? = null, + //NOT USED val tabs: List = listOf(), val subtitle: String? = null, val extras: Map = mapOf() diff --git a/common/src/main/java/dev/brahmkshatriya/echo/common/models/Shelf.kt b/common/src/main/java/dev/brahmkshatriya/echo/common/models/Shelf.kt index 3087adb7..d84fbf46 100644 --- a/common/src/main/java/dev/brahmkshatriya/echo/common/models/Shelf.kt +++ b/common/src/main/java/dev/brahmkshatriya/echo/common/models/Shelf.kt @@ -63,7 +63,8 @@ sealed class Shelf { data class Item( - val media: EchoMediaItem + val media: EchoMediaItem, + val loadTracks: Boolean = false ) : Shelf() data class Category( From 09d995e21e79225486b9e658909cf268f2c5c828 Mon Sep 17 00:00:00 2001 From: brahmkshatriya <69040506+brahmkshatriya@users.noreply.github.com> Date: Wed, 9 Oct 2024 12:21:01 +0530 Subject: [PATCH 3/7] Only show now playing when playing --- .../brahmkshatriya/echo/playback/Current.kt | 21 +++++++++++++++++-- .../playback/listeners/PlayerEventListener.kt | 10 ++++++--- .../echo/ui/adapter/GridViewHolder.kt | 3 ++- .../echo/ui/adapter/MediaItemViewHolder.kt | 7 +++---- .../echo/ui/adapter/ShelfViewHolder.kt | 8 +++---- .../echo/ui/item/ItemBottomSheet.kt | 5 ++--- 6 files changed, 36 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/Current.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/Current.kt index ea37ccb6..716902be 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/Current.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/Current.kt @@ -1,9 +1,26 @@ package dev.brahmkshatriya.echo.playback import androidx.media3.common.MediaItem +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track data class Current( val index: Int, val mediaItem: MediaItem, - val isLoaded: Boolean -) + val isLoaded: Boolean, + val isPlaying: Boolean, +) { + val context = lazy { mediaItem.context } + val track = lazy { mediaItem.track } + fun isPlaying(id: String): Boolean { + val same = mediaItem.mediaId == id + || context.value?.id == id + || track.value.album?.id == id + || track.value.artists.any { it.id == id } + return isPlaying && same + } + + companion object { + fun Current?.isPlaying(id: String): Boolean = this?.isPlaying(id) ?: false + } +} 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 f724bf1a..c9c09abe 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 @@ -32,7 +32,7 @@ class PlayerEventListener( private fun updateCurrent() { handler.removeCallbacks(runnable) - if (player.isPlaying) ResumptionUtils.saveCurrentPos(context, player.currentPosition) + ResumptionUtils.saveCurrentPos(context, player.currentPosition) handler.postDelayed(runnable, 1000) } @@ -52,14 +52,13 @@ class PlayerEventListener( private fun updateCurrentFlow() { currentFlow.value = player.currentMediaItem?.let { - Current(player.currentMediaItemIndex, it, it.isLoaded) + Current(player.currentMediaItemIndex, it, it.isLoaded, player.isPlaying) } } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { updateCurrentFlow() updateCustomLayout() - updateCurrent() } override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { @@ -76,4 +75,9 @@ class PlayerEventListener( updateCustomLayout() } + override fun onIsPlayingChanged(isPlaying: Boolean) { + updateCurrentFlow() + updateCurrent() + } + } 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 8622a3b5..fa8aa1a8 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 @@ -11,6 +11,7 @@ import dev.brahmkshatriya.echo.common.models.Shelf import dev.brahmkshatriya.echo.common.models.Track import dev.brahmkshatriya.echo.databinding.ItemShelfMediaGridBinding import dev.brahmkshatriya.echo.databinding.NewItemMediaTitleBinding +import dev.brahmkshatriya.echo.playback.Current.Companion.isPlaying 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 @@ -62,7 +63,7 @@ class GridViewHolder( } binding.isPlaying.toolTipOnClick() observe(listener.current) { - val playing = it?.mediaItem?.mediaId == media.id + val playing = it.isPlaying(media.id) binding.isPlaying.isVisible = playing if (playing) (binding.isPlaying.icon as Animatable).start() } 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 3951d958..d3c68573 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 @@ -14,7 +14,7 @@ import dev.brahmkshatriya.echo.databinding.NewItemMediaListsBinding import dev.brahmkshatriya.echo.databinding.NewItemMediaProfileBinding import dev.brahmkshatriya.echo.databinding.NewItemMediaTitleBinding import dev.brahmkshatriya.echo.databinding.NewItemMediaTrackBinding -import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.Current.Companion.isPlaying import dev.brahmkshatriya.echo.utils.loadInto import dev.brahmkshatriya.echo.utils.loadWith import dev.brahmkshatriya.echo.utils.observe @@ -54,7 +54,7 @@ sealed class MediaItemViewHolder( titleBinding.bind(item) val isPlaying = binding.cover.bind(item) observe(listener.current) { - isPlaying(it?.mediaItem?.context?.id == item.id) + isPlaying(it.isPlaying(item.id)) } } @@ -87,8 +87,7 @@ sealed class MediaItemViewHolder( titleBinding.bind(item) val isPlaying = binding.cover.bind(item) observe(listener.current) { - val media = it?.mediaItem - isPlaying(media?.mediaId == item.id) + isPlaying(it.isPlaying(item.id)) } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt index d8f328cc..d0abd7ad 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/ShelfViewHolder.kt @@ -29,7 +29,7 @@ import dev.brahmkshatriya.echo.databinding.ItemShelfListsBinding import dev.brahmkshatriya.echo.databinding.ItemShelfMediaBinding import dev.brahmkshatriya.echo.databinding.ItemShelfMediaListsBinding import dev.brahmkshatriya.echo.extensions.getExtension -import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.Current.Companion.isPlaying import dev.brahmkshatriya.echo.ui.adapter.GridViewHolder.Companion.ifGrid import dev.brahmkshatriya.echo.ui.adapter.MediaItemViewHolder.Companion.bind import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.Media.Companion.bind @@ -153,8 +153,7 @@ sealed class ShelfViewHolder( val media = (item as? Shelf.Item)?.media ?: return val isPlaying = binding.bind(media) observe(listener.current) { - val mediaItem = it?.mediaItem - isPlaying(mediaItem?.mediaId == media.id || mediaItem?.context?.id == media.id) + isPlaying(it.isPlaying(media.id)) } binding.more.setOnClickListener { listener.onLongClick(clientId, media, transitionView) @@ -209,8 +208,7 @@ sealed class ShelfViewHolder( if (media !is EchoMediaItem.Lists) return val isPlaying = binding.listsInfo.bind(media) observe(listener.current) { - val mediaItem = it?.mediaItem - isPlaying(mediaItem?.mediaId == media.id || mediaItem?.context?.id == media.id) + isPlaying(it.isPlaying(media.id)) } binding.listsInfo.more.setOnClickListener { listener.onLongClick(clientId, media, transitionView) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt index 9b41743f..f231aebe 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt @@ -30,7 +30,7 @@ import dev.brahmkshatriya.echo.databinding.ItemDialogButtonBinding import dev.brahmkshatriya.echo.databinding.ItemDialogButtonLoadingBinding import dev.brahmkshatriya.echo.extensions.getExtension import dev.brahmkshatriya.echo.offline.OfflineExtension -import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.Current.Companion.isPlaying import dev.brahmkshatriya.echo.ui.adapter.ShelfViewHolder.Media.Companion.bind import dev.brahmkshatriya.echo.ui.common.openFragment import dev.brahmkshatriya.echo.ui.editplaylist.AddToPlaylistBottomSheet @@ -84,8 +84,7 @@ class ItemBottomSheet : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { var isPlaying: (Boolean) -> Unit = {} observe(playerViewModel.currentFlow) { - val mediaItem = it?.mediaItem - isPlaying(mediaItem?.mediaId == item.id || mediaItem?.context?.id == item.id) + isPlaying(it.isPlaying(item.id)) } binding.itemContainer.run { more.run { From 823ae8fd01abd7b2006fa8f6d8ee364a247891b8 Mon Sep 17 00:00:00 2001 From: brahmkshatriya <69040506+brahmkshatriya@users.noreply.github.com> Date: Wed, 9 Oct 2024 18:25:08 +0530 Subject: [PATCH 4/7] Add support to open .eapk files --- app/src/main/AndroidManifest.xml | 13 + .../echo/ExtensionOpenerActivity.kt | 52 +++ .../dev/brahmkshatriya/echo/MainActivity.kt | 61 +--- .../echo/ui/common/OpenFragment.kt | 58 +++- .../echo/ui/extension/ApkLinkParser.kt | 320 ++++++++++++++++++ 5 files changed, 449 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/dev/brahmkshatriya/echo/ExtensionOpenerActivity.kt create mode 100644 app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ApkLinkParser.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20e89529..4807077d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,6 +68,19 @@ + + + + + + + + + + + getTempFile(uri) + else -> null + } + finish() + if (file == null) + Toast.makeText(this, "Could not find a file.", Toast.LENGTH_SHORT).show() + val startIntent = Intent(this, MainActivity::class.java) + startIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startIntent.data = file?.let { Uri.fromFile(it) } + startActivity(startIntent) + } + + private fun getTempFile(uri: Uri): File? { + val stream = contentResolver.openInputStream(uri) ?: return null + val bytes = stream.readBytes() + val tempFile = File.createTempFile("temp", ".apk", cacheDir) + tempFile.writeBytes(bytes) + return tempFile + } + + companion object { + const val EXTENSION_INSTALLER = "extensionInstaller" + fun FragmentActivity.openExtensionInstaller(uri: Uri) { + val apk = uri.toFile() + val supportedLinks = ApkLinkParser.getSupportedLinks(apk) + + + supportFragmentManager.setFragmentResultListener(EXTENSION_INSTALLER, this) { _, bundle -> + val file = bundle.getString("file")?.toUri()?.toFile() + file?.delete() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt b/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt index 5b206804..709090e4 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt @@ -18,16 +18,9 @@ import com.google.android.material.navigation.NavigationBarView import com.google.android.material.navigationrail.NavigationRailView import com.google.common.util.concurrent.ListenableFuture import dagger.hilt.android.AndroidEntryPoint -import dev.brahmkshatriya.echo.common.models.Album -import dev.brahmkshatriya.echo.common.models.Artist -import dev.brahmkshatriya.echo.common.models.EchoMediaItem -import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Companion.toMediaItem -import dev.brahmkshatriya.echo.common.models.Playlist -import dev.brahmkshatriya.echo.common.models.Track -import dev.brahmkshatriya.echo.common.models.User +import dev.brahmkshatriya.echo.ExtensionOpenerActivity.Companion.openExtensionInstaller import dev.brahmkshatriya.echo.databinding.ActivityMainBinding -import dev.brahmkshatriya.echo.ui.common.openFragment -import dev.brahmkshatriya.echo.ui.item.ItemFragment +import dev.brahmkshatriya.echo.ui.common.openItemFragmentFromUri import dev.brahmkshatriya.echo.ui.settings.LookFragment.Companion.NAVBAR_GRADIENT import dev.brahmkshatriya.echo.utils.animateTranslation import dev.brahmkshatriya.echo.utils.checkAudioPermissions @@ -38,7 +31,6 @@ import dev.brahmkshatriya.echo.utils.listenFuture import dev.brahmkshatriya.echo.utils.observe import dev.brahmkshatriya.echo.viewmodels.PlayerViewModel import dev.brahmkshatriya.echo.viewmodels.PlayerViewModel.Companion.connectPlayerToUI -import dev.brahmkshatriya.echo.viewmodels.SnackBar import dev.brahmkshatriya.echo.viewmodels.SnackBar.Companion.configureSnackBar import dev.brahmkshatriya.echo.viewmodels.UiViewModel import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.isNightMode @@ -83,9 +75,9 @@ class MainActivity : AppCompatActivity() { ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets -> uiViewModel.setSystemInsets(this, insets) - val navBarSize = uiViewModel.systemInsets.value.bottom + val navBarSize = uiViewModel.systemInsets.value.bottom val full = playerViewModel.settings.getBoolean(NAVBAR_GRADIENT, true) - navView.createNavDrawable(isRail,navBarSize, !full) + navView.createNavDrawable(isRail, navBarSize, !full) insets } @@ -152,47 +144,10 @@ class MainActivity : AppCompatActivity() { return } val uri = data - if (uri != null) { - fun createSnack(id: Int) { - val snackbar by viewModels() - val message = getString(id) - snackbar.create(SnackBar.Message(message)) - } - - val extensionType = uri.host - when (extensionType) { - "music" -> { - val extensionId = uri.pathSegments.firstOrNull() - if (extensionId == null) { - createSnack(R.string.error_no_client) - return - } - val type = uri.pathSegments.getOrNull(1) - val id = uri.pathSegments.getOrNull(2) - if (id == null) { - createSnack(R.string.error_no_id) - return - } - val name = uri.getQueryParameter("name").orEmpty() - val item: EchoMediaItem? = when (type) { - "user" -> User(id, name).toMediaItem() - "artist" -> Artist(id, name).toMediaItem() - "track" -> Track(id, name).toMediaItem() - "album" -> Album(id, name).toMediaItem() - "playlist" -> Playlist(id, name, false).toMediaItem() - else -> null - } - if (item == null) { - createSnack(R.string.error_invalid_type) - return - } - openFragment(ItemFragment.newInstance(extensionId, item)) - } - - else -> { - createSnack(R.string.invalid_extension_host) - } - } + println("URI: $uri") + when (uri?.scheme) { + "echo" -> openItemFragmentFromUri(uri) + "file" -> openExtensionInstaller(uri) } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/common/OpenFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/common/OpenFragment.kt index bd0da82e..7cf22e87 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/common/OpenFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/common/OpenFragment.kt @@ -1,12 +1,23 @@ package dev.brahmkshatriya.echo.ui.common +import android.net.Uri import android.os.Bundle import android.view.View +import androidx.activity.viewModels import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.fragment.app.activityViewModels import androidx.fragment.app.commit import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Companion.toMediaItem +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.common.models.User +import dev.brahmkshatriya.echo.ui.item.ItemFragment +import dev.brahmkshatriya.echo.viewmodels.SnackBar import dev.brahmkshatriya.echo.viewmodels.UiViewModel fun Fragment.openFragment(newFragment: Fragment, view: View? = null) { @@ -14,7 +25,7 @@ fun Fragment.openFragment(newFragment: Fragment, view: View? = null) { if (view != null) { addSharedElement(view, view.transitionName) newFragment.run { - if(arguments == null) arguments = Bundle() + if (arguments == null) arguments = Bundle() arguments!!.putString("transitionName", view.transitionName) } } @@ -31,4 +42,47 @@ fun Fragment.openFragment(newFragment: Fragment, view: View? = null) { fun FragmentActivity.openFragment(newFragment: Fragment, view: View? = null) { val oldFragment = supportFragmentManager.findFragmentById(R.id.navHostFragment)!! oldFragment.openFragment(newFragment, view) -} \ No newline at end of file +} + +fun FragmentActivity.openItemFragmentFromUri(uri: Uri) { + fun createSnack(id: Int) { + val snackbar by viewModels() + val message = getString(id) + snackbar.create(SnackBar.Message(message)) + } + + val extensionType = uri.host + when (extensionType) { + "music" -> { + val extensionId = uri.pathSegments.firstOrNull() + if (extensionId == null) { + createSnack(R.string.error_no_client) + return + } + val type = uri.pathSegments.getOrNull(1) + val id = uri.pathSegments.getOrNull(2) + if (id == null) { + createSnack(R.string.error_no_id) + return + } + val name = uri.getQueryParameter("name").orEmpty() + val item: EchoMediaItem? = when (type) { + "user" -> User(id, name).toMediaItem() + "artist" -> Artist(id, name).toMediaItem() + "track" -> Track(id, name).toMediaItem() + "album" -> Album(id, name).toMediaItem() + "playlist" -> Playlist(id, name, false).toMediaItem() + else -> null + } + if (item == null) { + createSnack(R.string.error_invalid_type) + return + } + openFragment(ItemFragment.newInstance(extensionId, item)) + } + + else -> { + createSnack(R.string.invalid_extension_host) + } + } +} diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ApkLinkParser.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ApkLinkParser.kt new file mode 100644 index 00000000..c06ad968 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ApkLinkParser.kt @@ -0,0 +1,320 @@ +package dev.brahmkshatriya.echo.ui.extension + +import org.w3c.dom.Document +import org.w3c.dom.Node +import org.xml.sax.Attributes +import org.xml.sax.helpers.DefaultHandler +import java.io.File +import java.io.InputStream +import java.util.Stack +import java.util.zip.ZipFile +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.SAXParserFactory + +class ApkLinkParser { + companion object { + fun getSupportedLinks(apkFile: File): List { + val zip = ZipFile(apkFile) + val entry = zip.getEntry("AndroidManifest.xml") + val manifest = parse(zip.getInputStream(entry)) ?: return listOf() + return manifest.getElementsByTagName("intent-filter").run { + (0 until length).flatMap { index -> + val intentFilter = item(index) + val schemes = mutableListOf() + val hosts = mutableListOf() + val paths = mutableListOf() + + intentFilter.childNodes.run { + for (i in 0 until length) { + val node = item(i) + fun data(name: String) = node.attributes.getNamedItem(name)?.nodeValue + if (node.nodeName == "data") { + data("android:scheme")?.let { schemes.add(it) } + data("android:host")?.let { hosts.add(it) } + data("android:path")?.let { + paths.add(it) + } + } + } + + schemes.flatMap { scheme -> + hosts.flatMap { host -> + if (paths.isEmpty()) listOf("$scheme://$host") + else paths.map { path -> "$scheme://$host$path" } + } + } + } + } + } + } + + private fun parse(input: InputStream) = runCatching { + val xmlDom = XmlDom() + runCatching { + CompressedXmlParser(xmlDom).parse(input) + }.getOrElse { + NonCompressedXmlParser(xmlDom).parse(input) + } + xmlDom.document + }.getOrNull() + } + + class Attribute { + var name: String? = null + var prefix: String? = null + var namespace: String? = null + var value: String? = null + } + + class XmlDom { + val document: Document = + DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument() + private val mStack = Stack() + private fun isEmpty(text: String?) = (text == null) || ("" == text) + fun startDocument() { mStack.push(document) } + fun startElement( + uri: String?, localName: String?, qName: String?, attrs: Array + ) { + val elt = if (isEmpty(uri)) document.createElement(localName) + else document.createElementNS(uri, qName) + + for (attr in attrs) { + if (isEmpty(attr.namespace)) elt.setAttribute(attr.name, attr.value) + else elt.setAttributeNS(attr.namespace, attr.prefix + ':' + attr.name, attr.value) + } + mStack.peek()!!.appendChild(elt) + mStack.push(elt) + } + fun endElement() { mStack.pop() } + fun text(data: String?) { mStack.peek()!!.appendChild(document.createTextNode(data)) } + fun characterData(data: String?) { + mStack.peek()!!.appendChild(document.createCDATASection(data)) + } + } + + //NOT TESTED + class NonCompressedXmlParser(private val mListener: XmlDom) { + fun parse(input: InputStream) { + val factory = SAXParserFactory.newInstance() + val saxParser = factory.newSAXParser() + val handler = object : DefaultHandler() { + override fun startDocument() = mListener.startDocument() + override fun startElement( + uri: String?, localName: String?, qName: String, attributes: Attributes + ) { + val attrs = Array(attributes.length) { i -> + Attribute().apply { + name = attributes.getQName(i) + namespace = uri + value = attributes.getValue(i) + } + } + mListener.startElement(uri, localName, qName, attrs) + } + + override fun characters(ch: CharArray, start: Int, length: Int) { + val data = String(ch, start, length) + mListener.characterData(data) + } + + override fun endElement(uri: String?, localName: String?, qName: String) = + mListener.endElement() + + override fun endDocument() {} + } + + saxParser.parse(input, handler) + } + } + + class CompressedXmlParser(private val mListener: XmlDom) { + + fun parse(input: InputStream) { + mData = ByteArray(input.available()) + input.read(mData) + input.close() + parseCompressedXml() + } + + private fun parseCompressedXml() { + var word0: Int + + while (mParserOffset < mData.size) { + word0 = getLEWord(mParserOffset) + when (word0) { + WORD_START_DOCUMENT -> parseStartDocument() + WORD_STRING_TABLE -> parseStringTable() + WORD_RES_TABLE -> parseResourceTable() + WORD_START_NS -> parseNamespace(true) + WORD_END_NS -> parseNamespace(false) + WORD_START_TAG -> parseStartTag() + WORD_END_TAG -> parseEndTag() + WORD_TEXT -> parseText() + else -> mParserOffset += WORD_SIZE + } + } + } + + private fun parseStartDocument() { + mListener.startDocument() + mParserOffset += (2 * WORD_SIZE) + } + + private fun parseStringTable() { + val chunk = getLEWord(mParserOffset + (1 * WORD_SIZE)) + mStringsCount = getLEWord(mParserOffset + (2 * WORD_SIZE)) + mStylesCount = getLEWord(mParserOffset + (3 * WORD_SIZE)) + val strOffset = mParserOffset + getLEWord(mParserOffset + (5 * WORD_SIZE)) + mStringsTable = arrayOfNulls(mStringsCount) + var offset: Int + for (i in 0 until mStringsCount) { + offset = (strOffset + getLEWord(mParserOffset + ((i + 7) * WORD_SIZE))) + mStringsTable[i] = getStringFromStringTable(offset) + } + mParserOffset += chunk + } + + private fun parseResourceTable() { + val chunk = getLEWord(mParserOffset + (1 * WORD_SIZE)) + mResCount = (chunk / 4) - 2 + mResourcesIds = IntArray(mResCount) + for (i in 0 until mResCount) { + mResourcesIds[i] = getLEWord(mParserOffset + ((i + 2) * WORD_SIZE)) + } + mParserOffset += chunk + } + + private fun parseNamespace(start: Boolean) { + val prefixIdx = getLEWord(mParserOffset + (4 * WORD_SIZE)) + val uriIdx = getLEWord(mParserOffset + (5 * WORD_SIZE)) + val uri = getString(uriIdx) + val prefix = getString(prefixIdx) + if (start) mNamespaces[uri] = prefix + else mNamespaces.remove(uri) + mParserOffset += (6 * WORD_SIZE) + } + + private fun parseStartTag() { + val uriIdx = getLEWord(mParserOffset + (4 * WORD_SIZE)) + val nameIdx = getLEWord(mParserOffset + (5 * WORD_SIZE)) + val attrCount = getLEShort(mParserOffset + (7 * WORD_SIZE)) + val name = getString(nameIdx) + val (uri, qName) = if (uriIdx == -0x1) "" to name else { + val uri = getString(uriIdx) + uri to if (mNamespaces.containsKey(uri)) mNamespaces[uri] + ':' + name + else name + } + mParserOffset += (9 * WORD_SIZE) + val attrs = Array(attrCount) { + parseAttribute().also { mParserOffset += (5 * 4) } + } + mListener.startElement(uri, name, qName, attrs) + } + + private fun parseAttribute(): Attribute { + val attrNSIdx = getLEWord(mParserOffset) + val attrNameIdx = getLEWord(mParserOffset + (1 * WORD_SIZE)) + val attrValueIdx = getLEWord(mParserOffset + (2 * WORD_SIZE)) + val attrType = getLEWord(mParserOffset + (3 * WORD_SIZE)) + val attrData = getLEWord(mParserOffset + (4 * WORD_SIZE)) + + val attr = Attribute() + attr.name = getString(attrNameIdx) + + if (attrNSIdx == -0x1) { + attr.namespace = null + attr.prefix = null + } else { + val uri = getString(attrNSIdx) + if (mNamespaces.containsKey(uri)) { + attr.namespace = uri + attr.prefix = mNamespaces[uri] + } + } + attr.value = if (attrValueIdx == -0x1) getAttributeValue(attrType, attrData) + else getString(attrValueIdx) + + return attr + } + + private fun parseText() { + val strIndex = getLEWord(mParserOffset + (4 * WORD_SIZE)) + val data = getString(strIndex) + mListener.characterData(data) + mParserOffset += (7 * WORD_SIZE) + } + + private fun parseEndTag() { + mListener.endElement() + mParserOffset += (6 * WORD_SIZE) + } + + private fun getString(index: Int): String? { + val res = if ((index >= 0) && (index < mStringsCount)) mStringsTable[index] else null + return res + } + + private fun getStringFromStringTable(offset: Int): String { + val strLength: Int + val chars: ByteArray + if (mData[offset + 1] == mData[offset]) { + strLength = mData[offset].toInt() + chars = ByteArray(strLength) + for (i in 0 until strLength) { + chars[i] = mData[offset + 2 + i] + } + } else { + strLength = + (((mData[offset + 1].toInt() shl 8) and 0xFF00) or (mData[offset].toInt() and 0xFF)) + chars = ByteArray(strLength) + for (i in 0 until strLength) { + chars[i] = mData[offset + 2 + (i * 2)] + } + } + return String(chars) + } + + private fun getLEWord(off: Int) = (((mData[off + 3].toInt() shl 24) and -0x1000000) + or ((mData[off + 2].toInt() shl 16) and 0x00ff0000) + or ((mData[off + 1].toInt() shl 8) and 0x0000ff00) + or ((mData[off + 0].toInt() shl 0) and 0x000000ff)) + + private fun getLEShort(off: Int) = + (((mData[off + 1].toInt() shl 8) and 0xff00) or ((mData[off + 0].toInt() shl 0) and 0x00ff)) + + private fun getAttributeValue(type: Int, data: Int) = when (type) { + TYPE_STRING -> getString(data) + TYPE_ID_REF -> String.format("@id/0x%08X", data) + TYPE_ATTR_REF -> String.format("?id/0x%08X", data) + else -> String.format("%08X/0x%08X", type, data) + } + + private val mNamespaces: MutableMap = HashMap() + private lateinit var mData: ByteArray + + private lateinit var mStringsTable: Array + private lateinit var mResourcesIds: IntArray + private var mStringsCount = 0 + private var mStylesCount = 0 + private var mResCount = 0 + private var mParserOffset = 0 + + companion object { + const val WORD_START_DOCUMENT: Int = 0x00080003 + + const val WORD_STRING_TABLE: Int = 0x001C0001 + const val WORD_RES_TABLE: Int = 0x00080180 + + const val WORD_START_NS: Int = 0x00100100 + const val WORD_END_NS: Int = 0x00100101 + const val WORD_START_TAG: Int = 0x00100102 + const val WORD_END_TAG: Int = 0x00100103 + const val WORD_TEXT: Int = 0x00100104 + const val WORD_SIZE: Int = 4 + + private const val TYPE_ID_REF = 0x01000008 + private const val TYPE_ATTR_REF = 0x02000008 + private const val TYPE_STRING = 0x03000008 + } + } +} \ No newline at end of file From be26dd3457f3c96fb78affb6b5659efa574876ae Mon Sep 17 00:00:00 2001 From: brahmkshatriya <69040506+brahmkshatriya@users.noreply.github.com> Date: Fri, 11 Oct 2024 18:50:22 +0530 Subject: [PATCH 5/7] Add support to listen to cached songs, very experimental --- .../brahmkshatriya/echo/di/ExtensionModule.kt | 7 ++- .../echo/offline/OfflineExtension.kt | 39 ++++++++++++--- .../echo/playback/MediaItemUtils.kt | 6 +-- .../playback/listeners/PlayerEventListener.kt | 8 ++- .../playback/source/CustomCacheDataSource.kt | 50 +++++++++++++++++++ .../echo/playback/source/MediaFactory.kt | 5 +- .../echo/playback/source/MediaResolver.kt | 17 ++++++- .../echo/ui/adapter/GridViewHolder.kt | 3 +- .../echo/ui/adapter/MediaItemViewHolder.kt | 5 +- .../echo/ui/adapter/TrackViewHolder.kt | 6 ++- .../echo/utils/AnimationUtils.kt | 2 +- app/src/main/res/values/strings.xml | 2 + 12 files changed, 125 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/dev/brahmkshatriya/echo/playback/source/CustomCacheDataSource.kt 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 From a62e8a243f02018e5ba4d190b6e6091a708914c6 Mon Sep 17 00:00:00 2001 From: brahmkshatriya <69040506+brahmkshatriya@users.noreply.github.com> Date: Tue, 15 Oct 2024 17:59:09 +0530 Subject: [PATCH 6/7] Add support to open .eapk files --- app/src/main/AndroidManifest.xml | 14 +- .../brahmkshatriya/echo/EchoApplication.kt | 2 + .../echo/ExtensionOpenerActivity.kt | 101 +++++++++++-- .../dev/brahmkshatriya/echo/PlayerService.kt | 3 +- .../echo/extensions/ExtensionLoader.kt | 61 ++------ .../echo/extensions/ExtensionRepo.kt | 98 +++++++++++++ .../echo/extensions/ExtensionUtils.kt | 33 +---- .../echo/extensions/SettingsInjector.kt | 25 ---- .../extensions/plugger/ApkManifestParser.kt | 2 +- .../extensions/plugger/FileChangeListener.kt | 13 ++ .../extensions/plugger/FilePluginSource.kt | 25 ++-- .../echo/extensions/plugger/LazyPluginRepo.kt | 2 +- .../extensions/plugger/LazyPluginRepoImpl.kt | 2 +- ...tensionRepo.kt => BuiltInExtensionRepo.kt} | 4 +- .../ExtensionInstallerBottomSheet.kt | 121 ++++++++++++++++ .../echo/ui/settings/AudioFragment.kt | 2 +- .../{ui/extension => utils}/ApkLinkParser.kt | 2 +- .../echo/viewmodels/SnackBar.kt | 15 +- .../res/layout/dialog_extension_installer.xml | 133 ++++++++++++++++++ app/src/main/res/values/strings.xml | 10 ++ app/src/main/res/xml/provider_paths.xml | 4 + .../echo/common/helpers/ExtensionType.kt | 6 +- .../echo/common/helpers/ImportType.kt | 2 +- 23 files changed, 538 insertions(+), 142 deletions(-) create mode 100644 app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt create mode 100644 app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FileChangeListener.kt rename app/src/main/java/dev/brahmkshatriya/echo/offline/{LocalExtensionRepo.kt => BuiltInExtensionRepo.kt} (88%) create mode 100644 app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt rename app/src/main/java/dev/brahmkshatriya/echo/{ui/extension => utils}/ApkLinkParser.kt (99%) create mode 100644 app/src/main/res/layout/dialog_extension_installer.xml create mode 100644 app/src/main/res/xml/provider_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4807077d..a3538b5a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -75,9 +75,8 @@ - - + @@ -112,6 +111,17 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/EchoApplication.kt b/app/src/main/java/dev/brahmkshatriya/echo/EchoApplication.kt index 0159df94..335407e7 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/EchoApplication.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/EchoApplication.kt @@ -10,6 +10,7 @@ import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColorsOptions import com.google.android.material.color.ThemeUtils import dagger.hilt.android.HiltAndroidApp +import dev.brahmkshatriya.echo.ExtensionOpenerActivity.Companion.cleanupTempApks import dev.brahmkshatriya.echo.ui.exception.ExceptionFragment.Companion.getDetails import dev.brahmkshatriya.echo.ui.exception.ExceptionFragment.Companion.getTitle import dev.brahmkshatriya.echo.ui.settings.LookFragment.Companion.AMOLED_KEY @@ -39,6 +40,7 @@ class EchoApplication : Application() { //UI applyLocale(settings) applyUiChanges(this, settings) + cleanupTempApks() //Crash Handling Thread.setDefaultUncaughtExceptionHandler { _, exception -> diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ExtensionOpenerActivity.kt b/app/src/main/java/dev/brahmkshatriya/echo/ExtensionOpenerActivity.kt index e5dfa96e..ab2fb987 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ExtensionOpenerActivity.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ExtensionOpenerActivity.kt @@ -1,13 +1,29 @@ package dev.brahmkshatriya.echo import android.app.Activity +import android.content.Context import android.content.Intent +import android.content.pm.PackageInfo import android.net.Uri import android.widget.Toast +import androidx.activity.viewModels +import androidx.core.content.FileProvider import androidx.core.net.toFile import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity -import dev.brahmkshatriya.echo.ui.extension.ApkLinkParser +import androidx.lifecycle.lifecycleScope +import dev.brahmkshatriya.echo.common.helpers.ExtensionType +import dev.brahmkshatriya.echo.common.helpers.ImportType +import dev.brahmkshatriya.echo.extensions.ExtensionRepo.Companion.FEATURE +import dev.brahmkshatriya.echo.extensions.ExtensionRepo.Companion.getPluginFileDir +import dev.brahmkshatriya.echo.extensions.plugger.ApkManifestParser +import dev.brahmkshatriya.echo.extensions.plugger.ApkPluginSource +import dev.brahmkshatriya.echo.extensions.plugger.FileChangeListener +import dev.brahmkshatriya.echo.ui.extension.ExtensionInstallerBottomSheet +import dev.brahmkshatriya.echo.viewmodels.LoginUserViewModel +import dev.brahmkshatriya.echo.viewmodels.SnackBar +import dev.brahmkshatriya.echo.viewmodels.SnackBar.Companion.createSnack +import kotlinx.coroutines.launch import java.io.File @@ -15,13 +31,17 @@ class ExtensionOpenerActivity : Activity() { override fun onStart() { super.onStart() val uri = intent.data + val file = when (uri?.scheme) { "content" -> getTempFile(uri) else -> null } + + if (file == null) Toast.makeText( + this, getString(R.string.could_not_find_the_file), Toast.LENGTH_SHORT + ).show() + finish() - if (file == null) - Toast.makeText(this, "Could not find a file.", Toast.LENGTH_SHORT).show() val startIntent = Intent(this, MainActivity::class.java) startIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startIntent.data = file?.let { Uri.fromFile(it) } @@ -31,22 +51,85 @@ class ExtensionOpenerActivity : Activity() { private fun getTempFile(uri: Uri): File? { val stream = contentResolver.openInputStream(uri) ?: return null val bytes = stream.readBytes() - val tempFile = File.createTempFile("temp", ".apk", cacheDir) + val tempFile = File.createTempFile("temp", ".apk", getTempApkDir()) tempFile.writeBytes(bytes) return tempFile } companion object { const val EXTENSION_INSTALLER = "extensionInstaller" + + fun Context.getTempApkDir() = File(cacheDir, "apks").apply { mkdirs() } + + fun Context.cleanupTempApks() { + getTempApkDir().deleteRecursively() + } + fun FragmentActivity.openExtensionInstaller(uri: Uri) { - val apk = uri.toFile() - val supportedLinks = ApkLinkParser.getSupportedLinks(apk) + ExtensionInstallerBottomSheet.newInstance(uri.toString()) + .show(supportFragmentManager, null) - supportFragmentManager.setFragmentResultListener(EXTENSION_INSTALLER, this) { _, bundle -> - val file = bundle.getString("file")?.toUri()?.toFile() - file?.delete() + supportFragmentManager.setFragmentResultListener(EXTENSION_INSTALLER, this) { _, b -> + val file = b.getString("file")?.toUri()?.toFile() + val install = b.getBoolean("install") + val installAsApk = b.getBoolean("installAsApk") + val context = this + if (install && file != null) lifecycleScope.launch { + val installation = if (installAsApk) openApk(context, file) + else { + val viewModel by viewModels() + val extensionLoader = viewModel.extensionLoader + installAsFile(context, file, extensionLoader.fileListener) + } + val exception = installation.exceptionOrNull() + if (exception != null) { + val viewModel by viewModels() + viewModel.throwableFlow.emit(exception) + } else if (!installAsApk) + createSnack(getString(R.string.extension_installed_successfully)) + } } } + + private fun openApk(context: Context, file: File) = runCatching { + val contentUri = FileProvider.getUriForFile( + context, context.packageName + ".provider", file + ) + val installIntent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + data = contentUri + } + context.startActivity(installIntent) + } + + private suspend fun installAsFile( + context: Context, file: File, fileChangeListener: FileChangeListener + ) = runCatching { + val packageInfo = context.packageManager.getPackageArchiveInfo( + file.path, ApkPluginSource.PACKAGE_FLAGS + ) + val type = getType(packageInfo!!) + val metadata = ApkManifestParser(ImportType.File) + .parseManifest(packageInfo.applicationInfo!!) + val dir = context.getPluginFileDir(type) + dir.setWritable(true) + val newFile = File(dir, "${metadata.id}.apk") + val flow = fileChangeListener.getFlow(type) + flow.emit(newFile) + newFile.setWritable(true) + if (newFile.exists()) newFile.delete() + file.copyTo(newFile, true) + dir.setReadOnly() + flow.emit(null) + } + + fun getType(appInfo: PackageInfo) = appInfo.reqFeatures?.find { featureInfo -> + ExtensionType.entries.any { featureInfo.name == "$FEATURE${it.feature}" } + }?.let { featureInfo -> + ExtensionType.entries.first { it.feature == featureInfo.name.removePrefix(FEATURE) } + } ?: error("Extension type not found for ${appInfo.packageName}") } } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt b/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt index 013c4d7f..138878ec 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/PlayerService.kt @@ -137,7 +137,6 @@ class PlayerService : MediaLibraryService() { } } - //TODO: Open .eapk files //TODO: extension updater //TODO: Spotify //TODO: EQ, Pitch, Tempo, Reverb & Sleep Timer(5m, 10m, 15m, 30m, 45m, 1hr, End of track) @@ -157,7 +156,7 @@ class PlayerService : MediaLibraryService() { } override fun onTaskRemoved(rootIntent: Intent?) { - val stopPlayer = settings.getBoolean(CLOSE_PLAYER, false) + val stopPlayer = settings.getBoolean(CLOSE_PLAYER, true) val player = mediaSession?.player ?: return stopSelf() if (stopPlayer || !player.isPlaying) stopSelf() } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt index 7734cb11..e9d14a75 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt @@ -6,9 +6,9 @@ import dev.brahmkshatriya.echo.common.Extension import dev.brahmkshatriya.echo.common.LyricsExtension import dev.brahmkshatriya.echo.common.MusicExtension import dev.brahmkshatriya.echo.common.TrackerExtension +import dev.brahmkshatriya.echo.common.clients.ExtensionClient import dev.brahmkshatriya.echo.common.clients.LoginClient import dev.brahmkshatriya.echo.common.helpers.ExtensionType -import dev.brahmkshatriya.echo.common.helpers.ImportType import dev.brahmkshatriya.echo.common.models.Metadata import dev.brahmkshatriya.echo.common.providers.LyricsClientsProvider import dev.brahmkshatriya.echo.common.providers.MusicClientsProvider @@ -17,16 +17,9 @@ import dev.brahmkshatriya.echo.db.ExtensionDao import dev.brahmkshatriya.echo.db.UserDao import dev.brahmkshatriya.echo.db.models.UserEntity import dev.brahmkshatriya.echo.db.models.UserEntity.Companion.toUser -import dev.brahmkshatriya.echo.extensions.plugger.AndroidPluginLoader -import dev.brahmkshatriya.echo.extensions.plugger.ApkFileManifestParser -import dev.brahmkshatriya.echo.extensions.plugger.ApkManifestParser -import dev.brahmkshatriya.echo.extensions.plugger.ApkPluginSource -import dev.brahmkshatriya.echo.extensions.plugger.FilePluginSource -import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepo -import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepoImpl -import dev.brahmkshatriya.echo.extensions.plugger.LazyRepoComposer +import dev.brahmkshatriya.echo.extensions.plugger.FileChangeListener import dev.brahmkshatriya.echo.extensions.plugger.PackageChangeListener -import dev.brahmkshatriya.echo.offline.LocalExtensionRepo +import dev.brahmkshatriya.echo.offline.BuiltInExtensionRepo import dev.brahmkshatriya.echo.offline.OfflineExtension import dev.brahmkshatriya.echo.utils.catchWith import kotlinx.coroutines.CoroutineName @@ -46,7 +39,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -import java.io.File class ExtensionLoader( context: Context, @@ -63,40 +55,13 @@ class ExtensionLoader( private val extensionFlow: MutableStateFlow, ) { private val scope = MainScope() + CoroutineName("ExtensionLoader") - - private fun Context.getPluginFileDir() = File(filesDir, "extensions").apply { mkdirs() } private val listener = PackageChangeListener(context) - private fun getComposed( - context: Context, - suffix: String, - vararg repo: LazyPluginRepo - ): LazyPluginRepo { - val loader = AndroidPluginLoader(context) - val apkFilePluginRepo = LazyPluginRepoImpl( - FilePluginSource(context.getPluginFileDir(), ".eapk"), - ApkFileManifestParser(context.packageManager, ApkManifestParser(ImportType.Apk)), - loader, - ) - val appPluginRepo = LazyPluginRepoImpl( - ApkPluginSource(listener, context, "dev.brahmkshatriya.echo.$suffix"), - ApkManifestParser(ImportType.App), - loader - ) - return LazyRepoComposer(appPluginRepo, apkFilePluginRepo, *repo) - } - - private val musicExtensionRepo = MusicExtensionRepo( - context, - getComposed(context, "music", LocalExtensionRepo(offlineExtension)) - ) - - private val trackerExtensionRepo = TrackerExtensionRepo( - context, getComposed(context, "tracker") - ) + val fileListener = FileChangeListener(scope) + private val builtIn = BuiltInExtensionRepo(offlineExtension) - private val lyricsExtensionRepo = LyricsExtensionRepo( - context, getComposed(context, "lyrics") - ) + private val musicExtensionRepo = MusicExtensionRepo(context, listener, fileListener, builtIn) + private val trackerExtensionRepo = TrackerExtensionRepo(context, listener, fileListener) + private val lyricsExtensionRepo = LyricsExtensionRepo(context, listener, fileListener) val trackers = trackerListFlow val extensions = extensionListFlow @@ -192,7 +157,7 @@ class ExtensionLoader( val lyrics = MutableStateFlow(null) val music = MutableStateFlow(null) scope.launch { - trackerExtensionRepo.getPlugins(ExtensionType.TRACKER) { list -> + trackerExtensionRepo.getPlugins { list -> val trackerExtensions = list.map { (metadata, client) -> TrackerExtension(metadata, client) } @@ -202,7 +167,7 @@ class ExtensionLoader( } } scope.launch { - lyricsExtensionRepo.getPlugins(ExtensionType.LYRICS) { list -> + lyricsExtensionRepo.getPlugins { list -> val lyricsExtensions = list.map { (metadata, client) -> LyricsExtension(metadata, client) } @@ -215,7 +180,7 @@ class ExtensionLoader( trackers.first { it != null } scope.launch { - musicExtensionRepo.getPlugins(ExtensionType.MUSIC) { list -> + musicExtensionRepo.getPlugins { list -> val extensions = list.map { (metadata, client) -> MusicExtension(metadata, client) } @@ -232,8 +197,8 @@ class ExtensionLoader( music.first { it != null } } - private suspend fun LazyPluginRepo.getPlugins( - type: ExtensionType, collector: FlowCollector>>>> + private suspend fun ExtensionRepo.getPlugins( + collector: FlowCollector>>>> ) = getAllPlugins().catchWith(throwableFlow).map { list -> list.mapNotNull { result -> val (metadata, client) = result.getOrElse { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt new file mode 100644 index 00000000..9482aac5 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt @@ -0,0 +1,98 @@ +package dev.brahmkshatriya.echo.extensions + +import android.content.Context +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.clients.LyricsClient +import dev.brahmkshatriya.echo.common.clients.TrackerClient +import dev.brahmkshatriya.echo.common.helpers.ExtensionType +import dev.brahmkshatriya.echo.common.helpers.ImportType +import dev.brahmkshatriya.echo.common.models.Metadata +import dev.brahmkshatriya.echo.extensions.plugger.AndroidPluginLoader +import dev.brahmkshatriya.echo.extensions.plugger.ApkFileManifestParser +import dev.brahmkshatriya.echo.extensions.plugger.ApkManifestParser +import dev.brahmkshatriya.echo.extensions.plugger.ApkPluginSource +import dev.brahmkshatriya.echo.extensions.plugger.FileChangeListener +import dev.brahmkshatriya.echo.extensions.plugger.FilePluginSource +import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepo +import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepoImpl +import dev.brahmkshatriya.echo.extensions.plugger.LazyRepoComposer +import dev.brahmkshatriya.echo.extensions.plugger.PackageChangeListener +import dev.brahmkshatriya.echo.extensions.plugger.lazily +import tel.jeelpa.plugger.utils.mapState +import java.io.File + +sealed class ExtensionRepo( + private val context: Context, + private val listener: PackageChangeListener, + private val fileChangeListener: FileChangeListener, + private vararg val repo: LazyPluginRepo +) : LazyPluginRepo { + abstract val type: ExtensionType + + private val composed by lazy { + val loader = AndroidPluginLoader(context) + val dir = context.getPluginFileDir(type) + val apkFilePluginRepo = LazyPluginRepoImpl( + FilePluginSource(dir, fileChangeListener.scope, fileChangeListener.getFlow(type)), + ApkFileManifestParser(context.packageManager, ApkManifestParser(ImportType.File)), + loader, + ) + val appPluginRepo = LazyPluginRepoImpl( + ApkPluginSource(listener, context, "$FEATURE${type.feature}"), + ApkManifestParser(ImportType.App), + loader + ) + LazyRepoComposer(appPluginRepo, apkFilePluginRepo, *repo) + } + + private fun injected() = composed.getAllPlugins().mapState { list -> + list.map { + runCatching { + val plugin = it.getOrThrow() + val metadata = plugin.first + metadata to lazily { + val instance = plugin.second.value.getOrThrow() + //Injection + instance.setSettings(getSettings(context, type, metadata)) + + instance + } + } + } + } + + override fun getAllPlugins() = injected() + + companion object { + const val FEATURE = "dev.brahmkshatriya.echo." + fun Context.getPluginFileDir(type: ExtensionType) = + File(filesDir, type.feature).apply { mkdirs() } + } +} + +class MusicExtensionRepo( + context: Context, + listener: PackageChangeListener, + fileChangeListener: FileChangeListener, + vararg repo: LazyPluginRepo +) : ExtensionRepo(context, listener, fileChangeListener, *repo) { + override val type = ExtensionType.MUSIC +} + +class TrackerExtensionRepo( + context: Context, + listener: PackageChangeListener, + fileChangeListener: FileChangeListener, + vararg repo: LazyPluginRepo +) : ExtensionRepo(context, listener, fileChangeListener, *repo) { + override val type = ExtensionType.TRACKER +} + +class LyricsExtensionRepo( + context: Context, + listener: PackageChangeListener, + fileChangeListener: FileChangeListener, + vararg repo: LazyPluginRepo +) : ExtensionRepo(context, listener, fileChangeListener, *repo) { + override val type = ExtensionType.LYRICS +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionUtils.kt index 53b34ec8..f7fc8bd3 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionUtils.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionUtils.kt @@ -1,13 +1,7 @@ package dev.brahmkshatriya.echo.extensions -import android.content.Context import dev.brahmkshatriya.echo.common.Extension import dev.brahmkshatriya.echo.common.clients.ExtensionClient -import dev.brahmkshatriya.echo.common.clients.LyricsClient -import dev.brahmkshatriya.echo.common.clients.TrackerClient -import dev.brahmkshatriya.echo.common.helpers.ExtensionType -import dev.brahmkshatriya.echo.common.models.Metadata -import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepo import dev.brahmkshatriya.echo.ui.exception.AppException.Companion.toAppException import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow @@ -38,29 +32,4 @@ suspend inline fun Extension<*>.get( inline fun Extension<*>.isClient() = instance.value.getOrNull() is T fun StateFlow>?>.getExtension(id: String?) = - value?.find { it.metadata.id == id } - -class MusicExtensionRepo( - private val context: Context, - private val pluginRepo: LazyPluginRepo -) : LazyPluginRepo { - - override fun getAllPlugins() = pluginRepo.getAllPlugins() - .injectSettings(ExtensionType.MUSIC, context) -} - -class TrackerExtensionRepo( - private val context: Context, - private val pluginRepo: LazyPluginRepo -) : LazyPluginRepo { - override fun getAllPlugins() = pluginRepo.getAllPlugins() - .injectSettings(ExtensionType.TRACKER, context) -} - -class LyricsExtensionRepo( - private val context: Context, - private val pluginRepo: LazyPluginRepo -) : LazyPluginRepo { - override fun getAllPlugins() = pluginRepo.getAllPlugins() - .injectSettings(ExtensionType.LYRICS, context) -} \ No newline at end of file + value?.find { it.metadata.id == id } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/SettingsInjector.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/SettingsInjector.kt index 52157396..173d75bc 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/SettingsInjector.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/SettingsInjector.kt @@ -3,34 +3,9 @@ package dev.brahmkshatriya.echo.extensions import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit -import dev.brahmkshatriya.echo.common.clients.ExtensionClient import dev.brahmkshatriya.echo.common.helpers.ExtensionType import dev.brahmkshatriya.echo.common.models.Metadata import dev.brahmkshatriya.echo.common.settings.Settings -import kotlinx.coroutines.flow.StateFlow -import tel.jeelpa.plugger.utils.mapState - -inline fun StateFlow>>>>>.injectSettings( - type: ExtensionType, - context: Context -) = mapState { list -> - list.map { - runCatching { - val plugin = it.getOrThrow() - val metadata = plugin.first - Pair( - metadata, - lazy { - runCatching { - val instance = plugin.second.value.getOrThrow() - instance.setSettings(getSettings(context, type, metadata)) - instance - } - } - ) - } - } -} fun getSettings(context: Context, type: ExtensionType, metadata: Metadata): Settings { val name = "$type-${metadata.id}" diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/ApkManifestParser.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/ApkManifestParser.kt index 125342b5..47a4ebe1 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/ApkManifestParser.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/ApkManifestParser.kt @@ -16,7 +16,7 @@ class ApkManifestParser( path = data.sourceDir, className = get("class"), importType = importType, - id = get("id"), + id = get("id") + importType.name, name = get("name"), version = get("version"), description = get("description"), diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FileChangeListener.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FileChangeListener.kt new file mode 100644 index 00000000..583ed1a2 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FileChangeListener.kt @@ -0,0 +1,13 @@ +package dev.brahmkshatriya.echo.extensions.plugger + +import dev.brahmkshatriya.echo.common.helpers.ExtensionType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import java.io.File + +class FileChangeListener( + val scope: CoroutineScope, +) { + val map = mutableMapOf>() + fun getFlow(type: ExtensionType) = map.getOrPut(type) { MutableSharedFlow() } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FilePluginSource.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FilePluginSource.kt index 2f2aaab4..b63efeb2 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FilePluginSource.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/FilePluginSource.kt @@ -1,27 +1,32 @@ package dev.brahmkshatriya.echo.extensions.plugger -import android.os.Build -import android.os.FileObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import tel.jeelpa.plugger.PluginSource import java.io.File class FilePluginSource( private val folder: File, - private val extension: String, + scope: CoroutineScope, + fileIgnoreFlow: MutableSharedFlow ) : PluginSource { - private fun loadAllPlugins() = folder.listFiles()!!.filter { it.path.endsWith(extension) } + + private var ignoreFile : File? = null + private fun loadAllPlugins() = run { + folder.setReadOnly() + folder.listFiles()!!.filter { it != ignoreFile }.onEach { it.setWritable(false) } + } private val pluginStateFlow = MutableStateFlow(loadAllPlugins()) init { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val fsEventsListener = object : FileObserver(folder) { - override fun onEvent(event: Int, path: String?) { - pluginStateFlow.value = loadAllPlugins() - } + scope.launch { + fileIgnoreFlow.collect { + ignoreFile = it + pluginStateFlow.value = loadAllPlugins() } - fsEventsListener.startWatching() } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepo.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepo.kt index 5298df19..873c0d22 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepo.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepo.kt @@ -12,4 +12,4 @@ class LazyRepoComposer( .reduce { a, b -> combineStates(a, b) { x, y -> x + y } } } -fun lazily(value: T) = lazy { runCatching { value } } \ No newline at end of file +fun lazily(function: () -> T) = lazy { runCatching { function() } } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepoImpl.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepoImpl.kt index 9d6c7850..fff7218e 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepoImpl.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepoImpl.kt @@ -18,7 +18,7 @@ data class LazyPluginRepoImpl( }.mapState { metadata -> metadata.map { resultMetadata -> resultMetadata.mapCatching { - Pair(it, lazy { runCatching { pluginLoader.loadPlugin(it) } }) + it to lazily { pluginLoader.loadPlugin(it) } } } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/offline/LocalExtensionRepo.kt b/app/src/main/java/dev/brahmkshatriya/echo/offline/BuiltInExtensionRepo.kt similarity index 88% rename from app/src/main/java/dev/brahmkshatriya/echo/offline/LocalExtensionRepo.kt rename to app/src/main/java/dev/brahmkshatriya/echo/offline/BuiltInExtensionRepo.kt index 1a16d4c9..5a663acb 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/offline/LocalExtensionRepo.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/offline/BuiltInExtensionRepo.kt @@ -6,7 +6,7 @@ import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepo import dev.brahmkshatriya.echo.extensions.plugger.lazily import kotlinx.coroutines.flow.MutableStateFlow -class LocalExtensionRepo( +class BuiltInExtensionRepo( private val extension: OfflineExtension ) : LazyPluginRepo { @@ -18,5 +18,5 @@ class LocalExtensionRepo( ) private fun getLazy(metadata: Metadata, extension: ExtensionClient) = - Result.success(Pair(metadata, lazily(extension))) + Result.success(Pair(metadata, lazily { extension })) } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt new file mode 100644 index 00000000..d95e91cf --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt @@ -0,0 +1,121 @@ +package dev.brahmkshatriya.echo.ui.extension + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.net.toFile +import androidx.core.net.toUri +import androidx.core.view.isVisible +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.brahmkshatriya.echo.ExtensionOpenerActivity.Companion.EXTENSION_INSTALLER +import dev.brahmkshatriya.echo.ExtensionOpenerActivity.Companion.getType +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.helpers.ExtensionType +import dev.brahmkshatriya.echo.common.helpers.ImportType +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder +import dev.brahmkshatriya.echo.databinding.DialogExtensionInstallerBinding +import dev.brahmkshatriya.echo.extensions.plugger.ApkManifestParser +import dev.brahmkshatriya.echo.extensions.plugger.ApkPluginSource +import dev.brahmkshatriya.echo.utils.ApkLinkParser +import dev.brahmkshatriya.echo.utils.autoCleared +import dev.brahmkshatriya.echo.utils.loadWith +import dev.brahmkshatriya.echo.viewmodels.SnackBar.Companion.createSnack + +class ExtensionInstallerBottomSheet : BottomSheetDialogFragment() { + + companion object { + fun newInstance( + file: String, + ) = ExtensionInstallerBottomSheet().apply { + arguments = Bundle().apply { + putString("file", file) + } + } + } + + private var binding by autoCleared() + private val args by lazy { requireArguments() } + private val file by lazy { args.getString("file")!!.toUri().toFile() } + private val supportedLinks by lazy { ApkLinkParser.getSupportedLinks(file) } + private val pair by lazy { + runCatching { + val packageInfo = requireActivity().packageManager + .getPackageArchiveInfo(file.path, ApkPluginSource.PACKAGE_FLAGS)!! + val type = getType(packageInfo) + val metadata = + ApkManifestParser(ImportType.App).parseManifest(packageInfo.applicationInfo!!) + type to metadata + } + } + + override fun onCreateView(inflater: LayoutInflater, parent: ViewGroup?, state: Bundle?): View { + binding = DialogExtensionInstallerBinding.inflate(inflater, parent, false) + return binding.root + } + + private var install = false + private var installAsApk = true + + @SuppressLint("SetTextI18n") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.topAppBar.setNavigationOnClickListener { dismiss() } + val value = pair.getOrNull() + if (value == null) { + createSnack(R.string.invalid_extension) + dismiss() + return + } + val (extensionType, metadata) = value + + binding.extensionTitle.text = metadata.name + metadata.iconUrl?.toImageHolder().loadWith(binding.extensionIcon, R.drawable.ic_extension) { + binding.extensionIcon.setImageDrawable(it) + } + binding.extensionDetails.text = metadata.version + + val byAuthor = getString(R.string.by_author, metadata.author) + val type = when (extensionType) { + ExtensionType.MUSIC -> R.string.music + ExtensionType.TRACKER -> R.string.tracker + ExtensionType.LYRICS -> R.string.lyrics + } + val typeString = getString(R.string.name_extension, getString(type)) + binding.extensionDescription.text = "$typeString\n\n${metadata.description}\n\n$byAuthor" + + val isSupported = supportedLinks.isNotEmpty() + binding.installationTypeTitle.isVisible = isSupported + binding.installationTypeGroup.isVisible = isSupported + binding.installationTypeSummary.isVisible = isSupported + binding.installationTypeLinks.isVisible = isSupported + binding.installationTypeWarning.isVisible = false + + installAsApk = isSupported + if (isSupported) { + binding.installationTypeLinks.text = supportedLinks.joinToString("\n") + binding.installationTypeGroup.addOnButtonCheckedListener { group, _, _ -> + installAsApk = group.checkedButtonId == R.id.appInstall + binding.installationTypeWarning.isVisible = !installAsApk + } + } + + binding.installButton.setOnClickListener { + install = true + dismiss() + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + requireActivity().supportFragmentManager.setFragmentResult( + EXTENSION_INSTALLER, + Bundle().apply { + putString("file", file.toUri().toString()) + putBoolean("install", install) + putBoolean("installAsApk", installAsApk) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioFragment.kt index 49cca283..4bec7daf 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioFragment.kt @@ -56,7 +56,7 @@ class AudioFragment : BaseSettingsFragment() { summary = getString(R.string.stop_player_summary) layoutResource = R.layout.preference_switch isIconSpaceReserved = false - setDefaultValue(false) + setDefaultValue(true) addPreference(this) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ApkLinkParser.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/ApkLinkParser.kt similarity index 99% rename from app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ApkLinkParser.kt rename to app/src/main/java/dev/brahmkshatriya/echo/utils/ApkLinkParser.kt index c06ad968..539f3caa 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ApkLinkParser.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/utils/ApkLinkParser.kt @@ -1,4 +1,4 @@ -package dev.brahmkshatriya.echo.ui.extension +package dev.brahmkshatriya.echo.utils import org.w3c.dom.Document import org.w3c.dom.Node diff --git a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/SnackBar.kt b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/SnackBar.kt index 1c499cad..38d40516 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/SnackBar.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/SnackBar.kt @@ -7,6 +7,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.view.setMargins import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity import androidx.fragment.app.activityViewModels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -19,18 +20,15 @@ import dev.brahmkshatriya.echo.ui.exception.openException import dev.brahmkshatriya.echo.ui.exception.openLoginException import dev.brahmkshatriya.echo.utils.observe import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SnackBar @Inject constructor( - mutableThrowableFlow: MutableSharedFlow, + val throwableFlow: MutableSharedFlow, val mutableMessageFlow: MutableSharedFlow ) : ViewModel() { - val throwableFlow = mutableThrowableFlow.asSharedFlow() - data class Message( val message: String, val action: Action? = null @@ -113,5 +111,14 @@ class SnackBar @Inject constructor( fun Fragment.createSnack(message: Int) { createSnack(getString(message)) } + + fun FragmentActivity.createSnack(message: Message) { + val viewModel by viewModels() + viewModel.create(message) + } + + fun FragmentActivity.createSnack(message: String) { + createSnack(Message(message)) + } } } \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_extension_installer.xml b/app/src/main/res/layout/dialog_extension_installer.xml new file mode 100644 index 00000000..ba3f4100 --- /dev/null +++ b/app/src/main/res/layout/dialog_extension_installer.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +