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>
This commit is contained in:
Tom 2026-02-25 16:37:43 -03:00 committed by GitHub
parent b403d69982
commit 4f8212d491
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 256 additions and 20 deletions

View file

@ -19,6 +19,9 @@ public interface PlaylistDao {
@Query("SELECT * FROM playlist")
LiveData<List<Playlist>> getAll();
@Query("SELECT * FROM playlist")
List<Playlist> getAllSync();
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insert(Playlist playlist);

View file

@ -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<Boolean> playlistUpdateTrigger = new MutableLiveData<>();
public LiveData<Boolean> 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<List<Playlist>> allPlaylistsLiveData = new MutableLiveData<>();
public LiveData<List<Playlist>> getAllPlaylists(LifecycleOwner owner) {
refreshAllPlaylists();
return allPlaylistsLiveData;
}
public void refreshAllPlaylists() {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.getPlaylists()
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlaylists() != null) {
List<Playlist> playlists = response.body().getSubsonicResponse().getPlaylists().getPlaylists();
allPlaylistsLiveData.postValue(playlists);
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
}
public MutableLiveData<List<Playlist>> getPlaylists(boolean random, int size) {
MutableLiveData<List<Playlist>> listLivePlaylists = new MutableLiveData<>(new ArrayList<>());
@ -104,9 +144,16 @@ public class PlaylistRepository {
return playlistLiveData;
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId, Boolean playlistVisibilityIsPublic) {
public interface AddToPlaylistCallback {
void onSuccess();
void onFailure();
void onAllSkipped();
}
public void addSongToPlaylist(String playlistId, ArrayList<String> 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<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> 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<ApiResponse> 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<Integer> indexes = new ArrayList<>();
indexes.add(index);
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.updatePlaylist(playlistId, null, true, null, indexes)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) notifyPlaylistChanged();
if (callback != null) {
if (response.isSuccessful()) callback.onSuccess();
else callback.onFailure();
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
if (callback != null) callback.onFailure();
}
});
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId, Boolean playlistVisibilityIsPublic) {
addSongToPlaylist(playlistId, songsId, playlistVisibilityIsPublic, null);
}
public void createPlaylist(String playlistId, String name, ArrayList<String> songsId) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
@ -132,7 +207,7 @@ public class PlaylistRepository {
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) notifyPlaylistChanged();
}
@Override
@ -145,20 +220,45 @@ public class PlaylistRepository {
public void updatePlaylist(String playlistId, String name, ArrayList<String> songsId) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.deletePlaylist(playlistId)
.updatePlaylist(playlistId, name, true, null, null)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> 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<ApiResponse> call, @NonNull Throwable t) {
}
});
}
@OptIn(markerClass = UnstableApi.class)
private void updateLocalPinnedPlaylistName(String id, String newName) {
new Thread(() -> {
List<Playlist> 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<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> 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<String> forceIds) {
new Thread(() -> {
List<Playlist> pinned = playlistDao.getAllSync();
if (pinned != null && !pinned.isEmpty()) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.getPlaylists()
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlaylists() != null) {
List<Playlist> 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<ApiResponse> call, @NonNull Throwable t) {
}
});
}
}).start();
}
private static class InsertThreadSafe implements Runnable {
private final PlaylistDao playlistDao;
private final Playlist playlist;

View file

@ -359,6 +359,7 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
private boolean onLongClick() {
Bundle bundle = new Bundle();
bundle.putParcelable(Constants.TRACK_OBJECT, songs.get(getBindingAdapterPosition()));
bundle.putInt(Constants.ITEM_POSITION, getBindingAdapterPosition());
click.onMediaLongClick(bundle);

View file

@ -216,8 +216,9 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
});
bind.playlistPageShuffleButton.setOnClickListener(v -> {
Collections.shuffle(songs);
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
java.util.List<com.cappielloantonio.tempo.subsonic.models.Child> 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<com.cappielloantonio.tempo.subsonic.models.Child> 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);
}

View file

