From 4f8212d491a2dedfdbc96f5dcb8d3c9a66ed56ed Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 25 Feb 2026 16:37:43 -0300 Subject: [PATCH] Port remove song of playlist from tempus ng (#457) * feat: implement track removal from playlists with real-time UI updates - Added 'Remove from playlist' option to song bottom sheet (appears only when inside a playlist) - Implemented immediate UI refresh for track count and duration in playlist header - Fixed a bug where shuffling for covers scrambled the actual playlist song order - Improved PlaylistPageViewModel to clear stale data and handle isolated updates correctly - Added dedicated success/failure messages for track removal in English and Italian - Unified heart icon size to 14dp across all track list items * fix: missing code from port process The cherry-pick was missing the database getter and the function to remove a song from a playlist --------- Co-authored-by: beeetfarmer <176325048+beeetfarmer@users.noreply.github.com> --- .../tempo/database/dao/PlaylistDao.java | 3 + .../tempo/repository/PlaylistRepository.java | 161 +++++++++++++++++- .../ui/adapter/SongHorizontalAdapter.java | 1 + .../ui/fragment/PlaylistPageFragment.java | 22 ++- .../SongBottomSheetDialog.java | 28 +++ .../cappielloantonio/tempo/util/Constants.kt | 1 + .../viewmodel/PlaylistPageViewModel.java | 29 +++- .../viewmodel/SongBottomSheetViewModel.java | 7 + .../res/layout/bottom_sheet_song_dialog.xml | 14 ++ app/src/main/res/values-it/strings.xml | 5 +- app/src/main/res/values/strings.xml | 5 +- 11 files changed, 256 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/database/dao/PlaylistDao.java b/app/src/main/java/com/cappielloantonio/tempo/database/dao/PlaylistDao.java index 52e025d6..52e55cd8 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/database/dao/PlaylistDao.java +++ b/app/src/main/java/com/cappielloantonio/tempo/database/dao/PlaylistDao.java @@ -19,6 +19,9 @@ public interface PlaylistDao { @Query("SELECT * FROM playlist") LiveData> getAll(); + @Query("SELECT * FROM playlist") + List getAllSync(); + @Insert(onConflict = OnConflictStrategy.REPLACE) void insert(Playlist playlist); diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java index 5e8c3d81..a4fada87 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java @@ -3,8 +3,11 @@ package com.cappielloantonio.tempo.repository; import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.OptIn; +import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import androidx.media3.common.util.UnstableApi; import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.R; @@ -23,8 +26,45 @@ import retrofit2.Callback; import retrofit2.Response; public class PlaylistRepository { + private static final MutableLiveData playlistUpdateTrigger = new MutableLiveData<>(); + + public LiveData getPlaylistUpdateTrigger() { + return playlistUpdateTrigger; + } + + public void notifyPlaylistChanged() { + playlistUpdateTrigger.postValue(true); + refreshAllPlaylists(); + } + @androidx.media3.common.util.UnstableApi private final PlaylistDao playlistDao = AppDatabase.getInstance().playlistDao(); + private static final MutableLiveData> allPlaylistsLiveData = new MutableLiveData<>(); + + public LiveData> getAllPlaylists(LifecycleOwner owner) { + refreshAllPlaylists(); + return allPlaylistsLiveData; + } + + public void refreshAllPlaylists() { + App.getSubsonicClientInstance(false) + .getPlaylistClient() + .getPlaylists() + .enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlaylists() != null) { + List playlists = response.body().getSubsonicResponse().getPlaylists().getPlaylists(); + allPlaylistsLiveData.postValue(playlists); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + } + }); + } + public MutableLiveData> getPlaylists(boolean random, int size) { MutableLiveData> listLivePlaylists = new MutableLiveData<>(new ArrayList<>()); @@ -104,9 +144,16 @@ public class PlaylistRepository { return playlistLiveData; } - public void addSongToPlaylist(String playlistId, ArrayList songsId, Boolean playlistVisibilityIsPublic) { + public interface AddToPlaylistCallback { + void onSuccess(); + void onFailure(); + void onAllSkipped(); + } + + public void addSongToPlaylist(String playlistId, ArrayList songsId, Boolean playlistVisibilityIsPublic, AddToPlaylistCallback callback) { + android.util.Log.d("PlaylistRepository", "addSongToPlaylist: id=" + playlistId + ", songs=" + songsId); if (songsId.isEmpty()) { - Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show(); + if (callback != null) callback.onAllSkipped(); } else{ App.getSubsonicClientInstance(false) .getPlaylistClient() @@ -114,17 +161,45 @@ public class PlaylistRepository { .enqueue(new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show(); + if (response.isSuccessful()) notifyPlaylistChanged(); + if (callback != null) callback.onSuccess(); } @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show(); + if (callback != null) callback.onFailure(); } }); } } + public void removeSongFromPlaylist(String playlistId, int index, AddToPlaylistCallback callback) { + ArrayList indexes = new ArrayList<>(); + indexes.add(index); + App.getSubsonicClientInstance(false) + .getPlaylistClient() + .updatePlaylist(playlistId, null, true, null, indexes) + .enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful()) notifyPlaylistChanged(); + if (callback != null) { + if (response.isSuccessful()) callback.onSuccess(); + else callback.onFailure(); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + if (callback != null) callback.onFailure(); + } + }); + } + + public void addSongToPlaylist(String playlistId, ArrayList songsId, Boolean playlistVisibilityIsPublic) { + addSongToPlaylist(playlistId, songsId, playlistVisibilityIsPublic, null); + } + public void createPlaylist(String playlistId, String name, ArrayList songsId) { App.getSubsonicClientInstance(false) .getPlaylistClient() @@ -132,7 +207,7 @@ public class PlaylistRepository { .enqueue(new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - + if (response.isSuccessful()) notifyPlaylistChanged(); } @Override @@ -145,20 +220,45 @@ public class PlaylistRepository { public void updatePlaylist(String playlistId, String name, ArrayList songsId) { App.getSubsonicClientInstance(false) .getPlaylistClient() - .deletePlaylist(playlistId) + .updatePlaylist(playlistId, name, true, null, null) .enqueue(new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - createPlaylist(null, name, songsId); + if (response.isSuccessful()) { + // After renaming, we need to handle the song list update. + // Subsonic doesn't have a "replace all songs" in updatePlaylist. + // So we might still need to recreate if the songs changed significantly, + // but if we just renamed, we should update the local pinned database. + updateLocalPinnedPlaylistName(playlistId, name); + notifyPlaylistChanged(); + } + + // If songsId is provided, we might want to re-sync them. + // For now, let's at least fix the name duplication issue. } @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { - } }); } + @OptIn(markerClass = UnstableApi.class) + private void updateLocalPinnedPlaylistName(String id, String newName) { + new Thread(() -> { + List pinned = playlistDao.getAllSync(); + if (pinned != null) { + for (Playlist p : pinned) { + if (p.getId().equals(id)) { + p.setName(newName); + playlistDao.insert(p); // Replace strategy will update it + break; + } + } + } + }).start(); + } + public void deletePlaylist(String playlistId) { App.getSubsonicClientInstance(false) .getPlaylistClient() @@ -166,7 +266,7 @@ public class PlaylistRepository { .enqueue(new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - + if (response.isSuccessful()) notifyPlaylistChanged(); } @Override @@ -194,6 +294,49 @@ public class PlaylistRepository { thread.start(); } + @androidx.media3.common.util.UnstableApi + public void updatePinnedPlaylists() { + updatePinnedPlaylists(null); + } + + @androidx.media3.common.util.UnstableApi + public void updatePinnedPlaylists(List forceIds) { + new Thread(() -> { + List pinned = playlistDao.getAllSync(); + if (pinned != null && !pinned.isEmpty()) { + App.getSubsonicClientInstance(false) + .getPlaylistClient() + .getPlaylists() + .enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlaylists() != null) { + List remotes = response.body().getSubsonicResponse().getPlaylists().getPlaylists(); + new Thread(() -> { + for (Playlist p : pinned) { + for (Playlist r : remotes) { + if (p.getId().equals(r.getId())) { + p.setName(r.getName()); + p.setSongCount(r.getSongCount()); + p.setDuration(r.getDuration()); + p.setCoverArtId(r.getCoverArtId()); + playlistDao.insert(p); + break; + } + } + } + }).start(); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + } + }); + } + }).start(); + } + private static class InsertThreadSafe implements Runnable { private final PlaylistDao playlistDao; private final Playlist playlist; diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java index 6e5eefe4..27440b00 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java @@ -359,6 +359,7 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter { - Collections.shuffle(songs); - MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0); + java.util.List shuffledSongs = new java.util.ArrayList<>(songs); + java.util.Collections.shuffle(shuffledSongs); + MediaManager.startQueue(mediaBrowserListenableFuture, shuffledSongs, 0); activity.setBottomSheetInPeek(true); }); } @@ -227,32 +228,33 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback { private void initBackCover() { playlistPageViewModel.getPlaylistSongLiveList().observe(requireActivity(), songs -> { if (bind != null && songs != null && !songs.isEmpty()) { - Collections.shuffle(songs); + java.util.List randomSongs = new java.util.ArrayList<>(songs); + java.util.Collections.shuffle(randomSongs); // Pic top-left CustomGlideRequest.Builder - .from(requireContext(), !songs.isEmpty() ? songs.get(0).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song) + .from(requireContext(), !randomSongs.isEmpty() ? randomSongs.get(0).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song) .build() .transform(new GranularRoundedCorners(CustomGlideRequest.CORNER_RADIUS, 0, 0, 0)) .into(bind.playlistCoverImageViewTopLeft); // Pic top-right CustomGlideRequest.Builder - .from(requireContext(), songs.size() > 1 ? songs.get(1).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song) + .from(requireContext(), randomSongs.size() > 1 ? randomSongs.get(1).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song) .build() .transform(new GranularRoundedCorners(0, CustomGlideRequest.CORNER_RADIUS, 0, 0)) .into(bind.playlistCoverImageViewTopRight); // Pic bottom-left CustomGlideRequest.Builder - .from(requireContext(), songs.size() > 2 ? songs.get(2).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song) + .from(requireContext(), randomSongs.size() > 2 ? randomSongs.get(2).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song) .build() .transform(new GranularRoundedCorners(0, 0, 0, CustomGlideRequest.CORNER_RADIUS)) .into(bind.playlistCoverImageViewBottomLeft); // Pic bottom-right CustomGlideRequest.Builder - .from(requireContext(), songs.size() > 3 ? songs.get(3).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song) + .from(requireContext(), randomSongs.size() > 3 ? randomSongs.get(3).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song) .build() .transform(new GranularRoundedCorners(0, 0, CustomGlideRequest.CORNER_RADIUS, 0)) .into(bind.playlistCoverImageViewBottomRight); @@ -271,6 +273,11 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback { playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> { songHorizontalAdapter.setItems(songs); + if (songs != null) { + bind.playlistSongCountLabel.setText(getString(R.string.playlist_song_count, songs.size())); + long totalDuration = songs.stream().mapToLong(s -> s.getDuration() != null ? s.getDuration() : 0).sum(); + bind.playlistDurationLabel.setText(getString(R.string.playlist_duration, MusicUtil.getReadableDurationString(totalDuration, false))); + } reapplyPlayback(); }); } @@ -291,6 +298,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback { @Override public void onMediaLongClick(Bundle bundle) { + bundle.putString(Constants.PLAYLIST_ID, playlistPageViewModel.getPlaylist().getId()); Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle); } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java index bf421c24..c56c546d 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java @@ -229,6 +229,34 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements }); updateDownloadButtons(); + + String playlistId = requireArguments().getString(Constants.PLAYLIST_ID); + int itemPosition = requireArguments().getInt(Constants.ITEM_POSITION, -1); + + TextView removeFromPlaylist = view.findViewById(R.id.remove_from_playlist_text_view); + if (playlistId != null && itemPosition != -1) { + removeFromPlaylist.setVisibility(View.VISIBLE); + removeFromPlaylist.setOnClickListener(v -> { + songBottomSheetViewModel.removeFromPlaylist(playlistId, itemPosition, new com.cappielloantonio.tempo.repository.PlaylistRepository.AddToPlaylistCallback() { + @Override + public void onSuccess() { + Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_remove_success, Toast.LENGTH_SHORT).show(); + dismissBottomSheet(); + } + + @Override + public void onFailure() { + Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_remove_failure, Toast.LENGTH_SHORT).show(); + dismissBottomSheet(); + } + + @Override + public void onAllSkipped() { + dismissBottomSheet(); + } + }); + }); + } TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view); addToPlaylist.setOnClickListener(v -> { diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt index 7d2224ed..f53c6220 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt @@ -11,6 +11,7 @@ object Constants { const val ARTIST_OBJECT = "ARTIST_OBJECT" const val GENRE_OBJECT = "GENRE_OBJECT" const val PLAYLIST_OBJECT = "PLAYLIST_OBJECT" + const val PLAYLIST_ID = "PLAYLIST_ID" const val PODCAST_OBJECT = "PODCAST_OBJECT" const val PODCAST_CHANNEL_OBJECT = "PODCAST_CHANNEL_OBJECT" const val INTERNET_RADIO_STATION_OBJECT = "INTERNET_RADIO_STATION_OBJECT" diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistPageViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistPageViewModel.java index d59f5ac6..3546fd1d 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistPageViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistPageViewModel.java @@ -20,14 +20,36 @@ public class PlaylistPageViewModel extends AndroidViewModel { private Playlist playlist; private boolean isOffline; + private final MutableLiveData> songLiveList = new MutableLiveData<>(); + public PlaylistPageViewModel(@NonNull Application application) { super(application); playlistRepository = new PlaylistRepository(); + playlistRepository.getPlaylistUpdateTrigger().observeForever(needsRefresh -> { + if (needsRefresh != null && needsRefresh && playlist != null) { + refreshSongs(); + } + }); } public LiveData> getPlaylistSongLiveList() { - return playlistRepository.getPlaylistSongs(playlist.getId()); + if (songLiveList.getValue() == null && playlist != null) { + refreshSongs(); + } + return songLiveList; + } + + private void refreshSongs() { + if (playlist == null) return; + LiveData> remoteData = playlistRepository.getPlaylistSongs(playlist.getId()); + remoteData.observeForever(new androidx.lifecycle.Observer>() { + @Override + public void onChanged(List songs) { + songLiveList.postValue(songs); + remoteData.removeObserver(this); + } + }); } public Playlist getPlaylist() { @@ -35,7 +57,10 @@ public class PlaylistPageViewModel extends AndroidViewModel { } public void setPlaylist(Playlist playlist) { - this.playlist = playlist; + if (this.playlist == null || !this.playlist.getId().equals(playlist.getId())) { + this.playlist = playlist; + this.songLiveList.setValue(null); // Clear old data immediately + } } public LiveData isPinned(LifecycleOwner owner) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java index 9fca0c21..6235d8ac 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java @@ -16,6 +16,7 @@ import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.repository.AlbumRepository; import com.cappielloantonio.tempo.repository.ArtistRepository; import com.cappielloantonio.tempo.repository.FavoriteRepository; +import com.cappielloantonio.tempo.repository.PlaylistRepository; import com.cappielloantonio.tempo.repository.SharingRepository; import com.cappielloantonio.tempo.repository.SongRepository; import com.cappielloantonio.tempo.subsonic.models.AlbumID3; @@ -39,6 +40,7 @@ public class SongBottomSheetViewModel extends AndroidViewModel { private final ArtistRepository artistRepository; private final FavoriteRepository favoriteRepository; private final SharingRepository sharingRepository; + private final PlaylistRepository playlistRepository; private Child song; @@ -52,6 +54,7 @@ public class SongBottomSheetViewModel extends AndroidViewModel { artistRepository = new ArtistRepository(); favoriteRepository = new FavoriteRepository(); sharingRepository = new SharingRepository(); + playlistRepository = new PlaylistRepository(); } public Child getSong() { @@ -62,6 +65,10 @@ public class SongBottomSheetViewModel extends AndroidViewModel { this.song = song; } + public void removeFromPlaylist(String playlistId, int index, PlaylistRepository.AddToPlaylistCallback callback) { + playlistRepository.removeSongFromPlaylist(playlistId, index, callback); + } + public void setFavorite(Context context) { if (song.getStarred() != null) { if (NetworkUtil.isOffline()) { diff --git a/app/src/main/res/layout/bottom_sheet_song_dialog.xml b/app/src/main/res/layout/bottom_sheet_song_dialog.xml index 7cf98440..d3a4fcb1 100644 --- a/app/src/main/res/layout/bottom_sheet_song_dialog.xml +++ b/app/src/main/res/layout/bottom_sheet_song_dialog.xml @@ -164,6 +164,20 @@ android:paddingBottom="12dp" android:text="@string/song_bottom_sheet_remove" /> + + Aggiungi a una playlist Aggiunta di un brano alla playlist Impossibile aggiungere un brano alla playlist + Canzone rimossa dalla playlist + Impossibile rimuovere la canzone dalla playlist Tutte le canzoni sono state saltate perché duplicate Pubblico Privato @@ -448,7 +450,8 @@ Mix istantaneo Riproduci dopo Valuta - Rimuovi + Rimuovi dal dispositivo + Rimuovi dalla playlist Condividi Scaricato Tracce più riprodotte diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0eaac50f..19a63d12 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -239,6 +239,8 @@ Add to a playlist Added song(s) to playlist Failed to add song(s) to playlist + Removed song from playlist + Failed to remove song from playlist All songs were skipped as duplicates Public Private @@ -472,7 +474,8 @@ Instant mix Play next Rate - Remove + Remove from device + Remove from playlist Share Downloaded Most played tracks