diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/QueueRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/QueueRepository.java index 6b3d6252..9d7af95c 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/QueueRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/QueueRepository.java @@ -1,8 +1,11 @@ package com.cappielloantonio.tempo.repository; +import android.util.Log; + import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.database.AppDatabase; @@ -52,6 +55,8 @@ public class QueueRepository { public MutableLiveData getPlayQueue() { MutableLiveData playQueue = new MutableLiveData<>(); + Log.d(TAG, "Getting play queue from server..."); + App.getSubsonicClientInstance(false) .getBookmarksClient() .getPlayQueue() @@ -59,12 +64,19 @@ public class QueueRepository { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlayQueue() != null) { - playQueue.setValue(response.body().getSubsonicResponse().getPlayQueue()); + PlayQueue serverQueue = response.body().getSubsonicResponse().getPlayQueue(); + Log.d(TAG, "Server returned play queue with " + + (serverQueue.getEntries() != null ? serverQueue.getEntries().size() : 0) + " items"); + playQueue.setValue(serverQueue); + } else { + Log.d(TAG, "Server returned no play queue"); + playQueue.setValue(null); } } @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { + Log.e(TAG, "Failed to get play queue", t); playQueue.setValue(null); } }); @@ -73,18 +85,24 @@ public class QueueRepository { } public void savePlayQueue(List ids, String current, long position) { + Log.d(TAG, "Saving play queue to server - Items: " + ids.size() + ", Current: " + current); + App.getSubsonicClientInstance(false) .getBookmarksClient() .savePlayQueue(ids, current, position) .enqueue(new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - + if (response.isSuccessful()) { + Log.d(TAG, "Play queue saved successfully"); + } else { + Log.d(TAG, "Play queue save failed with code: " + response.code()); + } } @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { - + Log.e(TAG, "Play queue save failed", t); } }); } @@ -123,10 +141,9 @@ public class QueueRepository { private boolean isMediaInQueue(List queue, Child media) { if (queue == null || media == null) return false; - - return queue.stream().anyMatch(queueItem -> - queueItem != null && media.getId() != null && - queueItem.getId().equals(media.getId()) + return queue.stream().anyMatch(queueItem -> + queueItem != null && media.getId() != null && + queueItem.getId().equals(media.getId()) ); } @@ -146,8 +163,8 @@ public class QueueRepository { List filteredToAdd = toAdd; final List finalMedia = media; filteredToAdd = toAdd.stream() - .filter(child -> !isMediaInQueue(finalMedia, child)) - .collect(Collectors.toList()); + .filter(child -> !isMediaInQueue(finalMedia, child)) + .collect(Collectors.toList()); for (int i = 0; i < filteredToAdd.size(); i++) { Queue queueItem = new Queue(filteredToAdd.get(i)); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PlayerSongQueueAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PlayerSongQueueAdapter.java index 4db3a572..14269b1c 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PlayerSongQueueAdapter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PlayerSongQueueAdapter.java @@ -18,8 +18,10 @@ import com.cappielloantonio.tempo.databinding.ItemPlayerQueueSongBinding; import com.cappielloantonio.tempo.glide.CustomGlideRequest; import com.cappielloantonio.tempo.interfaces.ClickCallback; import com.cappielloantonio.tempo.interfaces.MediaIndexCallback; +import com.cappielloantonio.tempo.service.DownloaderManager; import com.cappielloantonio.tempo.service.MediaManager; import com.cappielloantonio.tempo.subsonic.models.Child; +import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; @@ -94,6 +96,20 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter mediaBrowserListenableFuture; @@ -53,6 +76,27 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class); playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class); + fabMenuToggle = bind.fabMenuToggle; + fabClearQueue = bind.fabClearQueue; + fabShuffleQueue = bind.fabShuffleQueue; + + fabSaveToPlaylist = bind.fabSaveToPlaylist; + fabDownloadAll = bind.fabDownloadAll; + fabLoadQueue = bind.fabLoadQueue; + + fabMenuToggle.setOnClickListener(v -> toggleFabMenu()); + fabClearQueue.setOnClickListener(v -> handleClearQueueClick()); + fabShuffleQueue.setOnClickListener(v -> handleShuffleQueueClick()); + + fabSaveToPlaylist.setOnClickListener(v -> handleSaveToPlaylistClick()); + fabDownloadAll.setOnClickListener(v -> handleDownloadAllClick()); + fabLoadQueue.setOnClickListener(v -> handleLoadQueueClick()); + + // Hide Load Queue FAB if sync is disabled + if (!Preferences.isSyncronizationEnabled()) { + fabLoadQueue.setVisibility(View.GONE); + } + initQueueRecyclerView(); return view; @@ -62,8 +106,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { public void onStart() { super.onStart(); initializeBrowser(); - bindMediaController(); - MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel); observePlayback(); } @@ -105,18 +147,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { MediaBrowser.releaseFuture(mediaBrowserListenableFuture); } - private void bindMediaController() { - mediaBrowserListenableFuture.addListener(() -> { - try { - MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get(); - initShuffleButton(mediaBrowser); - initCleanButton(mediaBrowser); - } catch (Exception exception) { - exception.printStackTrace(); - } - }, MoreExecutors.directExecutor()); - } - private void setMediaBrowserListenableFuture() { playerSongQueueAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); } @@ -149,18 +179,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { fromPosition = viewHolder.getBindingAdapterPosition(); toPosition = target.getBindingAdapterPosition(); - - /* - * Per spostare un elemento nella coda devo: - * - Spostare graficamente la traccia da una posizione all'altra con Collections.swap() - * - Spostare nel db la traccia, tramite QueueRepository - * - Notificare il Service dell'avvenuto spostamento con MusicPlayerRemote.moveSong() - * - * In onMove prendo la posizione di inizio e fine, ma solo al rilascio dell'elemento procedo allo spostamento - * In questo modo evito che ad ogni cambio di posizione vada a riscrivere nel db - * Al rilascio dell'elemento chiamo il metodo clearView() - */ - Collections.swap(playerSongQueueAdapter.getItems(), fromPosition, toPosition); recyclerView.getAdapter().notifyItemMoved(fromPosition, toPosition); @@ -188,46 +206,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { }).attachToRecyclerView(bind.playerQueueRecyclerView); } - private void initShuffleButton(MediaBrowser mediaBrowser) { - bind.playerShuffleQueueFab.setOnClickListener(view -> { - int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1; - int endPosition = playerSongQueueAdapter.getItems().size() - 1; - - if (startPosition < endPosition) { - ArrayList pool = new ArrayList<>(); - - for (int i = startPosition; i <= endPosition; i++) { - pool.add(i); - } - - while (pool.size() >= 2) { - int fromPosition = (int) (Math.random() * (pool.size())); - int positionA = pool.get(fromPosition); - pool.remove(fromPosition); - - int toPosition = (int) (Math.random() * (pool.size())); - int positionB = pool.get(toPosition); - pool.remove(toPosition); - - Collections.swap(playerSongQueueAdapter.getItems(), positionA, positionB); - bind.playerQueueRecyclerView.getAdapter().notifyItemMoved(positionA, positionB); - } - - MediaManager.shuffle(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition); - } - }); - } - - private void initCleanButton(MediaBrowser mediaBrowser) { - bind.playerCleanQueueButton.setOnClickListener(view -> { - int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1; - int endPosition = playerSongQueueAdapter.getItems().size(); - - MediaManager.removeRange(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition); - bind.playerQueueRecyclerView.getAdapter().notifyItemRangeRemoved(startPosition, endPosition); - }); - } - private void updateNowPlayingItem() { playerSongQueueAdapter.notifyDataSetChanged(); } @@ -259,4 +237,216 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { playerSongQueueAdapter.setPlaybackState(id, playing != null && playing); } } + + /** + * Toggles the visibility and animates all six secondary FABs. + */ + private void toggleFabMenu() { + if (isMenuOpen) { + // CLOSE MENU (Reverse order for visual effect) + if (Preferences.isSyncronizationEnabled()) { + closeFab(fabLoadQueue, 4); + } + closeFab(fabSaveToPlaylist, 3); + closeFab(fabClearQueue, 2); + closeFab(fabDownloadAll, 1); + closeFab(fabShuffleQueue, 0); + + fabMenuToggle.animate().rotation(0f).setDuration(ANIMATION_DURATION).start(); + } else { + // OPEN MENU (lowest index at bottom) + openFab(fabShuffleQueue, 0); + openFab(fabDownloadAll, 1); + openFab(fabClearQueue, 2); + openFab(fabSaveToPlaylist, 3); + if (Preferences.isSyncronizationEnabled()) { + openFab(fabLoadQueue, 4); + } + fabMenuToggle.animate().rotation(45f).setDuration(ANIMATION_DURATION).start(); + } + isMenuOpen = !isMenuOpen; + } + + private void openFab(View fab, int index) { + final float displacement = getResources().getDisplayMetrics().density * (FAB_VERTICAL_SPACING_DP * (index + 1)); + + fab.setVisibility(View.VISIBLE); + fab.setAlpha(0f); + fab.setTranslationY(displacement); // Start at the hidden (closed) position + + fab.animate() + .translationY(0f) + .alpha(1f) + .setDuration(ANIMATION_DURATION) + .start(); + } + + private void closeFab(View fab, int index) { + final float displacement = getResources().getDisplayMetrics().density * (FAB_VERTICAL_SPACING_DP * (index + 1)); + + fab.animate() + .translationY(displacement) + .alpha(0f) + .setDuration(ANIMATION_DURATION) + .withEndAction(() -> fab.setVisibility(View.GONE)) + .start(); + } + + private void handleShuffleQueueClick() { + Log.d(TAG, "Shuffle Queue Clicked!"); + + mediaBrowserListenableFuture.addListener(() -> { + try { + MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get(); + int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1; + int endPosition = playerSongQueueAdapter.getItems().size() - 1; + + if (startPosition < endPosition) { + ArrayList pool = new ArrayList<>(); + + for (int i = startPosition; i <= endPosition; i++) { + pool.add(i); + } + + while (pool.size() >= 2) { + int fromPosition = (int) (Math.random() * (pool.size())); + int positionA = pool.get(fromPosition); + pool.remove(fromPosition); + + int toPosition = (int) (Math.random() * (pool.size())); + int positionB = pool.get(toPosition); + pool.remove(toPosition); + + Collections.swap(playerSongQueueAdapter.getItems(), positionA, positionB); + bind.playerQueueRecyclerView.getAdapter().notifyItemMoved(positionA, positionB); + } + + MediaManager.shuffle(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition); + } + + } catch (Exception e) { + Log.e(TAG, "Error shuffling queue", e); + } + + toggleFabMenu(); + }, MoreExecutors.directExecutor()); + } + + private void handleClearQueueClick() { + Log.d(TAG, "Clear Queue Clicked!"); + + mediaBrowserListenableFuture.addListener(() -> { + try { + MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get(); + int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1; + int endPosition = playerSongQueueAdapter.getItems().size(); + + MediaManager.removeRange(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition); + bind.playerQueueRecyclerView.getAdapter().notifyItemRangeRemoved(startPosition, endPosition - startPosition); + + } catch (Exception e) { + Log.e(TAG, "Error clearing queue", e); + } + + toggleFabMenu(); + }, MoreExecutors.directExecutor()); + } + + private void handleSaveToPlaylistClick() { + Log.d(TAG, "Save to Playlist Clicked!"); + + List queueSongs = playerSongQueueAdapter.getItems(); + + if (queueSongs == null || queueSongs.isEmpty()) { + Toast.makeText(requireContext(), "Queue is empty", Toast.LENGTH_SHORT).show(); + toggleFabMenu(); + return; + } + + Bundle bundle = new Bundle(); + bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(queueSongs)); + + PlaylistChooserDialog dialog = new PlaylistChooserDialog(); + dialog.setArguments(bundle); + dialog.show(requireActivity().getSupportFragmentManager(), null); + + toggleFabMenu(); + } + + private void handleDownloadAllClick() { + Log.d(TAG, "Download All Clicked!"); + + List queueSongs = playerSongQueueAdapter.getItems(); + + if (queueSongs == null || queueSongs.isEmpty()) { + Toast.makeText(requireContext(), "Queue is empty", Toast.LENGTH_SHORT).show(); + toggleFabMenu(); + return; + } + + List mediaItemsToDownload = MappingUtil.mapMediaItems(queueSongs); + + List downloadModels = new ArrayList<>(); + + for (Child child : queueSongs) { + com.cappielloantonio.tempo.model.Download downloadModel = + new com.cappielloantonio.tempo.model.Download(child); + downloadModel.setArtist(child.getArtist()); + downloadModel.setAlbum(child.getAlbum()); + downloadModel.setCoverArtId(child.getCoverArtId()); + downloadModels.add(downloadModel); + } + + DownloaderManager downloaderManager = DownloadUtil.getDownloadTracker(requireContext()); + + if (downloaderManager != null) { + downloaderManager.download(mediaItemsToDownload, downloadModels); + Toast.makeText(requireContext(), "Starting download of " + queueSongs.size() + " songs in the background.", Toast.LENGTH_SHORT).show(); + } else { + Log.e(TAG, "DownloaderManager not initialized. Check DownloadUtil."); + Toast.makeText(requireContext(), "Download service unavailable.", Toast.LENGTH_SHORT).show(); + } + toggleFabMenu(); + } + + private void handleLoadQueueClick() { + Log.d(TAG, "Load Queue Clicked!"); + if (!Preferences.isSyncronizationEnabled()) { + toggleFabMenu(); + return; + } + + PlayerBottomSheetViewModel playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class); + + playerBottomSheetViewModel.getPlayQueue().observe(getViewLifecycleOwner(), new Observer() { + @Override + public void onChanged(PlayQueue playQueue) { + playerBottomSheetViewModel.getPlayQueue().removeObserver(this); + + if (playQueue != null && playQueue.getEntries() != null && !playQueue.getEntries().isEmpty()) { + int currentIndex = 0; + for (int i = 0; i < playQueue.getEntries().size(); i++) { + if (playQueue.getEntries().get(i).getId().equals(playQueue.getCurrent())) { + currentIndex = i; + break; + } + } + + MediaManager.startQueue(mediaBrowserListenableFuture, playQueue.getEntries(), currentIndex); + + Toast.makeText(requireContext(), "Queue loaded", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(requireContext(), "No saved queue found", Toast.LENGTH_SHORT).show(); + } + + toggleFabMenu(); + } + }); + + new Handler().postDelayed(() -> { + if (isMenuOpen) { + toggleFabMenu(); + } + }, 1000); + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumListPageViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumListPageViewModel.java index 956ba6fa..8e88cec8 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumListPageViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumListPageViewModel.java @@ -9,7 +9,6 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import com.cappielloantonio.tempo.repository.AlbumRepository; -import com.cappielloantonio.tempo.repository.DownloadRepository; import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.util.Constants; @@ -21,7 +20,6 @@ import java.util.List; public class AlbumListPageViewModel extends AndroidViewModel { private final AlbumRepository albumRepository; - private final DownloadRepository downloadRepository; public String title; public ArtistID3 artist; @@ -32,9 +30,7 @@ public class AlbumListPageViewModel extends AndroidViewModel { public AlbumListPageViewModel(@NonNull Application application) { super(application); - albumRepository = new AlbumRepository(); - downloadRepository = new DownloadRepository(); } public LiveData> getAlbumList(LifecycleOwner owner) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java index 2a100fbf..df571690 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java @@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.viewmodel; import android.app.Application; import android.content.Context; import android.text.TextUtils; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.OptIn; @@ -12,6 +13,7 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; import androidx.media3.common.util.UnstableApi; +import androidx.media3.session.MediaBrowser; import com.cappielloantonio.tempo.interfaces.StarCallback; import com.cappielloantonio.tempo.model.Download; @@ -291,13 +293,13 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel { List ids = queue.stream().map(Child::getId).collect(Collectors.toList()); if (media != null) { - queueRepository.savePlayQueue(ids, media.getId(), 0); + // TODO: We need to get the actual playback position here + Log.d(TAG, "Saving play queue - Current: " + media.getId() + ", Items: " + ids.size()); + queueRepository.savePlayQueue(ids, media.getId(), 0); // Still hardcoded to 0 for now return true; } - return false; } - private void observeCachedLyrics(LifecycleOwner owner, String songId) { if (TextUtils.isEmpty(songId)) { return; diff --git a/app/src/main/res/layout/inner_fragment_player_queue.xml b/app/src/main/res/layout/inner_fragment_player_queue.xml index 72a70a22..3ddd112b 100644 --- a/app/src/main/res/layout/inner_fragment_player_queue.xml +++ b/app/src/main/res/layout/inner_fragment_player_queue.xml @@ -1,18 +1,11 @@ - - - @@ -21,20 +14,74 @@ android:id="@+id/player_queue_recycler_view" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_marginTop="40dp" android:paddingTop="8dp" app:layout_behavior="@string/appbar_scrolling_view_behavior" /> - - + app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"> + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_player_queue_song.xml b/app/src/main/res/layout/item_player_queue_song.xml index baf28acc..22e3bc60 100644 --- a/app/src/main/res/layout/item_player_queue_song.xml +++ b/app/src/main/res/layout/item_player_queue_song.xml @@ -139,6 +139,17 @@ + + ?attr/colorSurface ?attr/colorSurface none + + @style/FloatingActionButtonStyle + + + + + + + +