From 87d93c2ed82c1bf91bcdde59f9519610adf02235 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 15 Oct 2025 01:03:47 +0200 Subject: [PATCH 01/12] WIP library support, albums, artists, videos --- app/src/main/AndroidManifest.xml | 3 + .../platformplayer/activities/MainActivity.kt | 30 + .../api/media/platforms/local/LocalClient.kt | 148 ++++- .../engine/packages/PackageDOMParser.kt | 2 +- .../bottombar/MenuBottomBarFragment.kt | 1 + .../mainactivity/main/ContentFeedView.kt | 2 +- .../mainactivity/main/LibraryAlbumFragment.kt | 123 ++++ .../main/LibraryAlbumsFragment.kt | 160 +++++ .../main/LibraryArtistFragment.kt | 576 ++++++++++++++++++ .../main/LibraryArtistsFragment.kt | 161 +++++ .../mainactivity/main/LibraryFragment.kt | 79 +++ .../main/LibraryVideosFragment.kt | 169 +++++ .../mainactivity/main/RecyclerFragment.kt | 52 ++ .../mainactivity/main/VideoListEditorView.kt | 34 +- .../futo/platformplayer/images/GlideHelper.kt | 1 - .../images/GrayjayAppGlideModule.java | 6 + .../images/MediaStoreThumbnailLoader.kt | 74 +++ .../platformplayer/states/StateLibrary.kt | 442 ++++++++++++++ .../platformplayer/states/StatePlatform.kt | 4 + app/src/main/res/drawable/ic_album.xml | 9 + app/src/main/res/drawable/ic_library.xml | 10 + .../main/res/drawable/ic_video_library.xml | 9 + app/src/main/res/drawable/ic_videocam.xml | 10 + .../res/layout/fragview_filter_recycler.xml | 30 + app/src/main/res/layout/fragview_library.xml | 70 +++ app/src/main/res/layout/list_album.xml | 70 +++ app/src/main/res/layout/list_playlists.xml | 5 +- app/src/main/res/values/strings.xml | 1 + 28 files changed, 2264 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumFragment.kt create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumsFragment.kt create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryVideosFragment.kt create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RecyclerFragment.kt create mode 100644 app/src/main/java/com/futo/platformplayer/images/MediaStoreThumbnailLoader.kt create mode 100644 app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt create mode 100644 app/src/main/res/drawable/ic_album.xml create mode 100644 app/src/main/res/drawable/ic_library.xml create mode 100644 app/src/main/res/drawable/ic_video_library.xml create mode 100644 app/src/main/res/drawable/ic_videocam.xml create mode 100644 app/src/main/res/layout/fragview_filter_recycler.xml create mode 100644 app/src/main/res/layout/fragview_library.xml create mode 100644 app/src/main/res/layout/list_album.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea6f3e5b..df1f5ba5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,9 @@ + + + _fragBuy as T; SubscriptionGroupFragment::class -> _fragSubGroup as T; SubscriptionGroupListFragment::class -> _fragSubGroupList as T; + LibraryFragment::class -> _fragLibrary as T; + LibraryAlbumsFragment::class -> _fragLibraryAlbums as T; + LibraryAlbumFragment::class -> _fragLibraryAlbum as T; + LibraryArtistsFragment::class -> _fragLibraryArtists as T; + LibraryArtistFragment::class -> _fragLibraryArtist as T; + LibraryVideosFragment::class -> _fragLibraryVideos as T; else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity"); } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt index 3f6e5b82..85b08f74 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt @@ -1,5 +1,149 @@ package com.futo.platformplayer.api.media.platforms.local -class LocalClient { - //TODO +import android.content.ContentResolver +import android.net.Uri +import android.provider.MediaStore +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.PlatformClientCapabilities +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.ResultCapabilities +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.chapters.IChapter +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor +import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.structures.EmptyPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException +import com.futo.platformplayer.models.ImageVariable +import com.futo.platformplayer.states.StateLibrary +import java.net.MalformedURLException + +class LocalClient: IPlatformClient { + override val id: String = "LOCAL" + override val name: String = "Local" + override val icon: ImageVariable? = ImageVariable.fromResource(R.drawable.ic_library) + override val capabilities: PlatformClientCapabilities = PlatformClientCapabilities() + + override fun initialize() {} + + override fun disable() { + + } + + override fun getHome(): IPager + = EmptyPager(); + + override fun isContentDetailsUrl(url: String): Boolean { + try { + val uri = Uri.parse(url); + return ContentResolver.SCHEME_CONTENT == uri.scheme + && MediaStore.AUTHORITY == uri.authority; + } + catch(ex: MalformedURLException) { + return false; + } + } + override fun getContentDetails(url: String): IPlatformContentDetails { + val uri = Uri.parse(url); + + if("audio" in uri.pathSegments) { + return StateLibrary.getAudioTrack(url) ?: throw Exception("Failed to find ${url}"); + } + else if("video" in uri.pathSegments) { + return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}"); + } + else + throw Exception("Unknown content url [${url}]"); + } + + override fun getSearchCapabilities(): ResultCapabilities + = ResultCapabilities(); + override fun search(query: String, type: String?, order: String?, filters: Map>?): IPager { + return EmptyPager(); //TODO + } + + override fun getSearchChannelContentsCapabilities(): ResultCapabilities + = ResultCapabilities(); + override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map>?): IPager { + return EmptyPager(); //TODO + } + + override fun searchChannels(query: String): IPager { + return EmptyPager(); //TODO + } + + override fun searchChannelsAsContent(query: String): IPager { + return EmptyPager(); //TODO + } + + override fun isChannelUrl(url: String): Boolean { + return false //TODO + } + + override fun getChannel(channelUrl: String): IPlatformChannel { + throw NotImplementedError(); + } + + override fun getChannelCapabilities(): ResultCapabilities + = ResultCapabilities(); + override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map>?): IPager { + return EmptyPager(); + } + + override fun getChannelPlaylists(channelUrl: String): IPager { + return EmptyPager(); + } + + override fun getPeekChannelTypes(): List = listOf(); + + override fun peekChannelContents(channelUrl: String, type: String?): List + = listOf(); + + override fun getShorts(): IPager = EmptyPager(); + + override fun searchSuggestions(query: String): Array = arrayOf(); + + override fun getChannelUrlByClaim(claimType: Int, claimValues: Map): String? + = null; + + override fun getContentChapters(url: String): List + = listOf(); + + override fun getPlaybackTracker(url: String): IPlaybackTracker? + = null; + + override fun getContentRecommendations(url: String): IPager? + = null; + + override fun getComments(url: String): IPager + = EmptyPager(); + + override fun getSubComments(comment: IPlatformComment): IPager + = EmptyPager(); + + override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? + = null; + + override fun getLiveEvents(url: String): IPager? + = null; + + override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map>?): IPager + = throw NotImplementedError(); + + override fun isPlaylistUrl(url: String): Boolean = false; + + override fun getPlaylist(url: String): IPlatformPlaylistDetails + = throw NotImplementedError(); + override fun getUserPlaylists(): Array = throw NotImplementedError(); + override fun getUserSubscriptions(): Array = throw NotImplementedError(); + override fun getUserHistory(): IPager = throw NotImplementedError(); + override fun isClaimTypeSupported(claimType: Int): Boolean = false; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageDOMParser.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageDOMParser.kt index aee754b0..2ea1cc5a 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageDOMParser.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageDOMParser.kt @@ -55,7 +55,7 @@ class PackageDOMParser : V8Package { } @V8Property fun lastChild(): DOMNode? { - val result = _element.firstElementChild()?.let { DOMNode(_package, it) }; + val result = _element.lastElementChild()?.let { DOMNode(_package, it) }; if(result != null) _children.add(result); return result; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt index f6e57c26..98560d3d 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt @@ -389,6 +389,7 @@ class MenuBottomBarFragment : MainActivityFragment() { } }), ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(12, R.drawable.ic_library, R.drawable.ic_library, R.string.library, canToggle = false, { it.currentMain is LibraryFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate(withHistory = false) }), diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt index cb7261e5..75fdc0a2 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt @@ -64,7 +64,7 @@ abstract class ContentFeedView : FeedView state.muted = true }; _exoPlayer = player; - return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply { + return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle ?: FeedStyle.THUMBNAIL, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply { attachAdapterEvents(this); } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumFragment.kt new file mode 100644 index 00000000..815d1653 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumFragment.kt @@ -0,0 +1,123 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.MediaStore +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.futo.platformplayer.R +import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.activities.IWithResultLauncher +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.states.Album +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.states.StateLibrary +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.views.buttons.BigButton +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput + + +class LibraryAlbumFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var view: FragView? = null; + + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View { + val newView = FragView(this, inflater); + view = newView; + return newView; + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + view?.onShown(parameter); + } + + override fun onDestroyMainView() { + view = null; + super.onDestroyMainView(); + } + + companion object { + fun newInstance() = LibraryAlbumFragment().apply {} + } + + + class FragView: VideoListEditorView { + val fragment: LibraryAlbumFragment; + + private var _album: Album? = null; + private var _tracks: List? = null; + private var _url: String? = null; + + constructor(fragment: LibraryAlbumFragment, inflater: LayoutInflater) : super(inflater) { + this.fragment = fragment; + + } + + fun onShown(parameter: Any? = null) { + + val album = if(parameter is String) + StateLibrary.instance.getAlbum(parameter); + else if(parameter is Long) + StateLibrary.instance.getAlbum(parameter); + else if(parameter is Album) + parameter; + else null; + if(album == null) { + _album = null; + _tracks = null; + setVideos(listOf(), false); + return; + } + setName(album.name); + val tracks = album.getTracks(); + _album = album; + _tracks = tracks; + setMetadata(tracks.size, if(tracks.size > 0) tracks.sumOf { it.duration } else -1); + setVideos(tracks, false, album.thumbnail); + } + + override fun onPlayAllClick() { + val playlist = _album?.toPlaylist(_tracks); + if (playlist != null) { + StatePlayer.instance.setPlaylist(playlist, focus = true); + } + } + + override fun onShuffleClick() { + val playlist = _album?.toPlaylist(_tracks); + if (playlist != null) { + StatePlayer.instance.setPlaylist(playlist, focus = true, shuffle = true); + } + } + + override fun onVideoOptions(video: IPlatformVideo) { + UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer); + } + override fun onVideoClicked(video: IPlatformVideo) { + val playlist = _album?.toPlaylist(_tracks); + if (playlist != null) { + val index = playlist.videos.indexOf(video); + if (index == -1) + return; + + StatePlayer.instance.setPlaylist(playlist, index, true); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumsFragment.kt new file mode 100644 index 00000000..89eb63c1 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryAlbumsFragment.kt @@ -0,0 +1,160 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout.GONE +import android.widget.LinearLayout.VISIBLE +import android.widget.Spinner +import android.widget.TextView +import androidx.core.widget.addTextChangedListener +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.structures.AdhocPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.Album +import com.futo.platformplayer.states.StateLibrary +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.platformplayer.views.adapters.SubscriptionAdapter +import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.platform.PlatformIndicator + +class LibraryAlbumsFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + + var view: FragView? = null; + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = FragView(this, inflater); + this.view = view; + return view; + } + + override fun onShown(parameter: Any?, isBack: Boolean) { + super.onShown(parameter, isBack) + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + view?.onShown(); + } + + override fun onDestroyMainView() { + view = null; + super.onDestroyMainView(); + } + + companion object { + fun newInstance() = LibraryAlbumsFragment().apply {} + } + + class FragView : FeedView, AlbumViewHolder> { + override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator; + + constructor(fragment: LibraryAlbumsFragment, inflater: LayoutInflater) : super(fragment, inflater) + + fun onShown() { + val initialAlbums = StateLibrary.instance.getAlbums(); + Logger.i(TAG, "Initial album count: " + initialAlbums.size); + + setPager(AdhocPager({ listOf(); }, initialAlbums)); + } + + override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList): InsertedViewAdapterWithLoader { + return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(), + childCountGetter = { dataset.size }, + childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); }, + childViewHolderFactory = { viewGroup, _ -> + val holder = AlbumViewHolder(viewGroup); + holder.onClick.subscribe { c -> fragment.navigate(c) }; + return@InsertedViewAdapterWithLoader holder; + } + ); + } + + override fun updateSpanCount(){ } + + override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager { + val glmResults = GridLayoutManager(context, 1) + + _swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply { + rightMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8.0f, + context.resources.displayMetrics + ).toInt() + } + + return glmResults + } + + companion object { + private const val TAG = "LibraryAlbumsFragmentsView"; + } + } + + class AlbumViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_album, + _viewGroup, false)) { + + val onClick = Event1(); + + protected var _album: Album? = null; + protected val _imageThumbnail: ImageView + protected val _textName: TextView + protected val _textMetadata: TextView + + init { + _imageThumbnail = _view.findViewById(R.id.image_thumbnail); + _textName = _view.findViewById(R.id.text_name); + _textMetadata = _view.findViewById(R.id.text_metadata); + + _view.setOnClickListener { onClick.emit(_album) }; + } + + + override fun bind(album: Album) { + _album = album; + _imageThumbnail?.let { + if (album.thumbnail != null) + Glide.with(it) + .load(album.thumbnail) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(it) + else + Glide.with(it).load(R.drawable.placeholder_channel_thumbnail).into(it); + }; + + _textName.text = album.name; + _textMetadata.text = album.artist ?: ""; + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt new file mode 100644 index 00000000..e0129f46 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt @@ -0,0 +1,576 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.graphics.drawable.Animatable +import android.net.Uri +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.LinearLayout.GONE +import android.widget.LinearLayout.VISIBLE +import android.widget.Spinner +import android.widget.TextView +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.view.allViews +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.ResultCapabilities +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.channels.SerializedChannel +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.platforms.local.LocalClient +import com.futo.platformplayer.api.media.structures.AdhocPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.assume +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.constructs.Event3 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp +import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment +import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment +import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment +import com.futo.platformplayer.fragment.channel.tab.ChannelMonetizationFragment +import com.futo.platformplayer.fragment.channel.tab.ChannelPlaylistsFragment +import com.futo.platformplayer.fragment.channel.tab.IChannelTabFragment +import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment.Companion +import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.SearchType +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.selectBestImage +import com.futo.platformplayer.selectHighestResolutionImage +import com.futo.platformplayer.states.Album +import com.futo.platformplayer.states.Artist +import com.futo.platformplayer.states.StateLibrary +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.ToggleBar +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.platformplayer.views.adapters.ChannelTab +import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.platformplayer.views.adapters.SubscriptionAdapter +import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.subscriptions.SubscribeButton +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.PolycentricProfile +import com.futo.polycentric.core.toURLInfoSystemLinkUrl +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class LibraryArtistFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _textMeta: TextView? = null; + + var view: FragView? = null; + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = FragView(this, inflater); + this.view = view; + return view; + } + + override fun onShown(parameter: Any?, isBack: Boolean) { + super.onShown(parameter, isBack) + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + view?.onShown(parameter, isBack); + } + + override fun onDestroyMainView() { + view = null; + super.onDestroyMainView(); + } + + companion object { + fun newInstance() = LibraryArtistFragment().apply {} + } + + class FragView(fragment: LibraryArtistFragment, inflater: LayoutInflater) : LinearLayout(inflater.context) { + private val _fragment: LibraryArtistFragment = fragment + + private var _textChannel: TextView + private var _textChannelSub: TextView + private var _creatorThumbnail: CreatorThumbnail + private var _imageBanner: AppCompatImageView + + private var _tabs: TabLayout + private var _viewPager: ViewPager2 + + // private var _adapter: ChannelViewPagerAdapter; + private var _tabLayoutMediator: TabLayoutMediator + private var _buttonSubscribe: SubscribeButton + private var _buttonSubscriptionSettings: ImageButton + + private var _overlayContainer: FrameLayout + private var _overlayLoading: LinearLayout + private var _overlayLoadingSpinner: ImageView + + private var _slideUpOverlay: SlideUpMenuOverlay? = null + + private var _isLoading: Boolean = false + private var _selectedTabIndex: Int = -1 + var channel: Artist? = null + private set + private var _url: String? = null + + private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {} + + init { + inflater.inflate(R.layout.fragment_channel, this) + + val tabs: TabLayout = findViewById(R.id.tabs) + val viewPager: ViewPager2 = findViewById(R.id.view_pager) + _textChannel = findViewById(R.id.text_channel_name) + _textChannelSub = findViewById(R.id.text_metadata) + _creatorThumbnail = findViewById(R.id.creator_thumbnail) + _imageBanner = findViewById(R.id.image_channel_banner) + _buttonSubscribe = findViewById(R.id.button_subscribe) + _buttonSubscriptionSettings = findViewById(R.id.button_sub_settings) + _overlayLoading = findViewById(R.id.channel_loading_overlay) + _overlayLoadingSpinner = findViewById(R.id.channel_loader_frag) + _overlayContainer = findViewById(R.id.overlay_container) + _buttonSubscribe.onSubscribed.subscribe { + UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer) + _buttonSubscriptionSettings.visibility = + if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE + } + _buttonSubscribe.onUnSubscribed.subscribe { + _buttonSubscriptionSettings.visibility = + if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE + } + _buttonSubscriptionSettings.setOnClickListener { + val url = channel?.contentUrl ?: _url ?: return@setOnClickListener + val sub = + StateSubscriptions.instance.getSubscription(url) ?: return@setOnClickListener + UISlideOverlays.showSubscriptionOptionsOverlay(sub, _overlayContainer) + } + + //TODO: Determine if this is really the only solution (isSaveEnabled=false) + viewPager.isSaveEnabled = false + viewPager.registerOnPageChangeCallback(_onPageChangeCallback) + val adapter = ArtistViewPagerAdapter(fragment, fragment.childFragmentManager, fragment.lifecycle) + adapter.onChannelClicked.subscribe { c -> fragment.navigate(c) } + adapter.onContentClicked.subscribe { v, _ -> + when (v) { + is IPlatformVideo -> { + StatePlayer.instance.clearQueue() + fragment.navigate(v).maximizeVideoDetail() + } + + is IPlatformPlaylist -> { + fragment.navigate(v) + } + + is IPlatformPost -> { + fragment.navigate(v) + } + } + } + adapter.onShortClicked.subscribe { v, _, pagerPair -> + when (v) { + is IPlatformVideo -> { + StatePlayer.instance.clearQueue() + fragment.navigate(Triple(v, pagerPair!!.first, pagerPair.second)) + } + } + } + adapter.onAddToClicked.subscribe { content -> + _overlayContainer.let { + if (content is IPlatformVideo) _slideUpOverlay = + UISlideOverlays.showVideoOptionsOverlay(content, it) + } + } + adapter.onAddToQueueClicked.subscribe { content -> + if (content is IPlatformVideo) { + StatePlayer.instance.addToQueue(content) + } + } + adapter.onAddToWatchLaterClicked.subscribe { content -> + if (content is IPlatformVideo) { + if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true)) + UIDialogs.toast("Added to watch later\n[${content.name}]") + else + UIDialogs.toast(context.getString(R.string.already_in_watch_later)) + } + } + adapter.onUrlClicked.subscribe { url -> + fragment.navigate(url) + } + adapter.onContentUrlClicked.subscribe { url, contentType -> + when (contentType) { + ContentType.MEDIA -> { + StatePlayer.instance.clearQueue() + fragment.navigate(url).maximizeVideoDetail() + } + + ContentType.URL -> fragment.navigate(url) + else -> {} + } + } + adapter.onLongPress.subscribe { content -> + _overlayContainer.let { + if (content is IPlatformVideo) _slideUpOverlay = + UISlideOverlays.showVideoOptionsOverlay(content, it) + } + } + viewPager.adapter = adapter + val tabLayoutMediator = TabLayoutMediator( + tabs, viewPager, (viewPager.adapter as ArtistViewPagerAdapter)::getTabNames + ) + tabLayoutMediator.attach() + + _tabLayoutMediator = tabLayoutMediator + _tabs = tabs + _viewPager = viewPager + if (_selectedTabIndex != -1) { + selectTab(_selectedTabIndex) + } + setLoading(true) + } + + fun selectTab(tab: ArtistTab) { + (_viewPager.adapter as ArtistViewPagerAdapter).getTabPosition(tab) + } + + fun cleanup() { + _tabLayoutMediator.detach() + _viewPager.unregisterOnPageChangeCallback(_onPageChangeCallback) + hideSlideUpOverlay() + (_overlayLoadingSpinner.drawable as Animatable?)?.stop() + } + + fun onShown(parameter: Any?, isBack: Boolean) { + hideSlideUpOverlay() + _selectedTabIndex = -1 + + if (!isBack || _url == null) { + _imageBanner.setImageDrawable(null) + + when (parameter) { + is String -> { + _buttonSubscribe.setSubscribeChannel(parameter) + _buttonSubscriptionSettings.visibility = + if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE + + _url = parameter + + val parsed = Uri.parse(parameter); + val idLong = parsed.lastPathSegment?.toLongOrNull(); + if(idLong != null) { + val artist = StateLibrary.instance.getArtist(idLong) ?: return; + showArtist(artist); + } + } + + is Artist -> { + showArtist(parameter) + _url = parameter.contentUrl + } + } + } + } + + private fun selectTab(selectedTabIndex: Int) { + _selectedTabIndex = selectedTabIndex + _tabs.selectTab(_tabs.getTabAt(selectedTabIndex)) + } + + private fun setLoading(isLoading: Boolean) { + if (_isLoading == isLoading) { + return + } + + _isLoading = isLoading + if (isLoading) { + _overlayLoading.visibility = View.VISIBLE + (_overlayLoadingSpinner.drawable as Animatable?)?.start() + } else { + (_overlayLoadingSpinner.drawable as Animatable?)?.stop() + _overlayLoading.visibility = View.GONE + } + } + + fun onBackPressed(): Boolean { + if (_slideUpOverlay != null) { + hideSlideUpOverlay() + return true + } + + return false + } + + private fun hideSlideUpOverlay() { + _slideUpOverlay?.hide(false) + _slideUpOverlay = null + } + + private fun showArtist(channel: Artist) { + setLoading(false) + + _fragment.topBar?.onShown(channel) + + val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) { + }) + + _fragment.lifecycleScope.launch(Dispatchers.IO) { + val plugin = StatePlatform.instance.getChannelClientOrNull(channel.contentUrl ?: return@launch) + withContext(Dispatchers.Main) { + buttons.add(Pair(R.drawable.ic_search) { + _fragment.navigate( + SuggestionsFragmentData( + "", SearchType.VIDEO + ) + ) + }) + _fragment.topBar?.assume()?.setMenuItems(buttons) + } + } + + _buttonSubscribe.visibility = GONE; + _buttonSubscriptionSettings.visibility = + if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE + _textChannel.text = channel.name + _textChannelSub.text = "" + + var supportsPlaylists = false; + val playlistPosition = 1 + if (supportsPlaylists && !(_viewPager.adapter as ArtistViewPagerAdapter).containsItem( + ArtistTab.PLAYLISTS.ordinal.toLong() + ) + ) { + // keep the current tab selected + if (_viewPager.currentItem >= playlistPosition) { + _viewPager.setCurrentItem(_viewPager.currentItem + 1, false) + } + + (_viewPager.adapter as ArtistViewPagerAdapter).insert( + playlistPosition, + ArtistTab.PLAYLISTS + ) + } + if (!supportsPlaylists && (_viewPager.adapter as ArtistViewPagerAdapter).containsItem( + ArtistTab.PLAYLISTS.ordinal.toLong() + ) + ) { + // keep the current tab selected + if (_viewPager.currentItem >= playlistPosition) { + _viewPager.setCurrentItem(_viewPager.currentItem - 1, false) + } + + (_viewPager.adapter as ArtistViewPagerAdapter).remove(playlistPosition) + } + + // sets the channel for each tab + for (fragment in _fragment.childFragmentManager.fragments) { + (fragment as IArtistTabFragment).setArtist(channel) + } + + (_viewPager.adapter as ArtistViewPagerAdapter).artist = channel + + + _viewPager.adapter!!.notifyDataSetChanged() + + this.channel = channel + } + + + companion object { + private const val TAG = "LibraryArtistFragmentsView"; + } + } + enum class ArtistTab { + VIDEOS, PLAYLISTS + } + + class ArtistViewPagerAdapter(private val fragment: LibraryArtistFragment, fragmentManager: FragmentManager, lifecycle: Lifecycle) : + FragmentStateAdapter(fragmentManager, lifecycle) { + private val _supportedFragments = mutableMapOf( + ArtistTab.VIDEOS.ordinal to ArtistTab.VIDEOS + ) + private val _tabs = arrayListOf(ArtistTab.VIDEOS) + + var artist: Artist? = null + + val onContentUrlClicked = Event2() + val onUrlClicked = Event1() + val onContentClicked = Event2() + val onShortClicked = Event3, ArrayList>?>() + val onChannelClicked = Event1() + val onAddToClicked = Event1() + val onAddToQueueClicked = Event1() + val onAddToWatchLaterClicked = Event1() + val onLongPress = Event1() + + override fun getItemId(position: Int): Long { + return _tabs[position].ordinal.toLong() + } + + override fun containsItem(itemId: Long): Boolean { + return _supportedFragments.containsKey(itemId.toInt()) + } + + override fun getItemCount(): Int { + return _supportedFragments.size + } + + fun getTabPosition(tab: ArtistTab): Int { + return _tabs.indexOf(tab) + } + + fun getTabNames(tab: TabLayout.Tab, position: Int) { + tab.text = _tabs[position].name + } + + fun insert(position: Int, tab: ArtistTab) { + _supportedFragments[tab.ordinal] = tab + _tabs.add(position, tab) + notifyItemInserted(position) + } + + fun remove(position: Int) { + _supportedFragments.remove(_tabs[position].ordinal) + _tabs.removeAt(position) + notifyItemRemoved(position) + } + + override fun createFragment(position: Int): Fragment { + val fragment: Fragment + when (_tabs[position]) { + ArtistTab.VIDEOS -> { + fragment = ChannelContentsFragment(this.fragment).apply { + /* + onContentClicked.subscribe { video, num, _ -> + this@ArtistViewPagerAdapter.onContentClicked.emit(video, num) + } + onContentUrlClicked.subscribe(this@ArtistViewPagerAdapter.onContentUrlClicked::emit) + onUrlClicked.subscribe(this@ArtistViewPagerAdapter.onUrlClicked::emit) + onChannelClicked.subscribe(this@ArtistViewPagerAdapter.onChannelClicked::emit) + onAddToClicked.subscribe(this@ArtistViewPagerAdapter.onAddToClicked::emit) + onAddToQueueClicked.subscribe(this@ArtistViewPagerAdapter.onAddToQueueClicked::emit) + onAddToWatchLaterClicked.subscribe(this@ArtistViewPagerAdapter.onAddToWatchLaterClicked::emit) + onLongPress.subscribe(this@ArtistViewPagerAdapter.onLongPress::emit) + */ + } + } + + ArtistTab.PLAYLISTS -> { + fragment = ChannelPlaylistsFragment.newInstance().apply { + /* + onContentClicked.subscribe(this@ArtistViewPagerAdapter.onContentClicked::emit) + onContentUrlClicked.subscribe(this@ArtistViewPagerAdapter.onContentUrlClicked::emit) + onUrlClicked.subscribe(this@ArtistViewPagerAdapter.onUrlClicked::emit) + onChannelClicked.subscribe(this@ArtistViewPagerAdapter.onChannelClicked::emit) + onAddToClicked.subscribe(this@ArtistViewPagerAdapter.onAddToClicked::emit) + onAddToQueueClicked.subscribe(this@ArtistViewPagerAdapter.onAddToQueueClicked::emit) + onAddToWatchLaterClicked.subscribe(this@ArtistViewPagerAdapter.onAddToWatchLaterClicked::emit) + onLongPress.subscribe(this@ArtistViewPagerAdapter.onLongPress::emit) + */ + } + } + } + artist?.let { (fragment as IArtistTabFragment).setArtist(it) } + + return fragment + } + } + + interface IArtistTabFragment { + fun setArtist(artist: Artist); + } + + class ChannelContentsFragment(private val frag: LibraryArtistFragment) : Fragment(), IArtistTabFragment { + + var view: ArtistContentView? = null; + + private var _lastArtist: Artist? = null; + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + view = ArtistContentView(frag, inflater); + _lastArtist?.let { + view?.setArtist(it); + } + return view; + } + + override fun onDestroyView() { + view = null; + super.onDestroyView() + } + + override fun setArtist(artist: Artist) { + view?.setArtist(artist); + _lastArtist = artist; + } + } + class ArtistContentView : ContentFeedView { + override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator; + + constructor(fragment: LibraryArtistFragment, inflater: LayoutInflater) : super(fragment, inflater) { + + } + + fun setArtist(artist: Artist) { + val tracks = artist.getAudioTracks(); + if(tracks.getResults().isEmpty()) + UIDialogs.appToast("No tracks found"); + setPager(tracks); + } + + override fun updateSpanCount(){ } + + + companion object { + private const val TAG = "LibraryAlbumsFragmentsView"; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt new file mode 100644 index 00000000..44320f12 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt @@ -0,0 +1,161 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout.GONE +import android.widget.LinearLayout.VISIBLE +import android.widget.Spinner +import android.widget.TextView +import androidx.core.widget.addTextChangedListener +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.structures.AdhocPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.Album +import com.futo.platformplayer.states.Artist +import com.futo.platformplayer.states.StateLibrary +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.platformplayer.views.adapters.SubscriptionAdapter +import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.platform.PlatformIndicator + +class LibraryArtistsFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _textMeta: TextView? = null; + + var view: FragView? = null; + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = FragView(this, inflater); + this.view = view; + return view; + } + + override fun onShown(parameter: Any?, isBack: Boolean) { + super.onShown(parameter, isBack) + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + view?.onShown(); + } + + override fun onDestroyMainView() { + view = null; + super.onDestroyMainView(); + } + + companion object { + fun newInstance() = LibraryArtistsFragment().apply {} + } + + class FragView : FeedView, ArtistViewHolder> { + override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator; + + constructor(fragment: LibraryArtistsFragment, inflater: LayoutInflater) : super(fragment, inflater) + + fun onShown() { + val intialArtists = StateLibrary.instance.getArtists(); + Logger.i(TAG, "Initial album count: " + intialArtists.size); + + setPager(AdhocPager({ listOf(); }, intialArtists)); + } + + override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList): InsertedViewAdapterWithLoader { + return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(), + childCountGetter = { dataset.size }, + childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); }, + childViewHolderFactory = { viewGroup, _ -> + val holder = ArtistViewHolder(viewGroup); + holder.onClick.subscribe { c -> + fragment.navigate(c) + }; + return@InsertedViewAdapterWithLoader holder; + } + ); + } + + override fun updateSpanCount(){ } + + override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager { + val glmResults = GridLayoutManager(context, 1) + + _swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply { + rightMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8.0f, + context.resources.displayMetrics + ).toInt() + } + return glmResults + } + + companion object { + private const val TAG = "LibraryArtistsFragmentsView"; + } + } + + class ArtistViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_album, + _viewGroup, false)) { + + val onClick = Event1(); + + protected var _artist: Artist? = null; + protected val _imageThumbnail: ImageView + protected val _textName: TextView + protected val _textMetadata: TextView + + init { + _imageThumbnail = _view.findViewById(R.id.image_thumbnail); + _textName = _view.findViewById(R.id.text_name); + _textMetadata = _view.findViewById(R.id.text_metadata); + + _view.setOnClickListener { _artist?.let { onClick.emit(it) } }; + } + + override fun bind(artist: Artist) { + _artist = artist; + _imageThumbnail?.let { + if (artist.thumbnail != null) + Glide.with(it) + .load(artist.thumbnail) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(it) + else + Glide.with(it).load(R.drawable.placeholder_channel_thumbnail).into(it); + }; + + _textName.text = artist.name; + _textMetadata.text = artist.countTracks?.let { "${it} tracks" } ?: ""; + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt new file mode 100644 index 00000000..7a04c859 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt @@ -0,0 +1,79 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.MediaStore +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.futo.platformplayer.R +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.views.buttons.BigButton + + +class LibraryFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var view: FragView? = null; + + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View { + val newView = FragView(this); + view = newView; + return newView; + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + view?.onShown(); + } + + override fun onDestroyMainView() { + view = null; + super.onDestroyMainView(); + } + + companion object { + fun newInstance() = LibraryFragment().apply {} + } + + + class FragView: ConstraintLayout { + val fragment: LibraryFragment; + constructor(fragment: LibraryFragment, attrs : AttributeSet? = null) : super(fragment.requireContext(), attrs) { + inflate(context, R.layout.fragview_library, this); + this.fragment = fragment; + val buttonArtists = findViewById(R.id.button_artists); + val buttonAlbums = findViewById(R.id.button_albums); + val buttonVideos = findViewById(R.id.button_videos); + val buttonPlaylists = findViewById(R.id.button_playlists); + val buttonFiles = findViewById(R.id.button_files); + + buttonArtists.onClick.subscribe { + fragment.navigate(); + } + buttonAlbums.onClick.subscribe { + fragment.navigate(); + } + buttonVideos.onClick.subscribe { + fragment.navigate(); + } + buttonPlaylists.onClick.subscribe { + fragment.navigate(); + } + buttonFiles.onClick.subscribe { + + } + } + + fun onShown() { + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryVideosFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryVideosFragment.kt new file mode 100644 index 00000000..fb251e9a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryVideosFragment.kt @@ -0,0 +1,169 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout.GONE +import android.widget.LinearLayout.VISIBLE +import android.widget.Spinner +import android.widget.TextView +import androidx.core.view.allViews +import androidx.core.widget.addTextChangedListener +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.UISlideOverlays.Companion.showOrderOverlay +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.structures.AdhocPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.Album +import com.futo.platformplayer.states.StateLibrary +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringArrayStorage +import com.futo.platformplayer.stores.StringStorage +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.ToggleBar +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.platformplayer.views.adapters.SubscriptionAdapter +import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.platform.PlatformIndicator + +class LibraryVideosFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _toggleBuckets = StateLibrary.instance.getVideoBucketNames().map { it.name }.toMutableList(); + + var view: FragView? = null; + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = FragView(this, inflater); + this.view = view; + return view; + } + + override fun onShown(parameter: Any?, isBack: Boolean) { + super.onShown(parameter, isBack) + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + view?.onShown(); + } + + override fun onDestroyMainView() { + view = null; + super.onDestroyMainView(); + } + + companion object { + fun newInstance() = LibraryVideosFragment().apply {} + } + + class FragView : ContentFeedView { + override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator; + + private var _toggleBar: ToggleBar? = null; + + constructor(fragment: LibraryVideosFragment, inflater: LayoutInflater) : super(fragment, inflater) { + initializeToolbarContent(); + } + + fun onShown() { + val initialAlbums = StateLibrary.instance.getAlbums(); + Logger.i(TAG, "Initial album count: " + initialAlbums.size); + val buckets = StateLibrary.instance.getVideoBucketNames(); + setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets)); + } + + + private val _filterLock = Object(); + fun initializeToolbarContent() { + if(_toolbarContentView.allViews.any { it is ToggleBar }) + _toolbarContentView.removeView(_toolbarContentView.allViews.find { it is ToggleBar }); + _toggleBar = ToggleBar(context).apply { + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + } + + synchronized(_filterLock) { + var buttonsPlugins: List = listOf() + buttonsPlugins = + (StateLibrary.instance.getVideoBucketNames() + .map { bucket -> + ToggleBar.Toggle(bucket.name, null, fragment._toggleBuckets.contains(bucket.name), { view, active -> + var dontSwap = false; + if (!active) { + if (fragment._toggleBuckets.contains(bucket.name)) + fragment._toggleBuckets.remove(bucket.name); + } else { + if (!fragment._toggleBuckets.contains(bucket.name)) { + val enabledClients = StatePlatform.instance.getEnabledClients(); + val availableAfterDisable = enabledClients.count { !fragment._toggleBuckets.contains(it.id) && it.id != bucket.name }; + if(availableAfterDisable > 0) + fragment._toggleBuckets.add(bucket.name); + else { + UIDialogs.appToast("Select atleast 1 bucket"); + dontSwap = true; + } + } + } + if(!dontSwap) + reloadForFilters(); + else { + view.setToggle(active); + } + }, { view, views, enabled -> + val toDisable = views.filter { it != view && it.tag == "plugins" }; + if(!view.isActive) + view.handleClick(); + for(tag in toDisable) { + if(tag.isActive) + tag.handleClick(); + } + }).withTag("plugins") + }) + val buttons = (buttonsPlugins) + .sortedBy { it.name }.toTypedArray() + + _toggleBar?.setToggles(*buttons); + } + + _toolbarContentView.addView(_toggleBar, 0); + } + + fun reloadForFilters() { + setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets)); + } + + override fun updateSpanCount(){ } + + + companion object { + private const val TAG = "LibraryAlbumsFragmentsView"; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RecyclerFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RecyclerFragment.kt new file mode 100644 index 00000000..78f19f4b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RecyclerFragment.kt @@ -0,0 +1,52 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.os.Bundle +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.Spinner +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import com.futo.platformplayer.R +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage + +class RecyclerFragment : MainFragment(){ + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var view: View? = null; + + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View { + val newView = RecyclerFragment.View(inflater.context); + view = newView; + return newView; + } + + override fun onDestroyMainView() { + view = null; + super.onDestroyMainView(); + } + + companion object { + fun newInstance() = RecyclerFragment().apply {} + } + + + class View: ConstraintLayout { + constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.fragview_filter_recycler, this); + } + + + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt index 10deee40..ae890d94 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt @@ -20,6 +20,7 @@ import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.assume import com.futo.platformplayer.downloads.VideoDownload +import com.futo.platformplayer.images.GlideHelper import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlaylists @@ -194,22 +195,35 @@ abstract class VideoListEditorView : LinearLayout { _textMetadata.text = parts.joinToString(" • "); } - protected fun setVideos(videos: List?, canEdit: Boolean) { - if (videos != null && videos.isNotEmpty()) { - val video = videos.first(); + protected fun setVideos(videos: List?, canEdit: Boolean, thumbnail: String? = null) { + if(thumbnail != null) { _imagePlaylistThumbnail.let { Glide.with(it) - .load(video.thumbnails.getHQThumbnail()) + .load(thumbnail) .placeholder(R.drawable.placeholder_video_thumbnail) .crossfade() .into(it); - }; - } else { - _textMetadata.text = "0 " + context.getString(R.string.videos); - Glide.with(_imagePlaylistThumbnail) - .load(R.drawable.placeholder_video_thumbnail) - .into(_imagePlaylistThumbnail) + } } + else { + if (videos != null && videos.isNotEmpty()) { + val video = videos.first(); + _imagePlaylistThumbnail.let { + Glide.with(it) + .load(video.thumbnails.getHQThumbnail()) + .placeholder(R.drawable.placeholder_video_thumbnail) + .crossfade() + .into(it); + }; + } else { + Glide.with(_imagePlaylistThumbnail) + .load(R.drawable.placeholder_video_thumbnail) + .into(_imagePlaylistThumbnail) + } + } + if(videos == null || videos.isEmpty()) + _textMetadata.text = "0 " + context.getString(R.string.videos); + _loadedVideos = videos; _loadedVideosCanEdit = canEdit; _videoListEditorView.setVideos(videos, canEdit); diff --git a/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt b/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt index 682ad9be..4b98cdeb 100644 --- a/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt @@ -29,7 +29,6 @@ class GlideHelper { req.into(this); } - fun RequestBuilder.crossfade(): RequestBuilder { return this.transition(DrawableTransitionOptions.withCrossFade()); } diff --git a/app/src/main/java/com/futo/platformplayer/images/GrayjayAppGlideModule.java b/app/src/main/java/com/futo/platformplayer/images/GrayjayAppGlideModule.java index 60d962ac..e5e75fef 100644 --- a/app/src/main/java/com/futo/platformplayer/images/GrayjayAppGlideModule.java +++ b/app/src/main/java/com/futo/platformplayer/images/GrayjayAppGlideModule.java @@ -1,11 +1,14 @@ package com.futo.platformplayer.images; import android.content.Context; +import android.os.Build; import android.util.Log; import com.bumptech.glide.Glide; import com.bumptech.glide.Registry; import com.bumptech.glide.annotation.GlideModule; import com.bumptech.glide.module.AppGlideModule; + +import java.io.InputStream; import java.nio.ByteBuffer; @GlideModule @@ -14,5 +17,8 @@ public class GrayjayAppGlideModule extends AppGlideModule { public void registerComponents(Context context, Glide glide, Registry registry) { Log.i("GrayjayAppGlideModule", "registerComponents called"); registry.prepend(String.class, ByteBuffer.class, new PolycentricModelLoader.Factory()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + registry.prepend(String.class, InputStream.class, new MediaStoreThumbnailLoader.InputStreamFactory()); + } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/images/MediaStoreThumbnailLoader.kt b/app/src/main/java/com/futo/platformplayer/images/MediaStoreThumbnailLoader.kt new file mode 100644 index 00000000..2af75364 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/images/MediaStoreThumbnailLoader.kt @@ -0,0 +1,74 @@ +package com.futo.platformplayer.images + +import android.content.ContentResolver +import android.graphics.Point +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.data.LocalUriFetcher +import com.bumptech.glide.load.model.ModelLoader +import com.bumptech.glide.load.model.ModelLoaderFactory +import com.bumptech.glide.load.model.MultiModelLoaderFactory +import com.bumptech.glide.signature.ObjectKey +import com.futo.platformplayer.states.StateApp +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.net.MalformedURLException + +@RequiresApi(Build.VERSION_CODES.Q) +class MediaStoreThumbnailLoader private constructor() : ModelLoader { + + override fun handles(model: String): Boolean = isMediaStoreAudioUri(model) + + private fun isMediaStoreAudioUri(uri: String): Boolean { + try { + val parsed = Uri.parse(uri); + return ContentResolver.SCHEME_CONTENT == parsed.scheme + && MediaStore.AUTHORITY == parsed.authority + && "audio" in parsed.pathSegments + } + catch(ex: MalformedURLException) { + return false; + } + } + + override fun buildLoadData(model: String, width: Int, height: Int, options: Options): ModelLoader.LoadData? { + val diskCacheKey = ObjectKey(model) + val resolver = StateApp.instance.contextOrNull?.contentResolver ?: return null; + val fetcher = InputStreamFetcher(resolver, Uri.parse(model), width, height) + return ModelLoader.LoadData(diskCacheKey, fetcher) + } + + class InputStreamFactory() : ModelLoaderFactory { + + override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = MediaStoreThumbnailLoader() + + override fun teardown() { + // Do nothing. + } + } + + private class InputStreamFetcher(resolver: ContentResolver, uri: Uri, private val width: Int, private val height: Int) : LocalUriFetcher(resolver, uri) { + + override fun getDataClass(): Class = InputStream::class.java + + @Throws(FileNotFoundException::class) + override fun loadResource(uri: Uri, contentResolver: ContentResolver): InputStream { + val optimalSizeOptions = Bundle(1) + optimalSizeOptions.putParcelable(ContentResolver.EXTRA_SIZE, Point(width, height)) + + return contentResolver.openTypedAssetFile(uri, "image/*", optimalSizeOptions, null) + ?.createInputStream() + ?: throw FileNotFoundException("FileDescriptor is null for: $uri") + } + + @Throws(IOException::class) + override fun close(data: InputStream) { + data.close() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt new file mode 100644 index 00000000..4c8cb04b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt @@ -0,0 +1,442 @@ +package com.futo.platformplayer.states + +import android.content.ContentUris +import android.database.Cursor +import android.net.Uri +import android.provider.MediaStore +import android.provider.MediaStore.Audio.Artists +import android.provider.MediaStore.Images.ImageColumns +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.Thumbnail +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.models.video.LocalVideoDetails +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.structures.AdhocPager +import com.futo.platformplayer.api.media.structures.EmptyPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.states.Album.Companion +import com.futo.platformplayer.states.Album.Companion.TAG +import com.futo.platformplayer.states.StateLibrary.Companion.getAudioTrack +import com.futo.platformplayer.states.StateLibrary.Companion.videoFromCursor +import com.google.protobuf.Empty +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneOffset + + +class StateLibrary { + + + + fun getAlbums(): List { + return Album.getAlbums(); + } + fun getAlbum(str: String): Album? { + val idLong = str.toLongOrNull(); + if(idLong != null) + return getAlbum(idLong); + return null; + } + fun getAlbum(id: Long): Album? { + return Album.getAlbum(id); + } + + fun getArtists(): List { + return Artist.getArtists(); + } + fun getArtist(str: String): Artist? { + val idLong = str.toLongOrNull(); + if(idLong != null) + return getArtist(idLong); + return null; + } + fun getArtist(id: Long): Artist? { + return Artist.getArtist(id); + } + + fun getVideos(buckets: List? = null): IPager { + val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION_VIDEO, + if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null, + null, + MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager(); + cursor.moveToFirst(); + val list = mutableListOf() + while(!cursor.isAfterLast && list.size < 10) { + list.add(videoFromCursor(cursor)); + cursor.moveToNext(); + } + + return AdhocPager({ + val list = mutableListOf() + while(!cursor.isAfterLast && list.size < 10) { + list.add(videoFromCursor(cursor)); + cursor.moveToNext(); + } + return@AdhocPager list; + }, list); + } + + private var _cacheBucketNames: List? = null; + fun getVideoBucketNames(): List { + if(_cacheBucketNames != null) + return _cacheBucketNames ?: listOf(); + val cur: Cursor = StateApp.instance.contextOrNull?.contentResolver?.query( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf( + MediaStore.Video.Media.BUCKET_ID, + MediaStore.Video.Media.BUCKET_DISPLAY_NAME, + ), null, null, null + ) ?: return listOf(); + + val buckets = mutableListOf(); + val list = HashSet(); + if (cur.moveToFirst()) { + var id: Long; + var bucket: String + do { + id = cur.getLong(0); + bucket = cur.getString(1) + if(!list.contains(id)) { + list.add(id); + buckets.add(Bucket(id, bucket)); + } + } while (cur.moveToNext()) + } + _cacheBucketNames = buckets.toList() + return _cacheBucketNames ?: listOf(); + } + + + companion object { + val PROJECTION_VIDEO = arrayOf( + MediaStore.Video.Media._ID, + MediaStore.Video.Media.DISPLAY_NAME, + MediaStore.Video.Media.AUTHOR, + MediaStore.Video.Media.DATE_ADDED, + MediaStore.Video.Media.MIME_TYPE, + MediaStore.Video.Media.BUCKET_DISPLAY_NAME + ); + val PROJECTION_MEDIA = arrayOf( + MediaStore.Audio.Media._ID, //0 + MediaStore.Audio.Media.DISPLAY_NAME, //1 + MediaStore.Audio.Media.ARTIST, //2 + MediaStore.Audio.Media.ALBUM_ID, //3 + MediaStore.Audio.Media.DURATION, //4 + MediaStore.Audio.Media.DATE_ADDED, //5 + MediaStore.Audio.Media.MIME_TYPE, //6 + MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //7 + ); + + fun getAudioTrack(url: String): IPlatformContentDetails? { + val uri = Uri.parse(url); + val id = uri.lastPathSegment?.toLongOrNull(); + if(id == null) + return null; + + val resolver = StateApp.instance.contextOrNull?.contentResolver; + if(resolver == null) { + Logger.w(TAG, "Album contentResolver not found"); + return null; + } + val cursor = resolver?.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media._ID} = ?", arrayOf(id.toString()), + null) ?: return null; + cursor.moveToFirst(); + if(cursor.isAfterLast) + return null; + return audioFromCursor(cursor); + } + fun getVideoTrack(url: String): IPlatformContentDetails? { + val uri = Uri.parse(url); + val id = uri.lastPathSegment?.toLongOrNull(); + if(id == null) + return null; + + val resolver = StateApp.instance.contextOrNull?.contentResolver; + if(resolver == null) { + Logger.w(TAG, "Album contentResolver not found"); + return null; + } + val cursor = resolver?.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media._ID} = ?", arrayOf(id.toString()), + null) ?: return null; + cursor.moveToFirst(); + if(cursor.isAfterLast) + return null; + return videoFromCursor(cursor); + } + + fun audioFromCursor(cursor: Cursor): IPlatformVideoDetails { + val id = cursor.getString(0); + val displayName = cursor.getString(1); + val author = cursor.getString(2); + val albumId = cursor.getLong(3); + val duration = cursor.getLong(4).let { if(it > 0) it / 1000 else 0 }; + val date = cursor.getLong(5); + val contentType = cursor.getString(6); + val category = cursor.getString(7); + + val idLong = id.toLongOrNull(); + val contentUrl = if(idLong != null ) + ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, idLong).toString(); + else + ""; + + val albumContentUrl = if(albumId > 0) + ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString() + else null; + + val dateObj = if(date > 0) + OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC) + else null; + + val authorObj = if(!author.isNullOrBlank()) + PlatformAuthorLink(PlatformID.NONE, author, "", null, null) + else PlatformAuthorLink.UNKNOWN; + + return LocalVideoDetails( + PlatformID("FILE", contentUrl, null, 0, -1), + displayName, Thumbnails(arrayOf( + Thumbnail(albumContentUrl ?: contentUrl, 0) + )), authorObj, contentUrl, duration, contentType, dateObj); + } + fun videoFromCursor(cursor: Cursor): IPlatformVideoDetails { + val id = cursor.getString(0); + val displayName = cursor.getString(1); + val author = cursor.getString(2); + val date = cursor.getLong(3); + val contentType = cursor.getString(4); + val category = cursor.getString(5); + + val idLong = id.toLongOrNull(); + val contentUrl = if(idLong != null ) + ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, idLong).toString(); + else + ""; + + val dateObj = if(date > 0) + OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC) + else null; + + val authorObj = if(!author.isNullOrBlank()) + PlatformAuthorLink(PlatformID.NONE, author, "", null, null) + else PlatformAuthorLink.UNKNOWN; + + return LocalVideoDetails( + PlatformID("FILE", contentUrl, null, 0, -1), + displayName, Thumbnails(arrayOf( + Thumbnail(contentUrl, 0) + )), authorObj, contentUrl, -1, contentType, dateObj); + } + + private var _instance : StateLibrary? = null; + val instance : StateLibrary + get(){ + if(_instance == null) + _instance = StateLibrary(); + return _instance!!; + }; + + fun finish() { + _instance?.let { + _instance = null; + } + } + } +} + +class Bucket(val id: Long, val name: String); + +class Artist { + val id: String; + val name: String; + val countTracks: Int; + val countAlbums: Int; + val thumbnail: String?; + val contentUrl: String?; + + constructor(name: String, countTracks: Int = -1, countAlbums: Int = -1, thumbnail: String? = null, id: String? = null, contentUrl: String? = null) { + this.id = id ?: ID_UNKNOWN; + this.name = name; + this.thumbnail = thumbnail; + this.countTracks = countTracks; + this.countAlbums = countAlbums; + this.contentUrl = contentUrl; + } + + fun getAlbums(): List { + return listOf(); + } + + fun getAudioTracks(): IPager { + val idLong = id.toLongOrNull() ?: return EmptyPager(); + return AdhocPager({ listOf() }, getTracksPager(idLong)); + } + + companion object { + val ID_UNKNOWN = "UNKNOWN"; + val PROJECTION: Array = arrayOf(Artists._ID, + Artists.ARTIST, + Artists.NUMBER_OF_TRACKS, + Artists.NUMBER_OF_ALBUMS); + + fun fromCursor(cursor: Cursor): Artist { + val id = cursor.getString(0); + val artist = cursor.getString(1); + val numTracks = cursor.getInt(2); + val numAlbums = cursor.getInt(3); + + val idLong = id.toLongOrNull(); + val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null; + + return Artist(artist, numTracks, numAlbums, null, id, uri?.toString()); + } + + fun getArtist(id: Long): Artist? { + val resolver = StateApp.instance.contextOrNull?.contentResolver; + if(resolver == null) { + Logger.w(TAG, "Artist contentResolver not found"); + return null + } + val cursor = resolver.query(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, + Artist.PROJECTION, + "${MediaStore.Audio.Artists._ID} = ?", + arrayOf(id.toString()), null) ?: + return null; + cursor.moveToFirst(); + if(cursor.isAfterLast) + return null; + return Artist.fromCursor(cursor); + } + fun getArtists(): List { + val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(Artists.EXTERNAL_CONTENT_URI, PROJECTION, null, null, + Artists.ARTIST + " ASC") ?: return listOf(); + cursor.moveToFirst(); + val list = mutableListOf() + while(!cursor.isAfterLast) { + list.add(fromCursor(cursor)); + cursor.moveToNext(); + } + return list; + } + + fun getTracksPager(artistId: Long): List { + val resolver = StateApp.instance.contextOrNull?.contentResolver; + if(resolver == null) { + Logger.w(TAG, "Album contentResolver not found"); + return listOf(); + } + val cursor = resolver?.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()), + null) ?: return listOf(); + cursor.moveToFirst(); + val list = mutableListOf() + while(!cursor.isAfterLast) { + list.add(StateLibrary.audioFromCursor(cursor)); + cursor.moveToNext(); + } + return list; + } + } +} + +class Album { + val id: String; + val name: String; + val artist: String?; + val countTracks: Int; + val thumbnail: String?; + + constructor(name: String, countTracks: Int = -1, artist: String? = null, id: String? = null, thumbnail: String? = null) { + this.id = id ?: ID_UNKNOWN; + this.name = name; + this.artist = artist; + this.countTracks = countTracks; + this.thumbnail = thumbnail; + } + + fun getTracks(): List { + return getAlbumTracks(id.toLongOrNull() ?: return listOf()) + } + + fun toPlaylist(tracks: List? = null): Playlist { + return Playlist(name, tracks?.map { SerializedPlatformVideo.fromVideo(it) } ?: getTracks().map { SerializedPlatformVideo.fromVideo(it) }) + } + + companion object { + val TAG = "StateLibrary"; + val ID_UNKNOWN = "UNKNOWN"; + val PROJECTION = arrayOf(MediaStore.Audio.Albums.ALBUM_ID, + MediaStore.Audio.Albums.ALBUM, + MediaStore.Audio.Albums.NUMBER_OF_SONGS, + MediaStore.Audio.Albums.ARTIST); + + fun fromCursor(cursor: Cursor): Album { + val id = cursor.getString(0); + val album = cursor.getString(1); + val numTracks = cursor.getInt(2); + val artist = cursor.getString(3); + + val idLong = id.toLongOrNull(); + val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null; + return Album(album, numTracks, artist, id, uri?.toString()); + } + + fun getAlbumTracks(albumId: Long): List { + val resolver = StateApp.instance.contextOrNull?.contentResolver; + if(resolver == null) { + Logger.w(TAG, "Album contentResolver not found"); + return listOf(); + } + val cursor = resolver?.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ALBUM_ID} = ?", arrayOf(albumId.toString()), + null) ?: return listOf(); + cursor.moveToFirst(); + val list = mutableListOf() + while(!cursor.isAfterLast) { + list.add(StateLibrary.audioFromCursor(cursor)); + cursor.moveToNext(); + } + return list; + } + fun getAlbum(id: Long): Album? { + val resolver = StateApp.instance.contextOrNull?.contentResolver; + if(resolver == null) { + Logger.w(TAG, "Album contentResolver not found"); + return null + } + val cursor = resolver.query(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, + PROJECTION, + "${MediaStore.Audio.Albums.ALBUM_ID} = ?", + arrayOf(id.toString()), null) ?: + return null; + cursor.moveToFirst(); + if(cursor.isAfterLast) + return null; + return fromCursor(cursor); + } + fun getAlbums(): List { + val resolver = StateApp.instance.contextOrNull?.contentResolver; + if(resolver == null) { + Logger.w(TAG, "Album contentResolver not found"); + return listOf(); + } + val cursor = resolver?.query( + MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, null, null, + MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf(); + cursor.moveToFirst(); + val list = mutableListOf() + while(!cursor.isAfterLast) { + list.add(fromCursor(cursor)); + cursor.moveToNext(); + } + return list; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index 8f5fc3ba..99213aea 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -28,6 +28,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.platforms.local.LocalClient import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager @@ -75,6 +76,7 @@ class StatePlatform { private val _cache : LruCache = LruCache(VIDEO_CACHE); //Clients + private val _localClient = LocalClient(); private val _enabledClientsPersistent = FragmentedStorage.get("enabledClients"); private val _platformOrderPersistent = FragmentedStorage.get("platformOrder"); private val _clientsLock = Object(); @@ -117,6 +119,7 @@ class StatePlatform { _enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let { _mainClientPool.getClientPooled(it).getContentDetails(url) } + ?: (if(_localClient.isContentDetailsUrl(url)) _localClient.getContentDetails(url) else null) ?: throw NoPlatformClientException("No client enabled that supports this url ($url)"); } else { @@ -124,6 +127,7 @@ class StatePlatform { _enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let { _privateClientPool.getClientPooled(it).getContentDetails(url) } + ?: (if(_localClient.isContentDetailsUrl(url)) _localClient.getContentDetails(url) else null) ?: throw NoPlatformClientException("No client enabled that supports this url ($url)"); } }, diff --git a/app/src/main/res/drawable/ic_album.xml b/app/src/main/res/drawable/ic_album.xml new file mode 100644 index 00000000..49d3d0e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_album.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_library.xml b/app/src/main/res/drawable/ic_library.xml new file mode 100644 index 00000000..d6a5f6e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_library.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_library.xml b/app/src/main/res/drawable/ic_video_library.xml new file mode 100644 index 00000000..e41ff300 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_library.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_videocam.xml b/app/src/main/res/drawable/ic_videocam.xml new file mode 100644 index 00000000..785a1fd9 --- /dev/null +++ b/app/src/main/res/drawable/ic_videocam.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragview_filter_recycler.xml b/app/src/main/res/layout/fragview_filter_recycler.xml new file mode 100644 index 00000000..639232f4 --- /dev/null +++ b/app/src/main/res/layout/fragview_filter_recycler.xml @@ -0,0 +1,30 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragview_library.xml b/app/src/main/res/layout/fragview_library.xml new file mode 100644 index 00000000..1153b133 --- /dev/null +++ b/app/src/main/res/layout/fragview_library.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_album.xml b/app/src/main/res/layout/list_album.xml new file mode 100644 index 00000000..ce5901b1 --- /dev/null +++ b/app/src/main/res/layout/list_album.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_playlists.xml b/app/src/main/res/layout/list_playlists.xml index a180d134..e94852e7 100644 --- a/app/src/main/res/layout/list_playlists.xml +++ b/app/src/main/res/layout/list_playlists.xml @@ -35,7 +35,7 @@ android:maxLines="2" app:layout_constraintLeft_toRightOf="@id/image_video_thumbnail" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintRight_toLeftOf="@id/button_trash" + app:layout_constraintRight_toRightOf="parent" app:layout_constraintBottom_toTopOf="@id/text_metadata" android:layout_marginStart="10dp" /> @@ -51,7 +51,7 @@ android:maxLines="1" app:layout_constraintTop_toBottomOf="@id/text_name" app:layout_constraintLeft_toRightOf="@id/image_video_thumbnail" - app:layout_constraintRight_toLeftOf="@id/button_trash" + app:layout_constraintRight_toRightOf="parent" app:layout_constraintBottom_toBottomOf="parent" android:layout_marginStart="10dp" /> @@ -68,6 +68,7 @@ app:layout_constraintBottom_toBottomOf="parent" android:layout_marginEnd="10dp"/> --> + Share View all Creators + Library Enabled Keep screen on Keep screen on while casting From 682b86330ee0fe084324daf53d7eff9a262197ac Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 23 Oct 2025 01:59:03 +0200 Subject: [PATCH 02/12] Library work --- .../main/LibraryArtistFragment.kt | 182 +++++++++--------- .../main/LibraryArtistsFragment.kt | 21 +- .../mainactivity/main/LibraryFragment.kt | 135 +++++++++++-- .../platformplayer/states/StateLibrary.kt | 19 +- app/src/main/res/layout/fragview_library.xml | 16 ++ app/src/main/res/layout/list_artist.xml | 58 ++++++ 6 files changed, 321 insertions(+), 110 deletions(-) create mode 100644 app/src/main/res/layout/list_artist.xml diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt index e0129f46..4c904eb7 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt @@ -8,94 +8,52 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.EditText import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout -import android.widget.LinearLayout.GONE -import android.widget.LinearLayout.VISIBLE -import android.widget.Spinner import android.widget.TextView import androidx.appcompat.widget.AppCompatImageView -import androidx.core.view.allViews -import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 -import com.bumptech.glide.Glide import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays -import com.futo.platformplayer.api.media.PlatformID -import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException import com.futo.platformplayer.api.media.models.PlatformAuthorLink -import com.futo.platformplayer.api.media.models.ResultCapabilities -import com.futo.platformplayer.api.media.models.channels.IPlatformChannel -import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo -import com.futo.platformplayer.api.media.platforms.local.LocalClient import com.futo.platformplayer.api.media.structures.AdhocPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.assume import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event3 -import com.futo.platformplayer.constructs.TaskHandler -import com.futo.platformplayer.dp -import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment -import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment -import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment -import com.futo.platformplayer.fragment.channel.tab.ChannelMonetizationFragment -import com.futo.platformplayer.fragment.channel.tab.ChannelPlaylistsFragment -import com.futo.platformplayer.fragment.channel.tab.IChannelTabFragment -import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment.Companion +import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumsFragment.AlbumViewHolder import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment -import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.SearchType -import com.futo.platformplayer.models.Subscription -import com.futo.platformplayer.selectBestImage -import com.futo.platformplayer.selectHighestResolutionImage import com.futo.platformplayer.states.Album import com.futo.platformplayer.states.Artist import com.futo.platformplayer.states.StateLibrary import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists -import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StateSubscriptions -import com.futo.platformplayer.stores.FragmentedStorage -import com.futo.platformplayer.stores.StringStorage -import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.views.FeedStyle -import com.futo.platformplayer.views.ToggleBar -import com.futo.platformplayer.views.adapters.AnyAdapter -import com.futo.platformplayer.views.adapters.ChannelTab -import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader -import com.futo.platformplayer.views.adapters.SubscriptionAdapter -import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay -import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.subscriptions.SubscribeButton -import com.futo.polycentric.core.ApiMethods -import com.futo.polycentric.core.PolycentricProfile -import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import kotlinx.coroutines.Dispatchers @@ -384,31 +342,14 @@ class LibraryArtistFragment : MainFragment() { var supportsPlaylists = false; val playlistPosition = 1 - if (supportsPlaylists && !(_viewPager.adapter as ArtistViewPagerAdapter).containsItem( - ArtistTab.PLAYLISTS.ordinal.toLong() - ) - ) { - // keep the current tab selected - if (_viewPager.currentItem >= playlistPosition) { - _viewPager.setCurrentItem(_viewPager.currentItem + 1, false) - } - - (_viewPager.adapter as ArtistViewPagerAdapter).insert( - playlistPosition, - ArtistTab.PLAYLISTS - ) - } - if (!supportsPlaylists && (_viewPager.adapter as ArtistViewPagerAdapter).containsItem( - ArtistTab.PLAYLISTS.ordinal.toLong() - ) - ) { - // keep the current tab selected - if (_viewPager.currentItem >= playlistPosition) { - _viewPager.setCurrentItem(_viewPager.currentItem - 1, false) - } - - (_viewPager.adapter as ArtistViewPagerAdapter).remove(playlistPosition) + // keep the current tab selected + if (_viewPager.currentItem >= playlistPosition) { + _viewPager.setCurrentItem(_viewPager.currentItem + 1, false) } + (_viewPager.adapter as ArtistViewPagerAdapter).insert( + playlistPosition, + ArtistTab.ALBUMS + ) // sets the channel for each tab for (fragment in _fragment.childFragmentManager.fragments) { @@ -429,15 +370,15 @@ class LibraryArtistFragment : MainFragment() { } } enum class ArtistTab { - VIDEOS, PLAYLISTS + SONGS, ALBUMS } class ArtistViewPagerAdapter(private val fragment: LibraryArtistFragment, fragmentManager: FragmentManager, lifecycle: Lifecycle) : FragmentStateAdapter(fragmentManager, lifecycle) { private val _supportedFragments = mutableMapOf( - ArtistTab.VIDEOS.ordinal to ArtistTab.VIDEOS + ArtistTab.SONGS.ordinal to ArtistTab.SONGS ) - private val _tabs = arrayListOf(ArtistTab.VIDEOS) + private val _tabs = arrayListOf(ArtistTab.SONGS, ArtistTab.ALBUMS) var artist: Artist? = null @@ -486,35 +427,15 @@ class LibraryArtistFragment : MainFragment() { override fun createFragment(position: Int): Fragment { val fragment: Fragment when (_tabs[position]) { - ArtistTab.VIDEOS -> { + ArtistTab.SONGS -> { fragment = ChannelContentsFragment(this.fragment).apply { - /* - onContentClicked.subscribe { video, num, _ -> - this@ArtistViewPagerAdapter.onContentClicked.emit(video, num) - } - onContentUrlClicked.subscribe(this@ArtistViewPagerAdapter.onContentUrlClicked::emit) - onUrlClicked.subscribe(this@ArtistViewPagerAdapter.onUrlClicked::emit) - onChannelClicked.subscribe(this@ArtistViewPagerAdapter.onChannelClicked::emit) - onAddToClicked.subscribe(this@ArtistViewPagerAdapter.onAddToClicked::emit) - onAddToQueueClicked.subscribe(this@ArtistViewPagerAdapter.onAddToQueueClicked::emit) - onAddToWatchLaterClicked.subscribe(this@ArtistViewPagerAdapter.onAddToWatchLaterClicked::emit) - onLongPress.subscribe(this@ArtistViewPagerAdapter.onLongPress::emit) - */ + } } - ArtistTab.PLAYLISTS -> { - fragment = ChannelPlaylistsFragment.newInstance().apply { - /* - onContentClicked.subscribe(this@ArtistViewPagerAdapter.onContentClicked::emit) - onContentUrlClicked.subscribe(this@ArtistViewPagerAdapter.onContentUrlClicked::emit) - onUrlClicked.subscribe(this@ArtistViewPagerAdapter.onUrlClicked::emit) - onChannelClicked.subscribe(this@ArtistViewPagerAdapter.onChannelClicked::emit) - onAddToClicked.subscribe(this@ArtistViewPagerAdapter.onAddToClicked::emit) - onAddToQueueClicked.subscribe(this@ArtistViewPagerAdapter.onAddToQueueClicked::emit) - onAddToWatchLaterClicked.subscribe(this@ArtistViewPagerAdapter.onAddToWatchLaterClicked::emit) - onLongPress.subscribe(this@ArtistViewPagerAdapter.onLongPress::emit) - */ + ArtistTab.ALBUMS -> { + fragment = ArtistAlbumsFragment(this.fragment).apply { + } } } @@ -569,6 +490,77 @@ class LibraryArtistFragment : MainFragment() { override fun updateSpanCount(){ } + companion object { + private const val TAG = "LibraryAlbumsFragmentsView"; + } + } + class ArtistAlbumsFragment(private val frag: LibraryArtistFragment) : Fragment(), IArtistTabFragment { + + var view: ArtistAlbumsView? = null; + + private var _lastArtist: Artist? = null; + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + view = ArtistAlbumsView(frag, inflater); + _lastArtist?.let { + view?.setArtist(it); + } + return view; + } + + override fun onDestroyView() { + view = null; + super.onDestroyView() + } + + override fun setArtist(artist: Artist) { + view?.setArtist(artist); + _lastArtist = artist; + } + } + class ArtistAlbumsView : FeedView, AlbumViewHolder> { + override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator; + + constructor(fragment: LibraryArtistFragment, inflater: LayoutInflater) : super(fragment, inflater) + + fun onShown() { + } + + fun setArtist(artist: Artist) { + val initialAlbums = artist.getAlbums(); + Logger.i(TAG, "Initial album count: " + initialAlbums.size); + + setPager(AdhocPager({ listOf() }, initialAlbums)); + } + + override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList): InsertedViewAdapterWithLoader { + return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(), + childCountGetter = { dataset.size }, + childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); }, + childViewHolderFactory = { viewGroup, _ -> + val holder = AlbumViewHolder(viewGroup); + holder.onClick.subscribe { c -> fragment.navigate(c) }; + return@InsertedViewAdapterWithLoader holder; + } + ); + } + + override fun updateSpanCount(){ } + + override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager { + val glmResults = GridLayoutManager(context, 1) + + _swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply { + rightMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8.0f, + context.resources.displayMetrics + ).toInt() + } + + return glmResults + } + companion object { private const val TAG = "LibraryAlbumsFragmentsView"; } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt index 44320f12..88d4ffdf 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt @@ -88,6 +88,19 @@ class LibraryArtistsFragment : MainFragment() { setPager(AdhocPager({ listOf(); }, intialArtists)); } + override fun reload() { + try { + setLoading(true); + val intialArtists = StateLibrary.instance.getArtists(); + Logger.i(TAG, "Initial album count: " + intialArtists.size); + + setPager(AdhocPager({ listOf(); }, intialArtists)); + } + finally { + setLoading(false); + } + } + override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList): InsertedViewAdapterWithLoader { return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(), childCountGetter = { dataset.size }, @@ -123,18 +136,18 @@ class LibraryArtistsFragment : MainFragment() { } class ArtistViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( - LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_album, + LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_artist, _viewGroup, false)) { val onClick = Event1(); protected var _artist: Artist? = null; - protected val _imageThumbnail: ImageView + //protected val _imageThumbnail: ImageView protected val _textName: TextView protected val _textMetadata: TextView init { - _imageThumbnail = _view.findViewById(R.id.image_thumbnail); + //_imageThumbnail = _view.findViewById(R.id.image_thumbnail); _textName = _view.findViewById(R.id.text_name); _textMetadata = _view.findViewById(R.id.text_metadata); @@ -143,6 +156,7 @@ class LibraryArtistsFragment : MainFragment() { override fun bind(artist: Artist) { _artist = artist; + /* _imageThumbnail?.let { if (artist.thumbnail != null) Glide.with(it) @@ -152,6 +166,7 @@ class LibraryArtistsFragment : MainFragment() { else Glide.with(it).load(R.drawable.placeholder_channel_thumbnail).into(it); }; + */ _textName.text = artist.name; _textMetadata.text = artist.countTracks?.let { "${it} tracks" } ?: ""; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt index 7a04c859..77f56d62 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt @@ -7,10 +7,14 @@ import android.provider.MediaStore import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.buttons.BigButton @@ -23,9 +27,12 @@ class LibraryFragment : MainFragment() { private var view: FragView? = null; + private var allowedMusic = false; + private var allowedVideo = false; + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View { - val newView = FragView(this); + val newView = FragView(this, allowedMusic, allowedVideo); view = newView; return newView; } @@ -33,6 +40,9 @@ class LibraryFragment : MainFragment() { override fun onShownWithView(parameter: Any?, isBack: Boolean) { super.onShownWithView(parameter, isBack); view?.onShown(); + + requestPermissionMusic(); + requestPermissionVideo(); } override fun onDestroyMainView() { @@ -40,6 +50,63 @@ class LibraryFragment : MainFragment() { super.onDestroyMainView(); } + fun setPermissionResultAudio(access: Boolean) { + allowedMusic = access; + view?.setMusicPermissions(access); + } + fun setPermissionResultVideo(access: Boolean) { + allowedVideo = access; + view?.setVideoPermissions(access); + } + + fun requestPermissionMusic() { + when { + ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.READ_MEDIA_AUDIO) == PackageManager.PERMISSION_GRANTED -> { + setPermissionResultAudio(true); + } + ActivityCompat.shouldShowRequestPermissionRationale(requireActivity(), android.Manifest.permission.READ_MEDIA_AUDIO) -> { + UIDialogs.showDialog(requireContext(), R.drawable.ic_library, + "Music permissions", "We require permissions to see your on-device music, denying this will hide the option to see local music.", null, 1, + UIDialogs.Action("Ok", { + permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO); + }, UIDialogs.ActionStyle.PRIMARY), + UIDialogs.Action("Cancel", { + + }, UIDialogs.ActionStyle.NONE)); + } + else -> { + permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO); + } + } + } + fun requestPermissionVideo() { + when { + ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.READ_MEDIA_VIDEO) == PackageManager.PERMISSION_GRANTED -> { + setPermissionResultVideo(true); + } + ActivityCompat.shouldShowRequestPermissionRationale(requireActivity(), android.Manifest.permission.READ_MEDIA_VIDEO) -> { + UIDialogs.showDialog(requireContext(), R.drawable.ic_library, false, + "Videos permissions", "We require permissions to see your on-device videos, denying this will hide the option to see local videos.", null, 1, + UIDialogs.Action("Ok", { + permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO); + }, UIDialogs.ActionStyle.PRIMARY), + UIDialogs.Action("Cancel", { + + }, UIDialogs.ActionStyle.NONE)); + } + else -> { + permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO); + } + } + } + + val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted -> + setPermissionResultAudio(isGranted); + }); + val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted -> + setPermissionResultVideo(isGranted); + }); + companion object { fun newInstance() = LibraryFragment().apply {} } @@ -47,30 +114,76 @@ class LibraryFragment : MainFragment() { class FragView: ConstraintLayout { val fragment: LibraryFragment; - constructor(fragment: LibraryFragment, attrs : AttributeSet? = null) : super(fragment.requireContext(), attrs) { + + var buttonArtists: BigButton; + var buttonAlbums: BigButton; + var buttonVideos: BigButton; + var buttonPlaylists: BigButton; + var buttonFiles: BigButton; + + var metaInfo: TextView; + + var allowMusic: Boolean = false; + var allowVideo: Boolean = false; + + constructor(fragment: LibraryFragment, allowMusic: Boolean?, allowVideo: Boolean?) : super(fragment.requireContext()) { inflate(context, R.layout.fragview_library, this); this.fragment = fragment; - val buttonArtists = findViewById(R.id.button_artists); - val buttonAlbums = findViewById(R.id.button_albums); - val buttonVideos = findViewById(R.id.button_videos); - val buttonPlaylists = findViewById(R.id.button_playlists); - val buttonFiles = findViewById(R.id.button_files); + buttonArtists = findViewById(R.id.button_artists); + buttonAlbums = findViewById(R.id.button_albums); + buttonVideos = findViewById(R.id.button_videos); + buttonPlaylists = findViewById(R.id.button_playlists); + buttonFiles = findViewById(R.id.button_files); + metaInfo = findViewById(R.id.meta_info); + + this.allowMusic = allowMusic ?: false; + this.allowVideo = allowVideo ?: false; buttonArtists.onClick.subscribe { - fragment.navigate(); + if(this.allowMusic) + fragment.navigate(); + else + fragment.requestPermissionMusic(); } buttonAlbums.onClick.subscribe { - fragment.navigate(); + if(this.allowMusic) + fragment.navigate(); + else + fragment.requestPermissionMusic(); } buttonVideos.onClick.subscribe { - fragment.navigate(); + if(this.allowVideo) + fragment.navigate(); + else + fragment.requestPermissionVideo(); } buttonPlaylists.onClick.subscribe { fragment.navigate(); } buttonFiles.onClick.subscribe { - + UIDialogs.appToast("This is gonna require a bit more work.."); } + buttonFiles.setButtonEnabled(false); + setMusicPermissions(allowMusic ?: false); + setVideoPermissions(allowVideo ?: false); + } + + fun setMusicPermissions(access: Boolean) { + allowMusic = access; + buttonAlbums.setButtonEnabled(access); + buttonArtists.setButtonEnabled(access); + metaInfo.text = listOf( + if(!allowMusic) "You did not give access to local music, so these options are disabled" else null, + if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null + ).filterNotNull().joinToString("\n"); + } + fun setVideoPermissions(access: Boolean) { + allowVideo = access; + buttonVideos.setButtonEnabled(access); + metaInfo.text = listOf( + if(!allowMusic) "You did not give access to local music, so these options are disabled" else null, + if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null + ).filterNotNull().joinToString("\n"); } fun onShown() { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt index 4c8cb04b..4070cc06 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt @@ -271,7 +271,7 @@ class Artist { } fun getAlbums(): List { - return listOf(); + return Album.getArtistAlbums(id.toLongOrNull() ?: return listOf()); } fun getAudioTracks(): IPager { @@ -438,5 +438,22 @@ class Album { } return list; } + fun getArtistAlbums(artistId: Long): List { + val resolver = StateApp.instance.contextOrNull?.contentResolver; + if(resolver == null) { + Logger.w(TAG, "Album contentResolver not found"); + return listOf(); + } + val cursor = resolver?.query( + MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()), + MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf(); + cursor.moveToFirst(); + val list = mutableListOf() + while(!cursor.isAfterLast) { + list.add(fromCursor(cursor)); + cursor.moveToNext(); + } + return list; + } } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragview_library.xml b/app/src/main/res/layout/fragview_library.xml index 1153b133..3c04a074 100644 --- a/app/src/main/res/layout/fragview_library.xml +++ b/app/src/main/res/layout/fragview_library.xml @@ -65,6 +65,22 @@ android:layout_marginLeft="10dp" android:layout_marginRight="10dp" android:layout_marginBottom="8dp" /> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_artist.xml b/app/src/main/res/layout/list_artist.xml new file mode 100644 index 00000000..270cab96 --- /dev/null +++ b/app/src/main/res/layout/list_artist.xml @@ -0,0 +1,58 @@ + + + + + + + + + \ No newline at end of file From 9b97e05e3b519d16ee7702783c1f88ccfba04e1d Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 30 Oct 2025 21:19:47 +0100 Subject: [PATCH 03/12] File browser support --- .../platformplayer/activities/MainActivity.kt | 5 + .../api/media/platforms/local/LocalClient.kt | 13 +- .../mainactivity/main/LibraryFilesFragment.kt | 235 ++++++++++++++++++ .../mainactivity/main/LibraryFragment.kt | 6 +- .../futo/platformplayer/states/StateApp.kt | 3 + .../platformplayer/states/StateLibrary.kt | 168 ++++++++++++- app/src/main/res/layout/fragview_library.xml | 4 +- app/src/main/res/layout/list_file.xml | 70 ++++++ 8 files changed, 491 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt create mode 100644 app/src/main/res/layout/list_file.xml diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index f0c3f6a2..4e35e758 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -61,6 +61,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumFragment import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumsFragment import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistFragment import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistsFragment +import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment import com.futo.platformplayer.fragment.mainactivity.main.LibraryVideosFragment import com.futo.platformplayer.fragment.mainactivity.main.MainFragment @@ -191,6 +192,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { lateinit var _fragLibraryArtists: LibraryArtistsFragment; lateinit var _fragLibraryArtist: LibraryArtistFragment; lateinit var _fragLibraryVideos: LibraryVideosFragment; + lateinit var _fragLibraryFiles: LibraryFilesFragment; lateinit var _fragBrowser: BrowserFragment; @@ -368,6 +370,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragLibraryArtists = LibraryArtistsFragment.newInstance(); _fragLibraryArtist = LibraryArtistFragment.newInstance(); _fragLibraryVideos = LibraryVideosFragment.newInstance(); + _fragLibraryFiles = LibraryFilesFragment.newInstance(); _fragBrowser = BrowserFragment.newInstance(); @@ -505,6 +508,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragLibraryArtists.topBar = _fragTopBarNavigation; _fragLibraryArtist.topBar = _fragTopBarNavigation; _fragLibraryVideos.topBar = _fragTopBarNavigation; + _fragLibraryFiles.topBar = _fragTopBarNavigation; _fragBrowser.topBar = _fragTopBarNavigation; @@ -1314,6 +1318,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { LibraryArtistsFragment::class -> _fragLibraryArtists as T; LibraryArtistFragment::class -> _fragLibraryArtist as T; LibraryVideosFragment::class -> _fragLibraryVideos as T; + LibraryFilesFragment::class -> _fragLibraryFiles as T; else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity"); } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt index 85b08f74..1d118f95 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt @@ -45,12 +45,17 @@ class LocalClient: IPlatformClient { try { val uri = Uri.parse(url); return ContentResolver.SCHEME_CONTENT == uri.scheme - && MediaStore.AUTHORITY == uri.authority; + && ( + MediaStore.AUTHORITY == uri.authority || + uri.authority == "com.android.externalstorage.documents" + ) } catch(ex: MalformedURLException) { return false; } } + + val audioExtensions = listOf(".mp3", ".wav", ".flac", ".mp4a", ".m4a"); override fun getContentDetails(url: String): IPlatformContentDetails { val uri = Uri.parse(url); @@ -60,6 +65,12 @@ class LocalClient: IPlatformClient { else if("video" in uri.pathSegments) { return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}"); } + else if(uri.toString().contains("com.android.externalstorage.documents")) { + if(audioExtensions.any { uri.lastPathSegment?.lowercase()?.endsWith(it) ?: false }) + return StateLibrary.getAudioTrack(url) ?: throw Exception("Failed to find ${url}"); + else + return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}"); + } else throw Exception("Unknown content url [${url}]"); } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt new file mode 100644 index 00000000..fba76049 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt @@ -0,0 +1,235 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.structures.AdhocPager +import com.futo.platformplayer.api.media.structures.EmptyPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.states.FileEntry +import com.futo.platformplayer.states.StateLibrary +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.NoResultsView +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.platformplayer.views.buttons.BigButton + +class LibraryFilesFragment : MainFragment() { + override val isMainView : Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + + var view: FragView? = null; + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = FragView(this, inflater); + this.view = view; + return view; + } + + override fun onShown(parameter: Any?, isBack: Boolean) { + super.onShown(parameter, isBack) + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + view?.onShown(); + } + + override fun onDestroyMainView() { + view = null; + super.onDestroyMainView(); + } + + companion object { + fun newInstance() = LibraryFilesFragment().apply {} + } + + class FragView : FeedView, FileViewHolder> { + override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator; + + val navStack = mutableListOf() + var buttonUp: BigButton? = null; + var buttonAdd: BigButton? = null; + + constructor(fragment: LibraryFilesFragment, inflater: LayoutInflater) : super(fragment, inflater) { + } + + fun onShown() { + loadTop(); + } + fun loadTop() { + val initialDirectories = StateLibrary.instance.getFileDirectories(); + if(initialDirectories.size == 0) { + setEmptyPager(true); + setPager(EmptyPager()); + buttonAdd?.let { + it.isVisible = false; + } + buttonUp?.let { + it.isVisible = false; + } + return; + } + else + setEmptyPager(false); + navStack.clear(); + navStack.add(FileStack("", initialDirectories)); + openDirectory(navStack.last()); + } + fun leaveDirectory() { + if(navStack.size > 1) { + navStack.removeLast(); + openDirectory(navStack.last()); + } + else {} + } + fun openDirectory(stack: FileStack, addToStack: Boolean = false) { + if(addToStack) + navStack.add(stack); + + buttonAdd?.let { + it.isVisible = navStack.size < 2 + } + buttonUp?.let { + it.isVisible = navStack.size > 1; + } + setPager(AdhocPager({ listOf(); }, stack.files)); + setLoading(false); + } + + override fun getEmptyPagerView(): View? { + return NoResultsView(context, "No Directories Added", + "To see files in Grayjay you have to add directories to view", + R.drawable.ic_library, listOf( + BigButton(context, "Add Directory", "Select a directory to add", R.drawable.ic_add, { + StateLibrary.instance.addFileDirectory { + loadTop(); + }; + }) + )) + } + + override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList): InsertedViewAdapterWithLoader { + val buttonUp = BigButton(fragment.requireContext(), "Go up", "Go up a directory", R.drawable.ic_move_up) { + if(navStack.size > 1) + leaveDirectory(); + } + val buttonAdd = BigButton(fragment.requireContext(), "Add Directory", "Select a directory to add", R.drawable.ic_add) { + StateLibrary.instance.addFileDirectory { + loadTop(); + }; + } + this.buttonUp = buttonUp; + this.buttonAdd = buttonAdd; + return InsertedViewAdapterWithLoader(context, arrayListOf(buttonUp), arrayListOf(buttonAdd), + childCountGetter = { dataset.size }, + childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); }, + childViewHolderFactory = { viewGroup, _ -> + val holder = FileViewHolder(viewGroup); + holder.onClick.subscribe { c -> + if (c != null) { + if(c.isDirectory) { + openDirectory(FileStack(c.path, c.getSubFiles()), true); + } else { + fragment.navigate(c.path) + } + } + }; + holder.onDelete.subscribe { c -> + if(c != null) { + StateLibrary.instance.deleteFileDirectory(c.path); + loadTop(); + } + } + return@InsertedViewAdapterWithLoader holder; + } + ); + } + + override fun updateSpanCount(){ } + + override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager { + val glmResults = GridLayoutManager(context, 1) + + _swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply { + rightMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8.0f, + context.resources.displayMetrics + ).toInt() + } + + return glmResults + } + + companion object { + private const val TAG = "LibraryAlbumsFragmentsView"; + } + } + class FileStack( + val path: String, + val files: List + ) + + class FileViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_file, + _viewGroup, false)) { + + val onClick = Event1(); + val onDelete = Event1(); + + protected var _file: FileEntry? = null; + protected val _imageThumbnail: ImageView + protected val _buttonDelete: ImageButton; + protected val _textName: TextView + protected val _textMetadata: TextView + + init { + _imageThumbnail = _view.findViewById(R.id.image_thumbnail); + _textName = _view.findViewById(R.id.text_name); + _textMetadata = _view.findViewById(R.id.text_metadata); + _buttonDelete = _view.findViewById(R.id.button_delete); + + _view.setOnClickListener { onClick.emit(_file) }; + _buttonDelete.setOnClickListener { onDelete.emit(_file) } + } + + + override fun bind(file: FileEntry) { + _file = file; + _imageThumbnail?.let { + if(file.isDirectory) + it.setImageResource(R.drawable.ic_library); + else { + Glide.with(it) + .load(file.thumbnail) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(it) + } + }; + _buttonDelete.isVisible = file.removable; + + _textName.text = file.name; + if(file.isDirectory) + _textMetadata.text = "Directory"; + else + _textMetadata.text = ""; + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt index 77f56d62..45c79cbf 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt @@ -53,10 +53,12 @@ class LibraryFragment : MainFragment() { fun setPermissionResultAudio(access: Boolean) { allowedMusic = access; view?.setMusicPermissions(access); + StateApp.instance.hasMediaStoreAudioPermission = (access); } fun setPermissionResultVideo(access: Boolean) { allowedVideo = access; view?.setVideoPermissions(access); + StateApp.instance.hasMediaStoreVideoPermission = (access); } fun requestPermissionMusic() { @@ -161,9 +163,9 @@ class LibraryFragment : MainFragment() { fragment.navigate(); } buttonFiles.onClick.subscribe { - UIDialogs.appToast("This is gonna require a bit more work.."); + fragment.navigate() } - buttonFiles.setButtonEnabled(false); + //buttonFiles.setButtonEnabled(false); setMusicPermissions(allowMusic ?: false); setVideoPermissions(allowVideo ?: false); } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 61a902bc..aa1cf5b1 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -80,6 +80,9 @@ class StateApp { privateModeChanged.emit(privateMode); } + var hasMediaStoreAudioPermission: Boolean = false; + var hasMediaStoreVideoPermission: Boolean = false; + fun getExternalGeneralDirectory(context: Context): DocumentFile? { val generalUri = Settings.instance.storage.getStorageGeneralUri(); if(isValidStorageUri(context, generalUri)) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt index 4070cc06..a3acedb5 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt @@ -1,11 +1,16 @@ package com.futo.platformplayer.states import android.content.ContentUris +import android.content.Intent import android.database.Cursor import android.net.Uri import android.provider.MediaStore import android.provider.MediaStore.Audio.Artists -import android.provider.MediaStore.Images.ImageColumns +import android.webkit.MimeTypeMap +import androidx.core.net.toFile +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.Thumbnail @@ -21,11 +26,10 @@ import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Playlist -import com.futo.platformplayer.states.Album.Companion import com.futo.platformplayer.states.Album.Companion.TAG -import com.futo.platformplayer.states.StateLibrary.Companion.getAudioTrack -import com.futo.platformplayer.states.StateLibrary.Companion.videoFromCursor -import com.google.protobuf.Empty +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringArrayStorage +import java.io.File import java.time.Instant import java.time.OffsetDateTime import java.time.ZoneOffset @@ -33,8 +37,56 @@ import java.time.ZoneOffset class StateLibrary { + private val _files = FragmentedStorage.get("libraryFiles") + fun getFileDirectories(): List { + val context = StateApp.instance.contextOrNull ?: return listOf(); + return _files.getAllValues().map { + if(it.startsWith("content://")) { + val uri = it.toUri(); + val docFile = DocumentFile.fromTreeUri(context, uri) ?: return@map null; + //val access = context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission } + if(!docFile.isDirectory) { + _files.remove(it); + return@map null; + } + if(docFile == null) + return@map null; + return@map FileEntry.fromFile(docFile).apply { this.removable = true } + } + else + FileEntry.fromPath(it); + }.filterNotNull(); + } + fun deleteFileDirectory(path: String) { + _files.remove(path); + _files.save(); + } + fun addFileDirectory(onAdded: ((entry: FileEntry) -> Unit)? = null): Boolean { + if(!StateApp.instance.isMainActive) + return false; + val mainActivity = StateApp.instance.contextOrNull as MainActivity? ?: return false; + + StateApp.instance.requestDirectoryAccess(mainActivity, "Select Directory", + "Select a directory you would like to make accessible to Grayjay", null, { + if(it != null) { + mainActivity.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)); + try { + val file = DocumentFile.fromTreeUri(mainActivity, it) ?: return@requestDirectoryAccess; + val dir = FileEntry.fromFile(file); + _files.add(dir.path); + _files.save(); + onAdded?.invoke(dir); + } + catch(ex: Throwable) { + Logger.e(TAG, "Something went wrong converting requested directory", ex); + } + } + }); + return false; + } + fun getAlbums(): List { return Album.getAlbums(); } @@ -133,11 +185,41 @@ class StateLibrary { MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //7 ); + fun getDocumentTrack(url: String): IPlatformContentDetails? { + if(!url.contains("com.android.externalstorage.documents")) + return null; + val docFile = DocumentFile.fromSingleUri(StateApp.instance.context, url.toUri()) ?: return null; + + val contentUri = docFile.uri.toString(); + + val mimeType = MimeTypeMap.getFileExtensionFromUrl(contentUri); + + if(docFile.name != null) { + if (StateApp.instance.hasMediaStoreAudioPermission && mimeType.startsWith("audio/")) { + val aud = findAudioByName(docFile.name!!); + if (aud != null) + return aud; + } + if (StateApp.instance.hasMediaStoreVideoPermission && mimeType.startsWith("video/")) { + val vid = findVideoByName(docFile.name!!); + if (vid != null) + return vid; + } + } + + return LocalVideoDetails( + PlatformID("FILE", contentUri, null, 0, -1), + docFile.name ?: docFile.uri.toString(), Thumbnails(arrayOf( + Thumbnail(docFile.uri.toString(), 0) + )), PlatformAuthorLink.UNKNOWN, contentUri, 0, mimeType, null); + } + fun getAudioTrack(url: String): IPlatformContentDetails? { val uri = Uri.parse(url); val id = uri.lastPathSegment?.toLongOrNull(); - if(id == null) - return null; + if(id == null) { + return getDocumentTrack(url); + } val resolver = StateApp.instance.contextOrNull?.contentResolver; if(resolver == null) { @@ -152,11 +234,25 @@ class StateLibrary { return null; return audioFromCursor(cursor); } + fun findAudioByName(name: String): IPlatformContentDetails? { + val resolver = StateApp.instance.contextOrNull?.contentResolver; + if(resolver == null) { + Logger.w(TAG, "Audio contentResolver not found"); + return null; + } + val cursor = resolver?.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.DISPLAY_NAME} = ?", arrayOf(name), + null) ?: return null; + cursor.moveToFirst(); + if(cursor.isAfterLast) + return null; + return audioFromCursor(cursor); + } fun getVideoTrack(url: String): IPlatformContentDetails? { val uri = Uri.parse(url); val id = uri.lastPathSegment?.toLongOrNull(); if(id == null) - return null; + return getDocumentTrack(url); val resolver = StateApp.instance.contextOrNull?.contentResolver; if(resolver == null) { @@ -171,6 +267,20 @@ class StateLibrary { return null; return videoFromCursor(cursor); } + fun findVideoByName(name: String): IPlatformContentDetails? { + val resolver = StateApp.instance.contextOrNull?.contentResolver; + if(resolver == null) { + Logger.w(TAG, "Album contentResolver not found"); + return null; + } + val cursor = resolver?.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media.DISPLAY_NAME} = ?", arrayOf(name), + null) ?: return null; + cursor.moveToFirst(); + if(cursor.isAfterLast) + return null; + return videoFromCursor(cursor); + } fun audioFromCursor(cursor: Cursor): IPlatformVideoDetails { val id = cursor.getString(0); @@ -456,4 +566,46 @@ class Album { return list; } } +} + + +class FileEntry( + val path: String, + val name: String, + val isDirectory: Boolean = false, + val thumbnail: String? = null, + + var removable: Boolean = false +) { + + fun getSubFiles(): List { + if(isDirectory) { + if(path.startsWith("content://")) + return DocumentFile.fromTreeUri(StateApp.instance.context, path.toUri())?.listFiles() + ?.map { fromFile(it) } ?: return listOf(); + return File(path).listFiles() + .map { fromFile(it) } + } + return listOf(); + } + + companion object { + fun fromPath(path: String): FileEntry { + /* + val cursor = StateApp.instance.context.contentResolver.query(path.toUri(), null, null, null, null); + cursor?.moveToFirst(); + val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)); + cursor?.close(); + return FileEntry(path, fileName, ); + */ + val file = File(path); + return FileEntry(file.path, file.name, file.isDirectory); + } + fun fromFile(file: File): FileEntry { + return FileEntry(file.path, file.name, file.isDirectory); + } + fun fromFile(file: DocumentFile): FileEntry { + return FileEntry(file.uri.toString(), file.name ?: "", file.isDirectory); + } + } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragview_library.xml b/app/src/main/res/layout/fragview_library.xml index 3c04a074..a3dd02c1 100644 --- a/app/src/main/res/layout/fragview_library.xml +++ b/app/src/main/res/layout/fragview_library.xml @@ -51,7 +51,7 @@ android:layout_height="wrap_content" app:buttonIcon="@drawable/ic_videocam" app:buttonText="Videos" - app:buttonSubText="All artists known on this phone" + app:buttonSubText="All local videos on this phone" android:layout_marginLeft="10dp" android:layout_marginRight="10dp" android:layout_marginBottom="8dp" /> @@ -79,7 +79,7 @@ android:layout_height="wrap_content" android:textAlignment="center" android:layout_marginTop="20dp" - android:text="Library UI is temporary, and will be replaced" + android:text="" android:textColor="#AAAAAA" /> diff --git a/app/src/main/res/layout/list_file.xml b/app/src/main/res/layout/list_file.xml new file mode 100644 index 00000000..f3e363f1 --- /dev/null +++ b/app/src/main/res/layout/list_file.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + \ No newline at end of file From 347ef855b3b9dbb819e56023973d50ae41f36077 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Sat, 8 Nov 2025 19:02:38 +0100 Subject: [PATCH 04/12] Library continuation, disable auto backup ask, minor tweaks. --- .../java/com/futo/platformplayer/Settings.kt | 2 +- .../main/LibraryArtistsFragment.kt | 5 +- .../mainactivity/main/LibraryFragment.kt | 165 ++++++++++++++---- .../futo/platformplayer/states/StateApp.kt | 3 + .../platformplayer/states/StateLibrary.kt | 38 +++- .../platformplayer/views/AnyAdapterView.kt | 4 +- .../platformplayer/views/LibrarySection.kt | 49 ++++++ .../viewholders/AlbumTileViewHolder.kt | 52 ++++++ .../viewholders/ArtistTileViewHolder.kt | 53 ++++++ .../viewholders/LocalVideoTileViewHolder.kt | 63 +++++++ app/src/main/res/drawable/unknown_music.png | Bin 0 -> 1946 bytes app/src/main/res/layout/fragview_library.xml | 37 ++-- app/src/main/res/layout/list_album_tile.xml | 67 +++++++ app/src/main/res/layout/list_artist.xml | 22 ++- app/src/main/res/layout/list_artist_tile.xml | 70 ++++++++ .../res/layout/list_video_thumbnail_tile.xml | 113 ++++++++++++ .../main/res/layout/view_library_section.xml | 56 ++++++ app/src/main/res/values/styles.xml | 8 + 18 files changed, 746 insertions(+), 61 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/views/LibrarySection.kt create mode 100644 app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/AlbumTileViewHolder.kt create mode 100644 app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ArtistTileViewHolder.kt create mode 100644 app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/LocalVideoTileViewHolder.kt create mode 100644 app/src/main/res/drawable/unknown_music.png create mode 100644 app/src/main/res/layout/list_album_tile.xml create mode 100644 app/src/main/res/layout/list_artist_tile.xml create mode 100644 app/src/main/res/layout/list_video_thumbnail_tile.xml create mode 100644 app/src/main/res/layout/view_library_section.xml diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index d67a531a..673c58c0 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -951,7 +951,7 @@ class Settings : FragmentedStorageFileJson() { class Backup { @Serializable(with = OffsetDateTimeSerializer::class) var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN; - var didAskAutoBackup: Boolean = false; + var didAskAutoBackup: Boolean = true; var autoBackupPassword: String? = null; fun shouldAutomaticBackup() = autoBackupPassword != null; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt index 88d4ffdf..335aa7f7 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistsFragment.kt @@ -32,6 +32,7 @@ import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.Album import com.futo.platformplayer.states.Artist +import com.futo.platformplayer.states.ArtistOrdering import com.futo.platformplayer.states.StateLibrary import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringStorage @@ -82,7 +83,7 @@ class LibraryArtistsFragment : MainFragment() { constructor(fragment: LibraryArtistsFragment, inflater: LayoutInflater) : super(fragment, inflater) fun onShown() { - val intialArtists = StateLibrary.instance.getArtists(); + val intialArtists = StateLibrary.instance.getArtists(ArtistOrdering.Alphabethic); Logger.i(TAG, "Initial album count: " + intialArtists.size); setPager(AdhocPager({ listOf(); }, intialArtists)); @@ -91,7 +92,7 @@ class LibraryArtistsFragment : MainFragment() { override fun reload() { try { setLoading(true); - val intialArtists = StateLibrary.instance.getArtists(); + val intialArtists = StateLibrary.instance.getArtists(ArtistOrdering.Alphabethic); Logger.i(TAG, "Initial album count: " + intialArtists.size); setPager(AdhocPager({ listOf(); }, intialArtists)); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt index 45c79cbf..62d459be 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt @@ -7,16 +7,37 @@ import android.provider.MediaStore import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup +import android.widget.LinearLayout import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.dp +import com.futo.platformplayer.states.Album +import com.futo.platformplayer.states.Artist +import com.futo.platformplayer.states.ArtistOrdering +import com.futo.platformplayer.states.FileEntry import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateLibrary +import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny +import com.futo.platformplayer.views.AnyInsertedAdapterView +import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop +import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews +import com.futo.platformplayer.views.LibrarySection +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.platformplayer.views.adapters.InsertedViewAdapter +import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder +import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder +import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder import com.futo.platformplayer.views.buttons.BigButton @@ -117,13 +138,17 @@ class LibraryFragment : MainFragment() { class FragView: ConstraintLayout { val fragment: LibraryFragment; - var buttonArtists: BigButton; - var buttonAlbums: BigButton; - var buttonVideos: BigButton; - var buttonPlaylists: BigButton; - var buttonFiles: BigButton; + var sectionArtists: LibrarySection; + var sectionAlbums: LibrarySection; + var sectionVideos: LibrarySection; + var sectionFiles: LibrarySection; + //var buttonFiles: BigButton; - var metaInfo: TextView; + val recycler: RecyclerView; + + val adapterFiles: AnyInsertedAdapterView; + + //var metaInfo: TextView; var allowMusic: Boolean = false; var allowVideo: Boolean = false; @@ -131,61 +156,135 @@ class LibraryFragment : MainFragment() { constructor(fragment: LibraryFragment, allowMusic: Boolean?, allowVideo: Boolean?) : super(fragment.requireContext()) { inflate(context, R.layout.fragview_library, this); this.fragment = fragment; - buttonArtists = findViewById(R.id.button_artists); - buttonAlbums = findViewById(R.id.button_albums); - buttonVideos = findViewById(R.id.button_videos); - buttonPlaylists = findViewById(R.id.button_playlists); - buttonFiles = findViewById(R.id.button_files); - metaInfo = findViewById(R.id.meta_info); + recycler = findViewById(R.id.recycler); + sectionArtists = LibrarySection(context)//findViewById(R.id.section_artists); + sectionArtists.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 155.dp(resources)).apply { + this.setMargins(0,10.dp(resources), 0, 0); + } + sectionAlbums = LibrarySection(context)//findViewById(R.id.section_albums); + sectionAlbums.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 185.dp(resources)).apply { + this.setMargins(0,0, 0, 0); + } + sectionVideos = LibrarySection(context)//findViewById(R.id.section_videos); + sectionVideos.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 170.dp(resources)).apply { + this.setMargins(0,0, 0, 0); + } + sectionFiles = LibrarySection(context)//findViewById(R.id.section_videos); + sectionFiles.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 40.dp(resources)).apply { + this.setMargins(0,0, 0, 0); + } + sectionFiles.setSection("Directories") { + StateLibrary.instance.addFileDirectory { + reloadFiles(); + } + } + sectionFiles.setNavIcon(R.drawable.ic_add); + //buttonFiles = findViewById(R.id.button_files); + //metaInfo = findViewById(R.id.meta_info); this.allowMusic = allowMusic ?: false; this.allowVideo = allowVideo ?: false; - buttonArtists.onClick.subscribe { + sectionArtists.setSection("Artists", { if(this.allowMusic) fragment.navigate(); else fragment.requestPermissionMusic(); - } - buttonAlbums.onClick.subscribe { + }); + val adapterArtists = sectionArtists.getAnyAdapter({ + it.onClick.subscribe { + if(it != null) + fragment.navigate(it); + } + }); + val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount); + adapterArtists.setData(artists); + + sectionAlbums.setSection("Albums", { if(this.allowMusic) fragment.navigate(); else fragment.requestPermissionMusic(); - } - buttonVideos.onClick.subscribe { + }); + val adapterAlbums = sectionAlbums.getAnyAdapter({ + it.onClick.subscribe { + if(it != null) + fragment.navigate(it); + } + }); + val albums = StateLibrary.instance.getAlbums(); + adapterAlbums.setData(albums); + + + sectionVideos.setSection("Videos", { if(this.allowVideo) fragment.navigate(); else fragment.requestPermissionVideo(); - } - buttonPlaylists.onClick.subscribe { - fragment.navigate(); - } + }); + val adapterVideos = sectionVideos.getAnyAdapter({ + it.onClick.subscribe { + if(it != null) + fragment.navigate(it); + } + }); + val videos = StateLibrary.instance.getRecentVideos(null, 20); + adapterVideos.setData(videos); + + adapterFiles = recycler.asAnyWithViews( + arrayListOf( + sectionArtists, + sectionAlbums, + sectionVideos, + sectionFiles + ), arrayListOf(), RecyclerView.VERTICAL, false, { + it.onClick.subscribe { + if(it != null) + fragment.navigate(it); + } + it.onDelete.subscribe { + if(it != null) { + StateLibrary.instance.deleteFileDirectory(it.path); + reloadFiles(); + } + } + } + ); + reloadFiles(); + + + /* buttonFiles.onClick.subscribe { fragment.navigate() - } + } */ //buttonFiles.setButtonEnabled(false); setMusicPermissions(allowMusic ?: false); setVideoPermissions(allowVideo ?: false); } + fun reloadFiles() { + val files = StateLibrary.instance.getFileDirectories(); + adapterFiles.setData(files); + } + + fun setMusicPermissions(access: Boolean) { allowMusic = access; - buttonAlbums.setButtonEnabled(access); - buttonArtists.setButtonEnabled(access); - metaInfo.text = listOf( - if(!allowMusic) "You did not give access to local music, so these options are disabled" else null, - if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null - ).filterNotNull().joinToString("\n"); + sectionAlbums.setContentEmptyMessage(R.drawable.ic_library, "No mediastore permissions"); + sectionArtists.setContentEmptyMessage(R.drawable.ic_library, "No mediastore permissions"); + //buttonArtists.setButtonEnabled(access); + //metaInfo.text = listOf( + // if(!allowMusic) "You did not give access to local music, so these options are disabled" else null, + // if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null + //).filterNotNull().joinToString("\n"); } fun setVideoPermissions(access: Boolean) { allowVideo = access; - buttonVideos.setButtonEnabled(access); - metaInfo.text = listOf( - if(!allowMusic) "You did not give access to local music, so these options are disabled" else null, - if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null - ).filterNotNull().joinToString("\n"); + sectionVideos.setContentEmptyMessage(R.drawable.ic_library, "No video permissions"); + //metaInfo.text = listOf( + // if(!allowMusic) "You did not give access to local music, so these options are disabled" else null, + // if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null + //).filterNotNull().joinToString("\n"); } fun onShown() { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index aa1cf5b1..70ffb4ba 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -593,7 +593,9 @@ class StateApp { scheduleBackgroundWork(context, interval != 0, interval); Logger.i(TAG, "MainApp Started: Initialize [AutoBackup]"); + Settings.instance.backup.didAskAutoBackup = true; //Some users have issues with it if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) { + /* StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", { if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) { UIDialogs.toast("Missing general directory"); @@ -610,6 +612,7 @@ class StateApp { Settings.instance.backup.didAskAutoBackup = true; Settings.instance.save(); }); + */ } else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) { if(context is IWithResultLauncher) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt index a3acedb5..8e01f6c0 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt @@ -100,8 +100,8 @@ class StateLibrary { return Album.getAlbum(id); } - fun getArtists(): List { - return Artist.getArtists(); + fun getArtists(ordering: ArtistOrdering): List { + return Artist.getArtists(ordering); } fun getArtist(str: String): Artist? { val idLong = str.toLongOrNull(); @@ -114,8 +114,9 @@ class StateLibrary { } fun getVideos(buckets: List? = null): IPager { + var query = if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null; val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION_VIDEO, - if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null, + query, null, MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager(); cursor.moveToFirst(); @@ -134,6 +135,16 @@ class StateLibrary { return@AdhocPager list; }, list); } + fun getRecentVideos(buckets: List? = null, count: Int = 20): List { + val videoPager = getVideos(buckets); + val items = mutableListOf(); + while(videoPager.getResults().size > 0 && items.size < count) { + items.addAll(videoPager.getResults().filter { it is IPlatformVideo }.map { it as IPlatformVideo }); + if(videoPager.hasMorePages()) + videoPager.nextPage(); + } + return items; + } private var _cacheBucketNames: List? = null; fun getVideoBucketNames(): List { @@ -363,6 +374,12 @@ class StateLibrary { class Bucket(val id: Long, val name: String); + +enum class ArtistOrdering { + Alphabethic, + TrackCount, + AlbumCount +} class Artist { val id: String; val name: String; @@ -424,9 +441,18 @@ class Artist { return null; return Artist.fromCursor(cursor); } - fun getArtists(): List { - val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(Artists.EXTERNAL_CONTENT_URI, PROJECTION, null, null, - Artists.ARTIST + " ASC") ?: return listOf(); + fun getArtists(ordering: ArtistOrdering = ArtistOrdering.Alphabethic): List { + val ordering = when(ordering) { + ArtistOrdering.Alphabethic -> Artists.ARTIST + " ASC"; + ArtistOrdering.AlbumCount -> Artists.NUMBER_OF_ALBUMS + " DESC"; + ArtistOrdering.TrackCount -> Artists.NUMBER_OF_TRACKS + " DESC"; + else -> null + } + + val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(Artists.EXTERNAL_CONTENT_URI, PROJECTION, + null, + null, + ordering) ?: return listOf(); cursor.moveToFirst(); val list = mutableListOf() while(!cursor.isAfterLast) { diff --git a/app/src/main/java/com/futo/platformplayer/views/AnyAdapterView.kt b/app/src/main/java/com/futo/platformplayer/views/AnyAdapterView.kt index db295aa3..2d8af452 100644 --- a/app/src/main/java/com/futo/platformplayer/views/AnyAdapterView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/AnyAdapterView.kt @@ -72,9 +72,9 @@ class AnyInsertedAdapterView(view: RecyclerView, adapter: BaseAnyAdapter> RecyclerView.asAnyWithViews(prepend: ArrayList = arrayListOf(), append: ArrayList = arrayListOf(), orientation: Int = RecyclerView.VERTICAL, reversed: Boolean = false, noinline onCreate: ((T)->Unit)? = null) : AnyInsertedAdapterView { for(view in prepend) - (view.parent as ViewGroup).removeView(view); + (view.parent as ViewGroup?)?.removeView(view); for(view in append) - (view.parent as ViewGroup).removeView(view); + (view.parent as ViewGroup?)?.removeView(view); return AnyInsertedAdapterView(this, AnyInsertedAdapter.create(prepend, append, onCreate), orientation, reversed); } inline fun> RecyclerView.asAnyWithViews(list: ArrayList, prepend: ArrayList = arrayListOf(), append: ArrayList = arrayListOf(), orientation: Int = RecyclerView.VERTICAL, reversed: Boolean = false, noinline onCreate: ((T)->Unit)? = null) : AnyInsertedAdapterView { diff --git a/app/src/main/java/com/futo/platformplayer/views/LibrarySection.kt b/app/src/main/java/com/futo/platformplayer/views/LibrarySection.kt new file mode 100644 index 00000000..176175be --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/LibrarySection.kt @@ -0,0 +1,49 @@ +package com.futo.platformplayer.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintLayout.GONE +import androidx.constraintlayout.widget.ConstraintLayout.inflate +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.platformplayer.views.adapters.AnyAdapter.AnyViewHolder +import com.google.android.material.imageview.ShapeableImageView + +class LibrarySection: ConstraintLayout { + val textName: TextView; + val imageNavigate: ImageView; + val recycler: RecyclerView; + + + constructor(context: Context, attr: AttributeSet? = null) : super(context, attr) { + inflate(context, R.layout.view_library_section, this); + textName = findViewById(R.id.text_label) + imageNavigate = findViewById(R.id.image_nav) + recycler = findViewById(R.id.recycler_collection); + + } + + fun setNavIcon(resId: Int) { + imageNavigate.setImageResource(resId); + } + + fun setContentEmptyMessage(icon: Int, msg: String) { + + } + inline fun > getAnyAdapter(noinline onCreate: ((V)->Unit)? = null, orientation: Int = RecyclerView.HORIZONTAL): AnyAdapterView { + return recycler.asAny(orientation, false, onCreate); + } + + inline fun setSection(title: String, crossinline onOpen: (()->Unit)) { + textName.text = title; + imageNavigate.setOnClickListener { onOpen.invoke() }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/AlbumTileViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/AlbumTileViewHolder.kt new file mode 100644 index 00000000..66d75a95 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/AlbumTileViewHolder.kt @@ -0,0 +1,52 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.states.Album +import com.futo.platformplayer.views.adapters.AnyAdapter + + +class AlbumTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate( + R.layout.list_album_tile, + _viewGroup, false)) { + + val onClick = Event1(); + + protected var _album: Album? = null; + protected val _imageThumbnail: ImageView + protected val _textName: TextView + protected val _textMetadata: TextView + + init { + _imageThumbnail = _view.findViewById(R.id.image_thumbnail); + _textName = _view.findViewById(R.id.text_name); + _textMetadata = _view.findViewById(R.id.text_metadata); + + _view.setOnClickListener { onClick.emit(_album) }; + } + + + override fun bind(album: Album) { + _album = album; + _imageThumbnail?.let { + if (album.thumbnail != null) + Glide.with(it) + .load(album.thumbnail) + .placeholder(R.drawable.unknown_music) + .into(it) + else + Glide.with(it).load(R.drawable.unknown_music).into(it); + }; + + _textName.text = album.name; + _textMetadata.text = album.artist ?: ""; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ArtistTileViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ArtistTileViewHolder.kt new file mode 100644 index 00000000..ab4f4664 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ArtistTileViewHolder.kt @@ -0,0 +1,53 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.states.Album +import com.futo.platformplayer.states.Artist +import com.futo.platformplayer.views.adapters.AnyAdapter + + +class ArtistTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate( + R.layout.list_artist_tile, + _viewGroup, false)) { + + val onClick = Event1(); + + protected var _artist: Artist? = null; + protected val _imageThumbnail: ImageView + protected val _textName: TextView + protected val _textMetadata: TextView + + init { + _imageThumbnail = _view.findViewById(R.id.image_thumbnail); + _textName = _view.findViewById(R.id.text_name); + _textMetadata = _view.findViewById(R.id.text_metadata); + + _view.setOnClickListener { onClick.emit(_artist) }; + } + + + override fun bind(artist: Artist) { + _artist = artist; + _imageThumbnail?.let { + if (artist.thumbnail != null) + Glide.with(it) + .load(artist.thumbnail) + .placeholder(R.drawable.unknown_music) + .into(it) + else + Glide.with(it).load(R.drawable.unknown_music).into(it); + }; + + _textName.text = artist.name; + _textMetadata.text = ""// "${artist.countTracks} tracks"; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/LocalVideoTileViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/LocalVideoTileViewHolder.kt new file mode 100644 index 00000000..1adee422 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/LocalVideoTileViewHolder.kt @@ -0,0 +1,63 @@ +package com.futo.platformplayer.views.adapters.viewholders + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.states.Album +import com.futo.platformplayer.states.Artist +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanTime +import com.futo.platformplayer.views.adapters.AnyAdapter +import com.google.android.material.imageview.ShapeableImageView + + +class LocalVideoTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate( + R.layout.list_video_thumbnail_tile, + _viewGroup, false)) { + + val onClick = Event1(); + + protected var _content: IPlatformVideo? = null; + protected val _imageThumbnail: ShapeableImageView + protected val _textDuration: TextView; + protected val _textName: TextView + protected val _textMetadata: TextView + + init { + _imageThumbnail = _view.findViewById(R.id.image_video_thumbnail); + _textDuration = _view.findViewById(R.id.thumbnail_duration); + _textName = _view.findViewById(R.id.text_video_name); + _textMetadata = _view.findViewById(R.id.text_video_metadata); + + _view.setOnClickListener { onClick.emit(_content) }; + } + + + override fun bind(content: IPlatformVideo) { + _content = content; + _imageThumbnail?.let { + if (content.thumbnails.getHQThumbnail() != null) + Glide.with(it) + .load(content.thumbnails.getHQThumbnail()) + .placeholder(R.drawable.unknown_music) + .into(it) + else + Glide.with(it).load(R.drawable.unknown_music).into(it); + }; + + _textName.text = content.name; + _textMetadata.text = content.datetime?.toHumanNowDiffString(); + _textDuration.text = content.duration.toHumanTime(false) + " ago"; + _textDuration.isVisible = content.duration > 0; + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/unknown_music.png b/app/src/main/res/drawable/unknown_music.png new file mode 100644 index 0000000000000000000000000000000000000000..f3e12684ff1b9ad0ad64bd27bba8702bc0d64e02 GIT binary patch literal 1946 zcmdT_X;2af6h@`g$Q{jV(oEBg%DgtOJi;4E5p=!5vkZ&!NYOkJZCA?!-NsC@0<;!Q z6tXN$7tAZ$NX{0c{8sF=?_)grMC+J0I0!y zJOkyrxJ{Lva=Y-L;j&y*lYPQd0RSD-Z7RS54}O*h6;cDC?f^P_-zT{P#JIuT0DzWq z&}Qro06^Ii=IIu6Sz+lzw|`Lh{(&z_?gzVD57zixEjn1!e%u0c;yZAJVTG_N=gevP zb$PF3kz#&Eg?1#@4(-;nO?XaGC~%DtM#FHwhoC6JF~;;NtT8Fr!$^ZgN`3nbclSIL z-Ie>Dp{%To7rErt)YL>IKKGGCWOJ}f-D0trSxM?OyshUP9u*Ze!RK#aRw`sec`4F( zELJq>jbJV+N2Dw-FK^Yae@#?+(df-BH-v13-S8imHQabyq-p$>+L<8`2=L306$0rs zb7&Q>c6}Hm82~W@Wuv7ebqpTg1?krY1BC<%1a59_ZeU|HoTe0~s&(~;#-NP@l}IGo z+1rm>M0Rk~Gcx!eKc3~kdv|U(*;U$DoSd7>!tVoyGt4->Yinz~k$ME5-Pt>f#}VSa|Itda5QH2I^EE$*MyULzYY}^mY{1HwR!B-irN>H5Q}EzJZok!>F9gK3;Ie>y zcT3}^RMUVwdF7G{%RP(LYEQw_zCg#bY-_1bzPr_bqH{i{YH4X{i+N4hQ{2%&PE@)1 zmTT^Nw6D3jc`GH^<6(ERpN_8X4&H%$(l6TYV}kbeg+bS~UKw+aFDG38RghS5VlObO zFepb~U*Fx?1L&AvR(4(c5B0Z)@^{iceKKNMnolIJsoG5NcoEru&%@?6FV!jnH4pzU z7`QakQ{bHeeAV)1aL~5H1X;H5s?v#N&$f(Uf<9;Zi&ARXUM&ff8(wj0n2h>pr= zG+KONq)X?wYClDTx?%>k=}CNm$yiKfnpGCzw5aYWvXoTeOYuN%6xp6KrI$thdAY^< z88GXVebS_O#f;jh(Q3x$^9eJ17LRaAak?eW^+O9^)3I2ZH98~((LinelyZy7WR4XK z4i2(eA~?zsRTrtEPYliU$3MBT`Q0e0yl^sOUnq zfWD3O3XT@MdyKhQeUuF?-%sR~RbS!6^rELTS3kmKjdcqP3+WN^b6EN^H!v&Cb1i>B z3m(#|o$SI-6+U2Vi?CxmbihPat`8dI&WP?xO!J@&aOImN1l~jD%d=$bZ5^YyzNx?f zdULY`Ivp!3=qAhC9JRdAzl!OasF!R?SM5$|qg_rhH_j$2cjb3ix=~x^Oy%=OivxD@ zB&^wKy$F*t`)Tc9WPaKCMpWqhMCch;3OES0yLS@Rc091Ocz21HvC1Ljj}5yk$;l6g^BkCVhJFY*{QFp*=XY1xhdhUkjg57S zoNu3F7p@uRhAlF;e3gv9&ThuzaFX#YUGCAX>^n%WIfn50~2>ui#ArlCMC9_ca zCXbd*-wTVqaDHCgFI|tS(3fNh7s1CYEG!TRgzUYOJ(HiE*`{C;>~w@VkR(}zV`bUj bPRo?@G>#y(``3cyuM`0D^7o{>$6WmvfImB7 literal 0 HcmV?d00001 diff --git a/app/src/main/res/layout/fragview_library.xml b/app/src/main/res/layout/fragview_library.xml index a3dd02c1..b7be6263 100644 --- a/app/src/main/res/layout/fragview_library.xml +++ b/app/src/main/res/layout/fragview_library.xml @@ -9,22 +9,31 @@ android:background="@drawable/bottom_menu_border" android:id="@+id/root" android:clickable="true"> - + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_album_tile.xml b/app/src/main/res/layout/list_album_tile.xml new file mode 100644 index 00000000..01f63a0e --- /dev/null +++ b/app/src/main/res/layout/list_album_tile.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_artist.xml b/app/src/main/res/layout/list_artist.xml index 270cab96..cef7fe70 100644 --- a/app/src/main/res/layout/list_artist.xml +++ b/app/src/main/res/layout/list_artist.xml @@ -6,10 +6,14 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:layout_marginTop="5dp" - android:layout_marginBottom="5dp" + android:layout_marginTop="0dp" + android:layout_marginBottom="0dp" android:id="@+id/root" - android:clickable="true"> + android:clickable="true" + android:paddingLeft="10dp" + android:paddingRight="10dp" + android:paddingBottom="10dp" + android:paddingTop="10dp"> --> + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_artist_tile.xml b/app/src/main/res/layout/list_artist_tile.xml new file mode 100644 index 00000000..667257bc --- /dev/null +++ b/app/src/main/res/layout/list_artist_tile.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_video_thumbnail_tile.xml b/app/src/main/res/layout/list_video_thumbnail_tile.xml new file mode 100644 index 00000000..dae0f7b5 --- /dev/null +++ b/app/src/main/res/layout/list_video_thumbnail_tile.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_library_section.xml b/app/src/main/res/layout/view_library_section.xml new file mode 100644 index 00000000..12c13492 --- /dev/null +++ b/app/src/main/res/layout/view_library_section.xml @@ -0,0 +1,56 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index dda08d7b..859df29b 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -23,6 +23,14 @@ rounded 16dp + + +