diff --git a/app/src/main/java/com/cappielloantonio/tempo/factory/CoverFlowViewModelFactory.java b/app/src/main/java/com/cappielloantonio/tempo/factory/CoverFlowViewModelFactory.java new file mode 100644 index 000000000..d0bf2a992 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/factory/CoverFlowViewModelFactory.java @@ -0,0 +1,28 @@ +package com.cappielloantonio.tempo.factory; + +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.cappielloantonio.tempo.repository.CoverRepository; +import com.cappielloantonio.tempo.viewmodel.CoverFlowViewModel; + +public class CoverFlowViewModelFactory implements ViewModelProvider.Factory { + + private final CoverRepository repository; + + public CoverFlowViewModelFactory(@NonNull CoverRepository repository) { + this.repository = repository; + } + + @NonNull + @Override + public T create(@NonNull Class modelClass) { + if (modelClass.isAssignableFrom(CoverFlowViewModel.class)) { + //noinspection unchecked + return (T) new CoverFlowViewModel(repository); + } + throw new IllegalArgumentException("Unknown ViewModel class"); + } +} + diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/Cover.java b/app/src/main/java/com/cappielloantonio/tempo/model/Cover.java new file mode 100644 index 000000000..d7a50ccef --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/model/Cover.java @@ -0,0 +1,25 @@ +package com.cappielloantonio.tempo.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class Cover { + private final String url; // image to show in the flow + private final String coverArtId; // id used by the CustomGlideRequest (may be null) + + public Cover(@NonNull String url, @Nullable String coverArtId) { + this.url = url; + this.coverArtId = coverArtId; + } + + @NonNull + public String getUrl() { + return url; + } + + /** Returns the id that the CustomGlideRequest needs – can be null. */ + @Nullable + public String getCoverArtId() { + return coverArtId; + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/CoverRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/CoverRepository.java new file mode 100644 index 000000000..7538f21b6 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/CoverRepository.java @@ -0,0 +1,20 @@ +package com.cappielloantonio.tempo.repository; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.media3.common.MediaMetadata; +import androidx.media3.session.MediaBrowser; + +import com.cappielloantonio.tempo.model.Cover; +import com.google.common.util.concurrent.ListenableFuture; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public interface CoverRepository { + /** Returns a list of covers. Call should be made off the UI thread. */ + @WorkerThread + List getCovers() throws Exception; +} + diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/MediaBrowserCoverRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/MediaBrowserCoverRepository.java new file mode 100644 index 000000000..a5f95cb77 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/MediaBrowserCoverRepository.java @@ -0,0 +1,53 @@ +package com.cappielloantonio.tempo.repository; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.media3.common.MediaMetadata; +import androidx.media3.session.MediaBrowser; + +import com.cappielloantonio.tempo.glide.CustomGlideRequest; +import com.cappielloantonio.tempo.model.Cover; +import com.google.common.util.concurrent.ListenableFuture; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; /** Example implementation that extracts data from a MediaBrowser */ +public class MediaBrowserCoverRepository implements CoverRepository { + + private final ListenableFuture mediaBrowserFuture; + + public MediaBrowserCoverRepository(@NonNull ListenableFuture mediaBrowserFuture) { + this.mediaBrowserFuture = mediaBrowserFuture; + } + + @Override + @WorkerThread + public List getCovers() throws Exception { + MediaBrowser mediaBrowser = mediaBrowserFuture.get(); // blocks only inside a background thread + MediaMetadata metadata = mediaBrowser.getMediaMetadata(); + + // Inject this here, somehow, since it grabs the covertArtId + /*` + CustomGlideRequest.Builder + .from(requireContext(), metadata.extras.getString("coverArtId"), CustomGlideRequest.ResourceType.Song) + .build() + .into(bind.playerHeaderLayout.playerHeaderMediaCoverImage); + + */ + // ----------------------------------------------------------------- + // Replace the below with the real extraction logic from metadata. + // For demonstration we just return the three dog‑image URLs. + // ----------------------------------------------------------------- + List urls = Arrays.asList( + "https://images.dog.ceo/breeds/affenpinscher/n02110627_11858.jpg", + "https://images.dog.ceo/breeds/hound-english/n02089973_811.jpg", + "https://images.dog.ceo/breeds/shiba/shiba-14.jpg" + ); + + List covers = new ArrayList<>(); + for (String url : urls) { + covers.add(new Cover(url, null)); // coverArtId can be filled later if needed + } + return covers; + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/CoverFlowAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/CoverFlowAdapter.java new file mode 100644 index 000000000..2f42a4df2 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/CoverFlowAdapter.java @@ -0,0 +1,79 @@ +package com.cappielloantonio.tempo.ui.adapter; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; +import com.cappielloantonio.tempo.R; +import com.cappielloantonio.tempo.model.Cover; + +import org.jspecify.annotations.NonNull; + +import java.util.List; + +public class CoverFlowAdapter extends RecyclerView.Adapter { + + /** Callback that is invoked for every bound item – useful for the extra Glide request. */ + public interface OnCoverBoundListener { + void onCoverBound(@NonNull Cover cover, @NonNull ImageView coverImage); + } + + private final Context context; + private final List covers; + private final OnCoverBoundListener boundListener; + + public CoverFlowAdapter(@NonNull Context context, + @NonNull List covers, + @NonNull OnCoverBoundListener boundListener) { + this.context = context; + this.covers = covers; + this.boundListener = boundListener; + } + + @NonNull + @Override + public CoverViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + // Inflate a simple item layout that contains only an ImageView. + View view = LayoutInflater.from(context).inflate(R.layout.item_cover_flow, parent, false); + return new CoverViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull CoverViewHolder holder, int position) { + Cover cover = covers.get(position); + holder.bind(cover); + // Let the fragment (or caller) run the extra CustomGlideRequest if it needs the id. + boundListener.onCoverBound(cover, holder.coverImage); + } + + @Override + public int getItemCount() { + return covers.size(); + } + + /** ----------------------------------------------------------------- + * ViewHolder – each item holds a single ImageView. + * ----------------------------------------------------------------- */ + class CoverViewHolder extends RecyclerView.ViewHolder { + final ImageView coverImage; + + CoverViewHolder(@NonNull View itemView) { + super(itemView); + coverImage = itemView.findViewById(R.id.item_cover_image); + } + + void bind(@NonNull Cover cover) { + // Normal image loading (the flow thumbnails) + Glide.with(context) + .load(cover.getUrl()) + .centerCrop() + .into(coverImage); + } + } +} + diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java index 5f2cee1c0..5819ead7f 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java @@ -24,6 +24,7 @@ import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.widget.PopupMenu; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.fragment.app.Fragment; @@ -38,6 +39,8 @@ import androidx.navigation.NavController; import androidx.navigation.NavOptions; import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; import androidx.transition.ChangeBounds; import androidx.transition.Slide; import androidx.transition.TransitionManager; @@ -46,9 +49,15 @@ import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerControllerBinding; +import com.cappielloantonio.tempo.factory.CoverFlowViewModelFactory; +import com.cappielloantonio.tempo.glide.CustomGlideRequest; +import com.cappielloantonio.tempo.model.Cover; +import com.cappielloantonio.tempo.repository.CoverRepository; +import com.cappielloantonio.tempo.repository.MediaBrowserCoverRepository; import com.cappielloantonio.tempo.equalizer.EqualizerManager; import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.ui.activity.MainActivity; +import com.cappielloantonio.tempo.ui.adapter.CoverFlowAdapter; import com.cappielloantonio.tempo.ui.dialog.PlaybackSpeedDialog; import com.cappielloantonio.tempo.ui.dialog.SleepTimerDialog; import com.cappielloantonio.tempo.util.SleepTimerManager; @@ -62,6 +71,8 @@ import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; +import com.cappielloantonio.tempo.util.UIUtil; +import com.cappielloantonio.tempo.viewmodel.CoverFlowViewModel; import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel; import com.cappielloantonio.tempo.viewmodel.RatingViewModel; import com.google.android.material.chip.Chip; @@ -73,6 +84,7 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Objects; @@ -101,6 +113,8 @@ public class PlayerControllerFragment extends Fragment { private LinearLayout sleepTimerContainer; private ImageButton sleepTimerButton; private android.widget.TextView sleepTimerLabel; + + private RecyclerView playerCoverFlow; private ChipGroup assetLinkChipGroup; private Chip playerSongLinkChip; private Chip playerAlbumLinkChip; @@ -138,6 +152,78 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, return view; } + private CoverFlowViewModel viewModel; + +// public CoverFlowFragment() { +// super(R.layout.inner_fragment_player_controller_layout); // your layout that contains the RecyclerView +// } + + @Override + public void onViewCreated(@NonNull View root, @Nullable Bundle savedInstanceState) { + super.onViewCreated(root, savedInstanceState); + + // ----------------------------------------------------------------- + // 1️⃣ Find the RecyclerView (the id changed to player_cover_flow) + // ----------------------------------------------------------------- + playerCoverFlow = root.findViewById(R.id.player_cover_flow); + + // ----------------------------------------------------------------- + // 2️⃣ Set up the ViewModel (repository injection omitted for brevity) + // ----------------------------------------------------------------- +// CoverRepository repository = new MediaBrowserCoverRepository(mediaBrowserListenableFuture); +// viewModel = new ViewModelProvider(this, +// new CoverFlowViewModelFactory(repository)) +// .get(CoverFlowViewModel.class); + CoverRepository repository = new MediaBrowserCoverRepository(mediaBrowserListenableFuture); + CoverFlowViewModelFactory factory = new CoverFlowViewModelFactory(repository); + + viewModel = new ViewModelProvider(this, factory) + .get(CoverFlowViewModel.class); + + // ----------------------------------------------------------------- + // 3️⃣ Observe the LiveData and build the UI when data arrives + // ----------------------------------------------------------------- + viewModel.getCovers().observe(getViewLifecycleOwner(), this::setupCoverFlow); + } + + /** Called once the list of covers is available */ + private void setupCoverFlow(@NonNull List covers) { + if (covers.isEmpty()) return; // optionally show an empty‑state view + + // ----------------------------------------------------------------- + // 4️⃣ Create the adapter – the lambda receives the Cover object + // and the ImageView that just got bound. + // ----------------------------------------------------------------- + CoverFlowAdapter adapter = new CoverFlowAdapter( + requireContext(), + covers, + (cover, imageView) -> { + // Run the *extra* Glide request only when we have a valid id. + String coverArtId = cover.getCoverArtId(); + if (coverArtId != null) { + CustomGlideRequest.Builder + .from(requireContext(), + coverArtId, + CustomGlideRequest.ResourceType.Song) + .build() + .into(imageView); // load into the same thumbnail ImageView + } + }); + + // ----------------------------------------------------------------- + // 5️⃣ RecyclerView basics (same as your original code) + // ----------------------------------------------------------------- + playerCoverFlow.setAdapter(adapter); + playerCoverFlow.setLayoutManager( + new LinearLayoutManager(requireContext(), + LinearLayoutManager.HORIZONTAL, + false)); + + playerCoverFlow.addItemDecoration(UIUtil.horizontalSpacing(32)); + playerCoverFlow.addOnScrollListener(UIUtil.scaleOnScroll()); + UIUtil.centerAndSnapRecyclerView(playerCoverFlow); + } + @Override public void onStart() { super.onStart(); @@ -171,6 +257,7 @@ private void init() { playerOpenQueueButton = bind.getRoot().findViewById(R.id.player_open_queue_button); playerOpenLyricsButton = bind.getRoot().findViewById(R.id.player_open_lyrics_button); playerTrackInfo = bind.getRoot().findViewById(R.id.player_info_track); + playerCoverFlow = bind.getRoot().findViewById(R.id.player_cover_flow); songRatingBar = bind.getRoot().findViewById(R.id.song_rating_bar); ratingContainer = bind.getRoot().findViewById(R.id.rating_container); equalizerButton = bind.getRoot().findViewById(R.id.player_open_equalizer_button); @@ -217,14 +304,20 @@ private void initQuickActionView() { playerOpenLyricsButton.setOnClickListener(view -> { int currentItem = playerMediaCoverViewPager.getCurrentItem(); if (currentItem == 0) { - playerMediaCoverViewPager.setCurrentItem(1, true); + playerMediaCoverViewPager.setCurrentItem(1, false); + playerCoverFlow.setVisibility(View.GONE); + playerMediaCoverViewPager.setVisibility(View.VISIBLE); } else if (currentItem == 1) { - playerMediaCoverViewPager.setCurrentItem(0, true);; + playerMediaCoverViewPager.setCurrentItem(0, false); + playerCoverFlow.setVisibility(View.VISIBLE); + playerMediaCoverViewPager.setVisibility(View.GONE); } }); } + + private void initializeBrowser() { mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync(); } diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/UIUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/UIUtil.java index 2184c10a3..597faf533 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/UIUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/UIUtil.java @@ -2,15 +2,22 @@ import android.content.Context; import android.content.res.TypedArray; +import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.graphics.drawable.InsetDrawable; +import android.view.View; import androidx.core.os.LocaleListCompat; import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.LinearSnapHelper; +import androidx.recyclerview.widget.PagerSnapHelper; +import androidx.recyclerview.widget.RecyclerView; import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.R; +import org.jspecify.annotations.NonNull; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; @@ -112,4 +119,79 @@ public static String getReadableDate(Date date) { return formatter.format(date); } + public static RecyclerView.ItemDecoration horizontalSpacing(int spacingPx) { + return new HorizontalSpacingItemDecoration(spacingPx); + } + + public static RecyclerView.OnScrollListener scaleOnScroll() { + return new ScaleOnScrollListener(); + } + + private static class HorizontalSpacingItemDecoration extends RecyclerView.ItemDecoration { + private final int spacingPx; + + HorizontalSpacingItemDecoration(int spacingPx) { + this.spacingPx = spacingPx; + } + + @Override + public void getItemOffsets(@NonNull Rect outRect, + @NonNull View view, + @NonNull RecyclerView parent, + RecyclerView.@NonNull State state) { + int pos = parent.getChildAdapterPosition(view); + outRect.left = (pos == 0) ? spacingPx : 0; + outRect.right = spacingPx; + } + } + + private static class ScaleOnScrollListener extends RecyclerView.OnScrollListener { + private static final float MAX = 1.0f; + private static final float MIN = 0.70f; + private static final float ELEVATION_FACTOR = 8f; + + @Override + public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) { + final float centerX = rv.getWidth() / 2f; + + for (int i = 0; i < rv.getChildCount(); i++) { + View child = rv.getChildAt(i); + + float childCenter = (child.getLeft() + child.getRight()) / 2f; + float distance = Math.abs(centerX - childCenter); + float scale = MAX - (distance / centerX) * (MAX - MIN); + + child.setScaleX(scale); + child.setScaleY(scale); + child.setElevation(scale * ELEVATION_FACTOR); + } + } + } + + public static void centerAndSnapRecyclerView(@NonNull RecyclerView rv) { + final PagerSnapHelper snapHelper = new PagerSnapHelper(); + snapHelper.attachToRecyclerView(rv); + + rv.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(@NonNull RecyclerView recyclerView, + int newState) { + if (newState != RecyclerView.SCROLL_STATE_IDLE) return; + + RecyclerView.LayoutManager lm = recyclerView.getLayoutManager(); + if (!(lm instanceof LinearLayoutManager)) return; + + View snapView = snapHelper.findSnapView(lm); + if (snapView == null) return; + + int[] distance = snapHelper.calculateDistanceToFinalSnap(lm, snapView); + if (distance == null) return; + + if (distance[0] != 0 || distance[1] != 0) { + recyclerView.smoothScrollBy(distance[0], distance[1]); + } + } + }); + } + } diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/CoverFlowViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/CoverFlowViewModel.java new file mode 100644 index 000000000..1b8721712 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/CoverFlowViewModel.java @@ -0,0 +1,62 @@ +package com.cappielloantonio.tempo.viewmodel; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import com.cappielloantonio.tempo.model.Cover; +import com.cappielloantonio.tempo.repository.CoverRepository; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public class CoverFlowViewModel extends ViewModel { + + private final CoverRepository repository; + private final MutableLiveData> coversLiveData = new MutableLiveData<>(); + + public CoverFlowViewModel(@NonNull CoverRepository repository) { + this.repository = repository; + loadCovers(); + } + + public LiveData> getCovers() { + return coversLiveData; + } + + private final Executor ioExecutor = Executors.newSingleThreadExecutor(); + + private void loadCovers() { + ioExecutor.execute(() -> { + try { + // Disabled, correct implementation not done just yet + // List list = repository.getCovers(); // Disabled as it ain't implemented + + /* Mock of data fecthing */ + List urls = Arrays.asList( + "https://images.dog.ceo/breeds/affenpinscher/n02110627_11858.jpg", + "https://images.dog.ceo/breeds/hound-english/n02089973_811.jpg", + "https://images.dog.ceo/breeds/shiba/shiba-14.jpg" + ); + List coversList = new ArrayList<>(); + for (String url : urls) { + coversList.add(new Cover(url, null)); // coverArtId can be filled later if needed + } + + // Normal logic end + coversLiveData.postValue(coversList); + } catch (Exception e) { + coversLiveData.postValue(Collections.emptyList()); + } + + }); + } +} + diff --git a/app/src/main/res/layout/inner_fragment_player_controller_layout.xml b/app/src/main/res/layout/inner_fragment_player_controller_layout.xml index 92791a00f..b0e503aba 100644 --- a/app/src/main/res/layout/inner_fragment_player_controller_layout.xml +++ b/app/src/main/res/layout/inner_fragment_player_controller_layout.xml @@ -102,7 +102,20 @@ app:layout_constraintBottom_toTopOf="@id/guideline" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/player_asset_link_row" /> + app:layout_constraintTop_toBottomOf="@+id/player_asset_link_row" + android:visibility="gone"/> + + + + + +