@ -230,6 +230,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 -> {
Bundle bundle = new Bundle();

View file

@ -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"

View file

@ -20,14 +20,36 @@ public class PlaylistPageViewModel extends AndroidViewModel {
private Playlist playlist;
private boolean isOffline;
private final MutableLiveData<List<Child>> 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<List<Child>> getPlaylistSongLiveList() {
return playlistRepository.getPlaylistSongs(playlist.getId());
if (songLiveList.getValue() == null && playlist != null) {
refreshSongs();
}
return songLiveList;
}
private void refreshSongs() {
if (playlist == null) return;
LiveData<List<Child>> remoteData = playlistRepository.getPlaylistSongs(playlist.getId());
remoteData.observeForever(new androidx.lifecycle.Observer<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {
songLiveList.postValue(songs);
remoteData.removeObserver(this);
}
});
}
public Playlist getPlaylist() {
@ -35,7 +57,10 @@ public class PlaylistPageViewModel extends AndroidViewModel {
}
public void setPlaylist(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<Boolean> isPinned(LifecycleOwner owner) {

View file

@ -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()) {

View file

@ -164,6 +164,20 @@
android:paddingBottom="12dp"
android:text="@string/song_bottom_sheet_remove" />
<TextView
android:id="@+id/remove_from_playlist_text_view"
style="@style/LabelMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:paddingStart="20dp"
android:paddingTop="12dp"
android:paddingEnd="20dp"
android:paddingBottom="12dp"
android:text="@string/song_bottom_sheet_remove_from_playlist"
android:visibility="gone" />
<TextView
android:id="@+id/add_to_playlist_text_view"
style="@style/LabelMedium"

View file

@ -228,6 +228,8 @@
<string name="playlist_chooser_dialog_title">Aggiungi a una playlist</string>
<string name="playlist_chooser_dialog_toast_add_success">Aggiunta di un brano alla playlist</string>
<string name="playlist_chooser_dialog_toast_add_failure">Impossibile aggiungere un brano alla playlist</string>
<string name="playlist_chooser_dialog_toast_remove_success">Canzone rimossa dalla playlist</string>
<string name="playlist_chooser_dialog_toast_remove_failure">Impossibile rimuovere la canzone dalla playlist</string>
<string name="playlist_chooser_dialog_toast_all_skipped">Tutte le canzoni sono state saltate perché duplicate</string>
<string name="playlist_chooser_dialog_visibility_public">Pubblico</string>
<string name="playlist_chooser_dialog_visibility_private">Privato</string>
@ -448,7 +450,8 @@
<string name="song_bottom_sheet_instant_mix">Mix istantaneo</string>
<string name="song_bottom_sheet_play_next">Riproduci dopo</string>
<string name="song_bottom_sheet_rate">Valuta</string>
<string name="song_bottom_sheet_remove">Rimuovi</string>
<string name="song_bottom_sheet_remove">Rimuovi dal dispositivo</string>
<string name="song_bottom_sheet_remove_from_playlist">Rimuovi dalla playlist</string>
<string name="song_bottom_sheet_share">Condividi</string>
<string name="song_list_page_downloaded">Scaricato</string>
<string name="song_list_page_most_played">Tracce più riprodotte</string>

View file

@ -239,6 +239,8 @@
<string name="playlist_chooser_dialog_title">Add to a playlist</string>
<string name="playlist_chooser_dialog_toast_add_success">Added song(s) to playlist</string>
<string name="playlist_chooser_dialog_toast_add_failure">Failed to add song(s) to playlist</string>
<string name="playlist_chooser_dialog_toast_remove_success">Removed song from playlist</string>
<string name="playlist_chooser_dialog_toast_remove_failure">Failed to remove song from playlist</string>
<string name="playlist_chooser_dialog_toast_all_skipped">All songs were skipped as duplicates</string>
<string name="playlist_chooser_dialog_visibility_public">Public</string>
<string name="playlist_chooser_dialog_visibility_private">Private</string>
@ -472,7 +474,8 @@
<string name="song_bottom_sheet_instant_mix">Instant mix</string>
<string name="song_bottom_sheet_play_next">Play next</string>
<string name="song_bottom_sheet_rate">Rate</string>
<string name="song_bottom_sheet_remove">Remove</string>
<string name="song_bottom_sheet_remove">Remove from device</string>
<string name="song_bottom_sheet_remove_from_playlist">Remove from playlist</string>
<string name="song_bottom_sheet_share">Share</string>
<string name="song_list_page_downloaded">Downloaded</string>
<string name="song_list_page_most_played">Most played tracks</string>