diff --git a/app/src/main/java/com/cappielloantonio/play/interfaces/ClickCallback.java b/app/src/main/java/com/cappielloantonio/play/interfaces/ClickCallback.java index 5216e632..cbb47b6a 100644 --- a/app/src/main/java/com/cappielloantonio/play/interfaces/ClickCallback.java +++ b/app/src/main/java/com/cappielloantonio/play/interfaces/ClickCallback.java @@ -28,9 +28,13 @@ public interface ClickCallback { default void onServerLongClick(Bundle bundle) {} - default void onPodcastClick(Bundle bundle) {} + default void onPodcastEpisodeClick(Bundle bundle) {} - default void onPodcastLongClick(Bundle bundle) {} + default void onPodcastEpisodeLongClick(Bundle bundle) {} + + default void onPodcastChannelClick(Bundle bundle) {} + + default void onPodcastChannelLongClick(Bundle bundle) {} default void onInternetRadioStationClick(Bundle bundle) {} diff --git a/app/src/main/java/com/cappielloantonio/play/service/MediaManager.java b/app/src/main/java/com/cappielloantonio/play/service/MediaManager.java index f2d3643d..89a72abf 100644 --- a/app/src/main/java/com/cappielloantonio/play/service/MediaManager.java +++ b/app/src/main/java/com/cappielloantonio/play/service/MediaManager.java @@ -10,6 +10,7 @@ import com.cappielloantonio.play.repository.QueueRepository; import com.cappielloantonio.play.repository.SongRepository; import com.cappielloantonio.play.subsonic.models.Child; import com.cappielloantonio.play.subsonic.models.InternetRadioStation; +import com.cappielloantonio.play.subsonic.models.PodcastEpisode; import com.cappielloantonio.play.util.MappingUtil; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -146,6 +147,23 @@ public class MediaManager { } } + public static void startPodcast(ListenableFuture mediaBrowserListenableFuture, PodcastEpisode podcastEpisode) { + if (mediaBrowserListenableFuture != null) { + mediaBrowserListenableFuture.addListener(() -> { + try { + if (mediaBrowserListenableFuture.isDone()) { + mediaBrowserListenableFuture.get().clearMediaItems(); + mediaBrowserListenableFuture.get().setMediaItem(MappingUtil.mapMediaItem(podcastEpisode)); + mediaBrowserListenableFuture.get().prepare(); + mediaBrowserListenableFuture.get().play(); + } + } catch (ExecutionException | InterruptedException e) { + e.printStackTrace(); + } + }, MoreExecutors.directExecutor()); + } + } + public static void enqueue(ListenableFuture mediaBrowserListenableFuture, List media, boolean playImmediatelyAfter) { if (mediaBrowserListenableFuture != null) { mediaBrowserListenableFuture.addListener(() -> { diff --git a/app/src/main/java/com/cappielloantonio/play/subsonic/models/PodcastEpisode.kt b/app/src/main/java/com/cappielloantonio/play/subsonic/models/PodcastEpisode.kt index 8af56379..7522353c 100644 --- a/app/src/main/java/com/cappielloantonio/play/subsonic/models/PodcastEpisode.kt +++ b/app/src/main/java/com/cappielloantonio/play/subsonic/models/PodcastEpisode.kt @@ -1,6 +1,7 @@ package com.cappielloantonio.play.subsonic.models import android.os.Parcelable +import androidx.room.ColumnInfo import com.google.gson.annotations.SerializedName import kotlinx.parcelize.Parcelize import java.util.* @@ -29,11 +30,14 @@ class PodcastEpisode : Parcelable { var transcodedContentType: String? = null var transcodedSuffix: String? = null var duration: Int? = null - var bitRate: Int? = null + @ColumnInfo("bitrate") + @SerializedName("bitRate") + var bitrate: Int? = null var path: String? = null + @ColumnInfo(name = "is_video") @SerializedName("isVideo") - var video: Boolean? = null + var isVideo: Boolean = false var userRating: Int? = null var averageRating: Double? = null var playCount: Long? = null diff --git a/app/src/main/java/com/cappielloantonio/play/ui/adapter/PodcastChannelCatalogueAdapter.java b/app/src/main/java/com/cappielloantonio/play/ui/adapter/PodcastChannelCatalogueAdapter.java new file mode 100644 index 00000000..ae1812c8 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/play/ui/adapter/PodcastChannelCatalogueAdapter.java @@ -0,0 +1,146 @@ +package com.cappielloantonio.play.ui.adapter; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.Filter; +import android.widget.Filterable; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.cappielloantonio.play.databinding.ItemHomeCataloguePodcastChannelBinding; +import com.cappielloantonio.play.databinding.ItemLibraryCatalogueAlbumBinding; +import com.cappielloantonio.play.glide.CustomGlideRequest; +import com.cappielloantonio.play.interfaces.ClickCallback; +import com.cappielloantonio.play.subsonic.models.AlbumID3; +import com.cappielloantonio.play.subsonic.models.PodcastChannel; +import com.cappielloantonio.play.util.Constants; +import com.cappielloantonio.play.util.MusicUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +public class PodcastChannelCatalogueAdapter extends RecyclerView.Adapter implements Filterable { + private final ClickCallback click; + private final Filter filtering = new Filter() { + @Override + protected FilterResults performFiltering(CharSequence constraint) { + List filteredList = new ArrayList<>(); + + if (constraint == null || constraint.length() == 0) { + filteredList.addAll(podcastChannelsFull); + } else { + String filterPattern = constraint.toString().toLowerCase().trim(); + + for (PodcastChannel item : podcastChannelsFull) { + if (item.getTitle().toLowerCase().contains(filterPattern)) { + filteredList.add(item); + } + } + } + + FilterResults results = new FilterResults(); + results.values = filteredList; + + return results; + } + + @Override + protected void publishResults(CharSequence constraint, FilterResults results) { + podcastChannels.clear(); + podcastChannels.addAll((List) results.values); + notifyDataSetChanged(); + } + }; + + private List podcastChannels; + private List podcastChannelsFull; + + public PodcastChannelCatalogueAdapter(ClickCallback click) { + this.click = click; + this.podcastChannels = Collections.emptyList(); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + ItemHomeCataloguePodcastChannelBinding view = ItemHomeCataloguePodcastChannelBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(ViewHolder holder, int position) { + PodcastChannel podcastChannel = podcastChannels.get(position); + + holder.item.podcastChannelTitleLabel.setText(MusicUtil.getReadableString(podcastChannel.getTitle())); + + CustomGlideRequest.Builder + .from(holder.itemView.getContext(), podcastChannel.getCoverArtId()) + .build() + .into(holder.item.podcastChannelCatalogueCoverImageView); + } + + @Override + public int getItemCount() { + return podcastChannels.size(); + } + + public PodcastChannel getItem(int position) { + return podcastChannels.get(position); + } + + public void setItems(List podcastChannels) { + this.podcastChannels = podcastChannels; + this.podcastChannelsFull = new ArrayList<>(podcastChannels); + notifyDataSetChanged(); + } + + @Override + public int getItemViewType(int position) { + return position; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public Filter getFilter() { + return filtering; + } + + public class ViewHolder extends RecyclerView.ViewHolder { + ItemHomeCataloguePodcastChannelBinding item; + + ViewHolder(ItemHomeCataloguePodcastChannelBinding item) { + super(item.getRoot()); + + this.item = item; + + item.podcastChannelTitleLabel.setSelected(true); + + itemView.setOnClickListener(v -> onClick()); + itemView.setOnLongClickListener(v -> onLongClick()); + } + + private void onClick() { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.PODCAST_CHANNEL_OBJECT, podcastChannels.get(getBindingAdapterPosition())); + + click.onAlbumClick(bundle); + } + + private boolean onLongClick() { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.PODCAST_CHANNEL_OBJECT, podcastChannels.get(getBindingAdapterPosition())); + + click.onAlbumLongClick(bundle); + + return true; + } + } +} diff --git a/app/src/main/java/com/cappielloantonio/play/ui/adapter/PodcastChannelHorizontalAdapter.java b/app/src/main/java/com/cappielloantonio/play/ui/adapter/PodcastChannelHorizontalAdapter.java new file mode 100644 index 00000000..6c56e5bf --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/play/ui/adapter/PodcastChannelHorizontalAdapter.java @@ -0,0 +1,97 @@ +package com.cappielloantonio.play.ui.adapter; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.cappielloantonio.play.databinding.ItemHorizontalPodcastChannelBinding; +import com.cappielloantonio.play.glide.CustomGlideRequest; +import com.cappielloantonio.play.interfaces.ClickCallback; +import com.cappielloantonio.play.subsonic.models.PodcastChannel; +import com.cappielloantonio.play.util.Constants; +import com.cappielloantonio.play.util.MusicUtil; + +import java.util.Collections; +import java.util.List; + +public class PodcastChannelHorizontalAdapter extends RecyclerView.Adapter { + private final ClickCallback click; + + private List podcastChannels; + + public PodcastChannelHorizontalAdapter(ClickCallback click) { + this.click = click; + this.podcastChannels = Collections.emptyList(); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + ItemHorizontalPodcastChannelBinding view = ItemHorizontalPodcastChannelBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(ViewHolder holder, int position) { + PodcastChannel podcastChannel = podcastChannels.get(position); + + holder.item.podcastChannelTitleTextView.setText(MusicUtil.getReadableString(podcastChannel.getTitle())); + holder.item.podcastChannelDescriptionTextView.setText(MusicUtil.getReadableString(podcastChannel.getDescription())); + + CustomGlideRequest.Builder + .from(holder.itemView.getContext(), podcastChannel.getOriginalImageUrl()) + .build() + .into(holder.item.podcastChannelCoverImageView); + } + + @Override + public int getItemCount() { + return podcastChannels.size(); + } + + public void setItems(List podcastChannels) { + this.podcastChannels = podcastChannels; + notifyDataSetChanged(); + } + + public PodcastChannel getItem(int id) { + return podcastChannels.get(id); + } + + public class ViewHolder extends RecyclerView.ViewHolder { + ItemHorizontalPodcastChannelBinding item; + + ViewHolder(ItemHorizontalPodcastChannelBinding item) { + super(item.getRoot()); + + this.item = item; + + item.podcastChannelTitleTextView.setSelected(true); + item.podcastChannelDescriptionTextView.setSelected(true); + + itemView.setOnClickListener(v -> onClick()); + itemView.setOnLongClickListener(v -> onLongClick()); + + item.podcastChannelMoreButton.setOnClickListener(v -> onLongClick()); + } + + private void onClick() { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.PODCAST_CHANNEL_OBJECT, podcastChannels.get(getBindingAdapterPosition())); + + click.onPodcastChannelClick(bundle); + } + + private boolean onLongClick() { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.PODCAST_CHANNEL_OBJECT, podcastChannels.get(getBindingAdapterPosition())); + + click.onPodcastChannelLongClick(bundle); + + return true; + } + } +} diff --git a/app/src/main/java/com/cappielloantonio/play/ui/adapter/PodcastEpisodeAdapter.java b/app/src/main/java/com/cappielloantonio/play/ui/adapter/PodcastEpisodeAdapter.java index e6be3f9c..a166c251 100644 --- a/app/src/main/java/com/cappielloantonio/play/ui/adapter/PodcastEpisodeAdapter.java +++ b/app/src/main/java/com/cappielloantonio/play/ui/adapter/PodcastEpisodeAdapter.java @@ -7,9 +7,6 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import com.bumptech.glide.load.resource.bitmap.CenterCrop; -import com.bumptech.glide.load.resource.bitmap.RoundedCorners; -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; import com.cappielloantonio.play.R; import com.cappielloantonio.play.databinding.ItemHomePodcastEpisodeBinding; import com.cappielloantonio.play.glide.CustomGlideRequest; @@ -47,7 +44,7 @@ public class PodcastEpisodeAdapter extends RecyclerView.Adapter onClick()); + itemView.setOnLongClickListener(v -> openMore()); - item.podcastMoreButton.setOnLongClickListener(v -> openMore()); + item.podcastPlayButton.setOnClickListener(v -> onClick()); + item.podcastMoreButton.setOnClickListener(v -> openMore()); } public void onClick() { Bundle bundle = new Bundle(); bundle.putParcelable(Constants.PODCAST_OBJECT, podcastEpisodes.get(getBindingAdapterPosition())); - click.onPodcastClick(bundle); + click.onPodcastEpisodeClick(bundle); } private boolean openMore() { Bundle bundle = new Bundle(); bundle.putParcelable(Constants.PODCAST_OBJECT, podcastEpisodes.get(getBindingAdapterPosition())); - click.onPodcastLongClick(bundle); + click.onPodcastEpisodeLongClick(bundle); return true; } diff --git a/app/src/main/java/com/cappielloantonio/play/ui/fragment/HomeTabPodcastFragment.java b/app/src/main/java/com/cappielloantonio/play/ui/fragment/HomeTabPodcastFragment.java index aebc74d7..a14dddc0 100644 --- a/app/src/main/java/com/cappielloantonio/play/ui/fragment/HomeTabPodcastFragment.java +++ b/app/src/main/java/com/cappielloantonio/play/ui/fragment/HomeTabPodcastFragment.java @@ -1,32 +1,52 @@ package com.cappielloantonio.play.ui.fragment; import android.content.ComponentName; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.util.UnstableApi; import androidx.media3.session.MediaBrowser; import androidx.media3.session.SessionToken; +import androidx.navigation.Navigation; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.viewpager2.widget.ViewPager2; +import com.cappielloantonio.play.R; import com.cappielloantonio.play.databinding.FragmentHomeTabPodcastBinding; +import com.cappielloantonio.play.interfaces.ClickCallback; +import com.cappielloantonio.play.service.MediaManager; import com.cappielloantonio.play.service.MediaService; import com.cappielloantonio.play.ui.activity.MainActivity; -import com.cappielloantonio.play.viewmodel.HomeViewModel; +import com.cappielloantonio.play.ui.adapter.PodcastChannelHorizontalAdapter; +import com.cappielloantonio.play.ui.adapter.PodcastEpisodeAdapter; +import com.cappielloantonio.play.util.Constants; +import com.cappielloantonio.play.util.UIUtil; +import com.cappielloantonio.play.viewmodel.PodcastViewModel; import com.google.common.util.concurrent.ListenableFuture; @UnstableApi -public class HomeTabPodcastFragment extends Fragment { +public class HomeTabPodcastFragment extends Fragment implements ClickCallback { private static final String TAG = "HomeTabPodcastFragment"; private FragmentHomeTabPodcastBinding bind; private MainActivity activity; - private HomeViewModel homeViewModel; + private PodcastViewModel podcastViewModel; + + private PodcastEpisodeAdapter podcastEpisodeAdapter; + private PodcastChannelHorizontalAdapter podcastChannelHorizontalAdapter; private ListenableFuture mediaBrowserListenableFuture; @@ -37,11 +57,21 @@ public class HomeTabPodcastFragment extends Fragment { bind = FragmentHomeTabPodcastBinding.inflate(inflater, container, false); View view = bind.getRoot(); - homeViewModel = new ViewModelProvider(requireActivity()).get(HomeViewModel.class); + podcastViewModel = new ViewModelProvider(requireActivity()).get(PodcastViewModel.class); + + init(); return view; } + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + initNewestPodcastsView(); + initPodcastChannelsView(); + } + @Override public void onStart() { super.onStart(); @@ -61,6 +91,47 @@ public class HomeTabPodcastFragment extends Fragment { bind = null; } + private void init() { + bind.podcastChannelsTextViewClickable.setOnClickListener(v -> activity.navController.navigate(R.id.action_homeFragment_to_podcastChannelCatalogueFragment)); + } + + private void initPodcastChannelsView() { + bind.podcastChannelsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); + + podcastChannelHorizontalAdapter = new PodcastChannelHorizontalAdapter(this); + bind.podcastChannelsRecyclerView.setAdapter(podcastChannelHorizontalAdapter); + podcastViewModel.getPodcastChannels(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), podcastChannels -> { + if (podcastChannels == null) { + if (bind != null) bind.homePodcastChannelsSector.setVisibility(View.GONE); + } else { + if (bind != null) + bind.homePodcastChannelsSector.setVisibility(!podcastChannels.isEmpty() ? View.VISIBLE : View.GONE); + + podcastChannelHorizontalAdapter.setItems(podcastChannels); + } + }); + } + + private void initNewestPodcastsView() { + bind.newestPodcastsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); + bind.newestPodcastsRecyclerView.addItemDecoration(UIUtil.getDividerItemDecoration(requireContext())); + + podcastEpisodeAdapter = new PodcastEpisodeAdapter(this); + bind.newestPodcastsRecyclerView.setAdapter(podcastEpisodeAdapter); + podcastViewModel.getNewestPodcastEpisodes(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), podcastEpisodes -> { + if (podcastEpisodes == null) { + if (bind != null) bind.homeNewestPodcastsSector.setVisibility(View.GONE); + } else { + if (bind != null) + bind.homeNewestPodcastsSector.setVisibility(!podcastEpisodes.isEmpty() ? View.VISIBLE : View.GONE); + + podcastEpisodeAdapter.setItems(podcastEpisodes); + } + }); + } + + + private void initializeMediaBrowser() { mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync(); } @@ -68,4 +139,25 @@ public class HomeTabPodcastFragment extends Fragment { private void releaseMediaBrowser() { MediaBrowser.releaseFuture(mediaBrowserListenableFuture); } + + @Override + public void onPodcastEpisodeClick(Bundle bundle) { + MediaManager.startPodcast(mediaBrowserListenableFuture, bundle.getParcelable(Constants.PODCAST_OBJECT)); + activity.setBottomSheetInPeek(true); + } + + @Override + public void onPodcastEpisodeLongClick(Bundle bundle) { + Navigation.findNavController(requireView()).navigate(R.id.podcastBottomSheetDialog, bundle); + } + + @Override + public void onPodcastChannelClick(Bundle bundle) { + Navigation.findNavController(requireView()).navigate(R.id.podcastChannelPageFragment, bundle); + } + + @Override + public void onPodcastChannelLongClick(Bundle bundle) { + Toast.makeText(requireContext(), "Long click!", Toast.LENGTH_SHORT).show(); + } } diff --git a/app/src/main/java/com/cappielloantonio/play/ui/fragment/PodcastChannelCatalogueFragment.java b/app/src/main/java/com/cappielloantonio/play/ui/fragment/PodcastChannelCatalogueFragment.java new file mode 100644 index 00000000..518c7f51 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/play/ui/fragment/PodcastChannelCatalogueFragment.java @@ -0,0 +1,155 @@ +package com.cappielloantonio.play.ui.fragment; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.SearchView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.core.view.ViewCompat; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.media3.common.util.UnstableApi; +import androidx.navigation.Navigation; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.cappielloantonio.play.R; +import com.cappielloantonio.play.databinding.FragmentPodcastChannelCatalogueBinding; +import com.cappielloantonio.play.helper.recyclerview.GridItemDecoration; +import com.cappielloantonio.play.interfaces.ClickCallback; +import com.cappielloantonio.play.ui.activity.MainActivity; +import com.cappielloantonio.play.ui.adapter.PodcastChannelCatalogueAdapter; +import com.cappielloantonio.play.viewmodel.PodcastChannelCatalogueViewModel; + +@OptIn(markerClass = UnstableApi.class) +public class PodcastChannelCatalogueFragment extends Fragment implements ClickCallback { + private static final String TAG = "PodcastChannelCatalogue"; + + private FragmentPodcastChannelCatalogueBinding bind; + private MainActivity activity; + private PodcastChannelCatalogueViewModel podcastChannelCatalogueViewModel; + + private PodcastChannelCatalogueAdapter podcastChannelCatalogueAdapter; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + activity = (MainActivity) getActivity(); + + bind = FragmentPodcastChannelCatalogueBinding.inflate(inflater, container, false); + View view = bind.getRoot(); + podcastChannelCatalogueViewModel = new ViewModelProvider(requireActivity()).get(PodcastChannelCatalogueViewModel.class); + + initAppBar(); + initPodcastChannelCatalogueView(); + + return view; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + bind = null; + } + + private void initAppBar() { + activity.setSupportActionBar(bind.toolbar); + + if (activity.getSupportActionBar() != null) { + activity.getSupportActionBar().setDisplayHomeAsUpEnabled(true); + activity.getSupportActionBar().setDisplayShowHomeEnabled(true); + } + + bind.toolbar.setNavigationOnClickListener(v -> { + hideKeyboard(v); + activity.navController.navigateUp(); + }); + + + bind.appBarLayout.addOnOffsetChangedListener((appBarLayout, verticalOffset) -> { + if ((bind.podcastChannelInfoSector.getHeight() + verticalOffset) < (2 * ViewCompat.getMinimumHeight(bind.toolbar))) { + bind.toolbar.setTitle(R.string.podcast_channel_catalogue_title); + } else { + bind.toolbar.setTitle(R.string.empty_string); + } + }); + } + + @SuppressLint("ClickableViewAccessibility") + private void initPodcastChannelCatalogueView() { + bind.podcastChannelCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2)); + bind.podcastChannelCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(2, 20, false)); + bind.podcastChannelCatalogueRecyclerView.setHasFixedSize(true); + + podcastChannelCatalogueAdapter = new PodcastChannelCatalogueAdapter(this); + podcastChannelCatalogueAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY); + bind.podcastChannelCatalogueRecyclerView.setAdapter(podcastChannelCatalogueAdapter); + podcastChannelCatalogueViewModel.getPodcastChannels(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), albums -> { + if (albums != null) { + podcastChannelCatalogueAdapter.setItems(albums); + } + }); + + bind.podcastChannelCatalogueRecyclerView.setOnTouchListener((v, event) -> { + hideKeyboard(v); + return false; + }); + } + + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { + inflater.inflate(R.menu.toolbar_menu, menu); + + MenuItem searchItem = menu.findItem(R.id.action_search); + + SearchView searchView = (SearchView) searchItem.getActionView(); + searchView.setImeOptions(EditorInfo.IME_ACTION_DONE); + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + searchView.clearFocus(); + return false; + } + + @Override + public boolean onQueryTextChange(String newText) { + podcastChannelCatalogueAdapter.getFilter().filter(newText); + return false; + } + }); + + searchView.setPadding(-32, 0, 0, 0); + } + + private void hideKeyboard(View view) { + InputMethodManager imm = (InputMethodManager) requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + + @Override + public void onPodcastChannelClick(Bundle bundle) { + Navigation.findNavController(requireView()).navigate(R.id.podcastChannelPageFragment, bundle); + hideKeyboard(requireView()); + } + + @Override + public void onPodcastChannelLongClick(Bundle bundle) { + // Navigation.findNavController(requireView()).navigate(R.id.albumBottomSheetDialog, bundle); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/play/ui/fragment/PodcastChannelPageFragment.java b/app/src/main/java/com/cappielloantonio/play/ui/fragment/PodcastChannelPageFragment.java new file mode 100644 index 00000000..177042b9 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/play/ui/fragment/PodcastChannelPageFragment.java @@ -0,0 +1,150 @@ +package com.cappielloantonio.play.ui.fragment; + +import android.content.ComponentName; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.core.view.ViewCompat; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.session.MediaBrowser; +import androidx.media3.session.SessionToken; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.viewpager2.widget.ViewPager2; + +import com.cappielloantonio.play.R; +import com.cappielloantonio.play.databinding.FragmentPodcastChannelPageBinding; +import com.cappielloantonio.play.glide.CustomGlideRequest; +import com.cappielloantonio.play.interfaces.ClickCallback; +import com.cappielloantonio.play.service.MediaManager; +import com.cappielloantonio.play.service.MediaService; +import com.cappielloantonio.play.ui.activity.MainActivity; +import com.cappielloantonio.play.ui.adapter.PodcastEpisodeAdapter; +import com.cappielloantonio.play.util.Constants; +import com.cappielloantonio.play.util.MusicUtil; +import com.cappielloantonio.play.util.UIUtil; +import com.cappielloantonio.play.viewmodel.PodcastChannelPageViewModel; +import com.google.common.util.concurrent.ListenableFuture; + +@UnstableApi +public class PodcastChannelPageFragment extends Fragment implements ClickCallback { + private FragmentPodcastChannelPageBinding bind; + private MainActivity activity; + private PodcastChannelPageViewModel podcastChannelPageViewModel; + + private PodcastEpisodeAdapter podcastEpisodeAdapter; + + private ListenableFuture mediaBrowserListenableFuture; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + activity = (MainActivity) getActivity(); + + bind = FragmentPodcastChannelPageBinding.inflate(inflater, container, false); + View view = bind.getRoot(); + podcastChannelPageViewModel = new ViewModelProvider(requireActivity()).get(PodcastChannelPageViewModel.class); + + init(); + initAppBar(); + initPodcastChannelInfo(); + initPodcastChannelEpisodesView(); + + return view; + } + + @Override + public void onStart() { + super.onStart(); + + initializeMediaBrowser(); + } + + @Override + public void onStop() { + releaseMediaBrowser(); + super.onStop(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + bind = null; + } + + private void init() { + podcastChannelPageViewModel.setPodcastChannel(requireArguments().getParcelable(Constants.PODCAST_CHANNEL_OBJECT)); + } + + private void initAppBar() { + activity.setSupportActionBar(bind.animToolbar); + if (activity.getSupportActionBar() != null) + activity.getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + bind.collapsingToolbar.setTitle(MusicUtil.getReadableString(podcastChannelPageViewModel.getPodcastChannel().getTitle())); + bind.animToolbar.setNavigationOnClickListener(v -> activity.navController.navigateUp()); + bind.collapsingToolbar.setExpandedTitleColor(getResources().getColor(R.color.white, null)); + } + + private void initPodcastChannelInfo() { + String normalizePodcastChannelDescription = MusicUtil.forceReadableString(podcastChannelPageViewModel.getPodcastChannel().getDescription()); + + if (bind != null) + bind.podcastChannelDescriptionTextView.setVisibility(!normalizePodcastChannelDescription.trim().isEmpty() ? View.VISIBLE : View.GONE); + + if (getContext() != null && bind != null) CustomGlideRequest.Builder + .from(requireContext(), podcastChannelPageViewModel.getPodcastChannel().getCoverArtId()) + .build() + .into(bind.podcastChannelBackdropImageView); + + if (bind != null) + bind.podcastChannelDescriptionTextView.setText(normalizePodcastChannelDescription); + } + + private void initPodcastChannelEpisodesView() { + bind.mostStreamedSongRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); + bind.mostStreamedSongRecyclerView.addItemDecoration(UIUtil.getDividerItemDecoration(requireContext())); + + podcastEpisodeAdapter = new PodcastEpisodeAdapter(this); + bind.mostStreamedSongRecyclerView.setAdapter(podcastEpisodeAdapter); + podcastChannelPageViewModel.getPodcastChannelEpisodes().observe(getViewLifecycleOwner(), channels -> { + if (channels == null) { + if (bind != null) + bind.podcastChannelPageEpisodesPlaceholder.placeholder.setVisibility(View.VISIBLE); + if (bind != null) bind.podcastChannelPageEpisodesSector.setVisibility(View.GONE); + } else { + if (bind != null) + bind.podcastChannelPageEpisodesPlaceholder.placeholder.setVisibility(View.GONE); + + if (channels.get(0) != null && channels.get(0).getEpisodes() != null) { + if (bind != null) + bind.podcastChannelPageEpisodesSector.setVisibility(!channels.get(0).getEpisodes().isEmpty() ? View.VISIBLE : View.GONE); + podcastEpisodeAdapter.setItems(channels.get(0).getEpisodes()); + } + } + }); + } + + private void initializeMediaBrowser() { + mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync(); + } + + private void releaseMediaBrowser() { + MediaBrowser.releaseFuture(mediaBrowserListenableFuture); + } + + @Override + public void onPodcastEpisodeClick(Bundle bundle) { + MediaManager.startPodcast(mediaBrowserListenableFuture, bundle.getParcelable(Constants.PODCAST_OBJECT)); + activity.setBottomSheetInPeek(true); + } + + @Override + public void onPodcastEpisodeLongClick(Bundle bundle) { + Toast.makeText(requireContext(), "Long click!", Toast.LENGTH_SHORT).show(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/play/util/Constants.kt b/app/src/main/java/com/cappielloantonio/play/util/Constants.kt index 59abb1d6..9e3d049f 100644 --- a/app/src/main/java/com/cappielloantonio/play/util/Constants.kt +++ b/app/src/main/java/com/cappielloantonio/play/util/Constants.kt @@ -12,6 +12,7 @@ object Constants { const val GENRE_OBJECT = "GENRE_OBJECT" const val PLAYLIST_OBJECT = "PLAYLIST_OBJECT" const val PODCAST_OBJECT = "PODCAST_OBJECT" + const val PODCAST_CHANNEL_OBJECT = "PODCAST_CHANNEL_OBJECT" const val INTERNET_RADIO_STATION_OBJECT = "INTERNET_RADIO_STATION_OBJECT" const val ALBUM_RECENTLY_PLAYED = "ALBUM_RECENTLY_PLAYED" diff --git a/app/src/main/java/com/cappielloantonio/play/util/MappingUtil.java b/app/src/main/java/com/cappielloantonio/play/util/MappingUtil.java index 966c1135..5ce7185e 100644 --- a/app/src/main/java/com/cappielloantonio/play/util/MappingUtil.java +++ b/app/src/main/java/com/cappielloantonio/play/util/MappingUtil.java @@ -12,6 +12,7 @@ import androidx.media3.common.util.UnstableApi; import com.cappielloantonio.play.App; import com.cappielloantonio.play.subsonic.models.Child; import com.cappielloantonio.play.subsonic.models.InternetRadioStation; +import com.cappielloantonio.play.subsonic.models.PodcastEpisode; import java.util.ArrayList; import java.util.List; @@ -152,9 +153,76 @@ public class MappingUtil { .build(); } + public static MediaItem mapMediaItem(PodcastEpisode podcastEpisode) { + Uri uri = getUri(podcastEpisode); + + Bundle bundle = new Bundle(); + bundle.putString("id", podcastEpisode.getId()); + bundle.putString("parentId", podcastEpisode.getParentId()); + bundle.putBoolean("isDir", podcastEpisode.isDir()); + bundle.putString("title", podcastEpisode.getTitle()); + bundle.putString("album", podcastEpisode.getAlbum()); + bundle.putString("artist", podcastEpisode.getArtist()); + bundle.putInt("track", podcastEpisode.getTrack() != null ? podcastEpisode.getTrack() : 0); + bundle.putInt("year", podcastEpisode.getYear() != null ? podcastEpisode.getYear() : 0); + bundle.putString("genre", podcastEpisode.getGenre()); + bundle.putString("coverArtId", podcastEpisode.getCoverArtId()); + bundle.putLong("size", podcastEpisode.getSize() != null ? podcastEpisode.getSize() : 0); + bundle.putString("contentType", podcastEpisode.getContentType()); + bundle.putString("suffix", podcastEpisode.getSuffix()); + bundle.putString("transcodedContentType", podcastEpisode.getTranscodedContentType()); + bundle.putString("transcodedSuffix", podcastEpisode.getTranscodedSuffix()); + bundle.putInt("duration", podcastEpisode.getDuration() != null ? podcastEpisode.getDuration() : 0); + bundle.putInt("bitrate", podcastEpisode.getBitrate() != null ? podcastEpisode.getBitrate() : 0); + bundle.putString("path", podcastEpisode.getPath()); + bundle.putBoolean("isVideo", podcastEpisode.isVideo()); + bundle.putInt("userRating", podcastEpisode.getUserRating() != null ? podcastEpisode.getUserRating() : 0); + bundle.putDouble("averageRating", podcastEpisode.getAverageRating() != null ? podcastEpisode.getAverageRating() : 0); + bundle.putLong("playCount", podcastEpisode.getPlayCount() != null ? podcastEpisode.getTrack() : 0); + bundle.putInt("discNumber", podcastEpisode.getDiscNumber() != null ? podcastEpisode.getTrack() : 0); + bundle.putLong("created", podcastEpisode.getCreated() != null ? podcastEpisode.getCreated().getTime() : 0); + bundle.putLong("starred", podcastEpisode.getStarred() != null ? podcastEpisode.getStarred().getTime() : 0); + bundle.putString("albumId", podcastEpisode.getAlbumId()); + bundle.putString("artistId", podcastEpisode.getArtistId()); + bundle.putString("type", podcastEpisode.getType()); + bundle.putLong("bookmarkPosition", podcastEpisode.getBookmarkPosition() != null ? podcastEpisode.getBookmarkPosition() : 0); + bundle.putInt("originalWidth", podcastEpisode.getOriginalWidth() != null ? podcastEpisode.getOriginalWidth() : 0); + bundle.putInt("originalHeight", podcastEpisode.getOriginalHeight() != null ? podcastEpisode.getOriginalHeight() : 0); + bundle.putString("uri", uri.toString()); + + return new MediaItem.Builder() + .setMediaId(podcastEpisode.getId()) + .setMediaMetadata( + new MediaMetadata.Builder() + .setTitle(MusicUtil.getReadableString(podcastEpisode.getTitle())) + .setTrackNumber(podcastEpisode.getTrack()) + .setDiscNumber(podcastEpisode.getDiscNumber()) + .setReleaseYear(podcastEpisode.getYear()) + .setAlbumTitle(MusicUtil.getReadableString(podcastEpisode.getAlbum())) + .setArtist(MusicUtil.getReadableString(podcastEpisode.getArtist())) + .setExtras(bundle) + .build() + ) + .setRequestMetadata( + new MediaItem.RequestMetadata.Builder() + .setMediaUri(uri) + .setExtras(bundle) + .build() + ) + .setMimeType(MimeTypes.BASE_TYPE_AUDIO) + .setUri(uri) + .build(); + } + private static Uri getUri(Child media) { return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(media.getId()) ? MusicUtil.getDownloadUri(media.getId()) : MusicUtil.getStreamUri(media.getId()); } + + private static Uri getUri(PodcastEpisode podcastEpisode) { + return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(podcastEpisode.getId()) + ? MusicUtil.getDownloadUri(podcastEpisode.getId()) + : MusicUtil.getStreamUri(podcastEpisode.getId()); + } } diff --git a/app/src/main/java/com/cappielloantonio/play/util/UIUtil.java b/app/src/main/java/com/cappielloantonio/play/util/UIUtil.java index 5aa350be..64a7581d 100644 --- a/app/src/main/java/com/cappielloantonio/play/util/UIUtil.java +++ b/app/src/main/java/com/cappielloantonio/play/util/UIUtil.java @@ -1,6 +1,11 @@ package com.cappielloantonio.play.util; import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; + +import androidx.recyclerview.widget.DividerItemDecoration; import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.GoogleApiAvailability; @@ -19,4 +24,18 @@ public class UIUtil { public static boolean isCastApiAvailable(Context context) { return GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS; } + + public static DividerItemDecoration getDividerItemDecoration(Context context) { + int[] ATTRS = new int[]{android.R.attr.listDivider}; + + TypedArray a = context.obtainStyledAttributes(ATTRS); + Drawable divider = a.getDrawable(0); + InsetDrawable insetDivider = new InsetDrawable(divider, 42, 0, 42, 42); + a.recycle(); + + DividerItemDecoration itemDecoration = new DividerItemDecoration(context, DividerItemDecoration.VERTICAL); + itemDecoration.setDrawable(insetDivider); + + return itemDecoration; + } } diff --git a/app/src/main/java/com/cappielloantonio/play/viewmodel/PodcastChannelCatalogueViewModel.java b/app/src/main/java/com/cappielloantonio/play/viewmodel/PodcastChannelCatalogueViewModel.java new file mode 100644 index 00000000..f02bc75d --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/play/viewmodel/PodcastChannelCatalogueViewModel.java @@ -0,0 +1,43 @@ +package com.cappielloantonio.play.viewmodel; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.cappielloantonio.play.App; +import com.cappielloantonio.play.interfaces.MediaCallback; +import com.cappielloantonio.play.repository.PodcastRepository; +import com.cappielloantonio.play.subsonic.base.ApiResponse; +import com.cappielloantonio.play.subsonic.models.AlbumID3; +import com.cappielloantonio.play.subsonic.models.PodcastChannel; + +import java.util.ArrayList; +import java.util.List; + +import retrofit2.Call; +import retrofit2.Callback; + +public class PodcastChannelCatalogueViewModel extends AndroidViewModel { + private final PodcastRepository podcastRepository; + + private final MutableLiveData> podcastChannels = new MutableLiveData<>(null); + + + public PodcastChannelCatalogueViewModel(@NonNull Application application) { + super(application); + + podcastRepository = new PodcastRepository(); + } + + public LiveData> getPodcastChannels(LifecycleOwner owner) { + if (podcastChannels.getValue() == null) { + podcastRepository.getPodcastChannels(false, null).observe(owner, podcastChannels::postValue); + } + + return podcastChannels; + } +} diff --git a/app/src/main/java/com/cappielloantonio/play/viewmodel/PodcastChannelPageViewModel.java b/app/src/main/java/com/cappielloantonio/play/viewmodel/PodcastChannelPageViewModel.java new file mode 100644 index 00000000..51937c1b --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/play/viewmodel/PodcastChannelPageViewModel.java @@ -0,0 +1,43 @@ +package com.cappielloantonio.play.viewmodel; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; + +import com.cappielloantonio.play.repository.AlbumRepository; +import com.cappielloantonio.play.repository.ArtistRepository; +import com.cappielloantonio.play.repository.PodcastRepository; +import com.cappielloantonio.play.subsonic.models.AlbumID3; +import com.cappielloantonio.play.subsonic.models.ArtistID3; +import com.cappielloantonio.play.subsonic.models.ArtistInfo2; +import com.cappielloantonio.play.subsonic.models.Child; +import com.cappielloantonio.play.subsonic.models.PodcastChannel; +import com.cappielloantonio.play.subsonic.models.PodcastEpisode; + +import java.util.List; + +public class PodcastChannelPageViewModel extends AndroidViewModel { + private final PodcastRepository podcastRepository; + + private PodcastChannel podcastChannel; + + public PodcastChannelPageViewModel(@NonNull Application application) { + super(application); + + podcastRepository = new PodcastRepository(); + } + + public LiveData> getPodcastChannelEpisodes() { + return podcastRepository.getPodcastChannels(true, podcastChannel.getId()); + } + + public PodcastChannel getPodcastChannel() { + return podcastChannel; + } + + public void setPodcastChannel(PodcastChannel podcastChannel) { + this.podcastChannel = podcastChannel; + } +} diff --git a/app/src/main/java/com/cappielloantonio/play/viewmodel/PodcastViewModel.java b/app/src/main/java/com/cappielloantonio/play/viewmodel/PodcastViewModel.java new file mode 100644 index 00000000..2dcfac1c --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/play/viewmodel/PodcastViewModel.java @@ -0,0 +1,44 @@ +package com.cappielloantonio.play.viewmodel; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.cappielloantonio.play.repository.PodcastRepository; +import com.cappielloantonio.play.subsonic.models.PodcastChannel; +import com.cappielloantonio.play.subsonic.models.PodcastEpisode; + +import java.util.List; + +public class PodcastViewModel extends AndroidViewModel { + private final PodcastRepository podcastRepository; + + private final MutableLiveData> newestPodcastEpisodes = new MutableLiveData<>(null); + private final MutableLiveData> podcastChannels = new MutableLiveData<>(null); + + public PodcastViewModel(@NonNull Application application) { + super(application); + + podcastRepository = new PodcastRepository(); + } + + public LiveData> getNewestPodcastEpisodes(LifecycleOwner owner) { + if (newestPodcastEpisodes.getValue() == null) { + podcastRepository.getNewestPodcastEpisodes(20).observe(owner, newestPodcastEpisodes::postValue); + } + + return newestPodcastEpisodes; + } + + public LiveData> getPodcastChannels(LifecycleOwner owner) { + if (podcastChannels.getValue() == null) { + podcastRepository.getPodcastChannels(false, null).observe(owner, podcastChannels::postValue); + } + + return podcastChannels; + } +} diff --git a/app/src/main/res/layout/fragment_home_tab_podcast.xml b/app/src/main/res/layout/fragment_home_tab_podcast.xml index 77d9ef65..c2757016 100644 --- a/app/src/main/res/layout/fragment_home_tab_podcast.xml +++ b/app/src/main/res/layout/fragment_home_tab_podcast.xml @@ -1,6 +1,94 @@ - + android:layout_height="wrap_content"> - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_podcast_channel_catalogue.xml b/app/src/main/res/layout/fragment_podcast_channel_catalogue.xml new file mode 100644 index 00000000..2c16e731 --- /dev/null +++ b/app/src/main/res/layout/fragment_podcast_channel_catalogue.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_podcast_channel_page.xml b/app/src/main/res/layout/fragment_podcast_channel_page.xml new file mode 100644 index 00000000..d46e4202 --- /dev/null +++ b/app/src/main/res/layout/fragment_podcast_channel_page.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_home_catalogue_podcast_channel.xml b/app/src/main/res/layout/item_home_catalogue_podcast_channel.xml new file mode 100644 index 00000000..3644ec11 --- /dev/null +++ b/app/src/main/res/layout/item_home_catalogue_podcast_channel.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_home_podcast_episode.xml b/app/src/main/res/layout/item_home_podcast_episode.xml index 7f3627fc..ef7b4ee7 100644 --- a/app/src/main/res/layout/item_home_podcast_episode.xml +++ b/app/src/main/res/layout/item_home_podcast_episode.xml @@ -1,121 +1,108 @@ - + android:layout_height="wrap_content" + android:clipChildren="false" + android:orientation="horizontal" + android:paddingHorizontal="16dp"> - + + + + + + + + android:layout_height="0.5dp" + android:layout_marginTop="12dp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/podcast_cover_image_view" /> - + - +