diff --git a/app/build.gradle b/app/build.gradle index 43b139e3..036ceed6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,7 +11,7 @@ android { targetSdk 35 versionCode 11 - versionName '4.6.1' + versionName '4.6.2' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' javaCompileOptions { diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/AlbumRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/AlbumRepository.java index c3533549..dd1a6401 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/AlbumRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/AlbumRepository.java @@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.repository; import androidx.annotation.NonNull; import androidx.lifecycle.MutableLiveData; + import android.util.Log; import com.cappielloantonio.tempo.App; @@ -11,6 +12,7 @@ import com.cappielloantonio.tempo.subsonic.base.ApiResponse; import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.AlbumInfo; import com.cappielloantonio.tempo.subsonic.models.Child; +import com.cappielloantonio.tempo.util.Constants.SeedType; import java.util.ArrayList; import java.util.Calendar; @@ -204,38 +206,15 @@ public class AlbumRepository { return albumInfo; } - public void getInstantMix(AlbumID3 album, int count, MediaCallback callback) { - Log.d("AlbumRepository", "Attempting getInstantMix for AlbumID: " + album.getId()); - - App.getSubsonicClientInstance(false) - .getBrowsingClient() - .getSimilarSongs2(album.getId(), count) - .enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - List songs = new ArrayList<>(); + public void getInstantMix(AlbumID3 album, int count, final MediaCallback callback) { + Log.d("AlbumRepository", "Starting Instant Mix for album: " + album.getName()); - if (response.isSuccessful() - && response.body() != null - && response.body().getSubsonicResponse().getSimilarSongs2() != null) { - - List similarSongs = response.body().getSubsonicResponse().getSimilarSongs2().getSongs(); - - if (similarSongs == null) { - Log.w("AlbumRepository", "API successful but 'songs' list was NULL for AlbumID: " + album.getId()); - } else { - songs.addAll(similarSongs); - } - } + new SongRepository().getInstantMix(album.getId(), SeedType.ALBUM, count, songs -> { - callback.onLoadMedia(songs); - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - callback.onLoadMedia(new ArrayList<>()); - } - }); + if (songs != null && !songs.isEmpty()) { + callback.onLoadMedia(songs); + } + }); } public MutableLiveData> getDecades() { @@ -248,7 +227,7 @@ public class AlbumRepository { @Override public void onLoadYear(int last) { if (first != -1 && last != -1) { - List decadeList = new ArrayList(); + List decadeList = new ArrayList<>(); int startDecade = first - (first % 10); int lastDecade = last - (last % 10); diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java index 5bea391f..cc47e676 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java @@ -5,12 +5,14 @@ import androidx.lifecycle.MutableLiveData; import android.util.Log; import com.cappielloantonio.tempo.App; +import com.cappielloantonio.tempo.interfaces.MediaCallback; import com.cappielloantonio.tempo.subsonic.base.ApiResponse; import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.ArtistInfo2; import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.IndexID3; +import com.cappielloantonio.tempo.util.Constants.SeedType; import java.util.ArrayList; import java.util.Arrays; @@ -149,7 +151,7 @@ public class ArtistRepository { if(response.body().getSubsonicResponse().getArtists() != null && response.body().getSubsonicResponse().getArtists().getIndices() != null) { for (IndexID3 index : response.body().getSubsonicResponse().getArtists().getIndices()) { - if(index != null && index.getArtists() != null) { + if(index.getArtists() != null) { artists.addAll(index.getArtists()); } } @@ -287,28 +289,22 @@ public class ArtistRepository { } public MutableLiveData> getInstantMix(ArtistID3 artist, int count) { - MutableLiveData> instantMix = new MutableLiveData<>(); - - App.getSubsonicClientInstance(false) - .getBrowsingClient() - .getSimilarSongs2(artist.getId(), count) - .enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs2() != null) { - instantMix.setValue(response.body().getSubsonicResponse().getSimilarSongs2().getSongs()); - } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - - } - }); - - return instantMix; + // Delegate to the centralized SongRepository + return new SongRepository().getInstantMix(artist.getId(), SeedType.ARTIST, count); } + public void getInstantMix(ArtistID3 artist, int count, final MediaCallback callback) { + // Delegate to the centralized SongRepository + new SongRepository().getInstantMix(artist.getId(), SeedType.ARTIST, count, songs -> { + if (songs != null && !songs.isEmpty()) { + callback.onLoadMedia(songs); + } else { + callback.onLoadMedia(Collections.emptyList()); + } + }); + } + + public MutableLiveData> getRandomSong(ArtistID3 artist, int count) { MutableLiveData> randomSongs = new MutableLiveData<>(); diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/SongRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/SongRepository.java index a40b3c97..25990236 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/SongRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/SongRepository.java @@ -1,22 +1,33 @@ package com.cappielloantonio.tempo.repository; +import android.util.Log; + import androidx.annotation.NonNull; import androidx.lifecycle.MutableLiveData; import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.subsonic.base.ApiResponse; import com.cappielloantonio.tempo.subsonic.models.Child; +import com.cappielloantonio.tempo.subsonic.models.SubsonicResponse; +import com.cappielloantonio.tempo.util.Constants.SeedType; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Objects; +import java.util.Set; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class SongRepository { + private static final String TAG = "SongRepository"; + public interface MediaCallbackInternal { + void onSongsAvailable(List songs); + } public MutableLiveData> getStarredSongs(boolean random, int size) { MutableLiveData> starredSongs = new MutableLiveData<>(Collections.emptyList()); @@ -42,219 +53,332 @@ public class SongRepository { } @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - - } + public void onFailure(@NonNull Call call, @NonNull Throwable t) {} }); return starredSongs; } - public MutableLiveData> getInstantMix(String id, int count) { - MutableLiveData> instantMix = new MutableLiveData<>(); + /** + * Used by ViewModels. Updates the LiveData list incrementally as songs are found. + */ + public MutableLiveData> getInstantMix(String id, SeedType type, int count) { + MutableLiveData> instantMix = new MutableLiveData<>(new ArrayList<>()); - App.getSubsonicClientInstance(false) - .getBrowsingClient() - .getSimilarSongs2(id, count) - .enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs2() != null) { - instantMix.setValue(response.body().getSubsonicResponse().getSimilarSongs2().getSongs()); + performSmartMix(id, type, count, songs -> { + List current = instantMix.getValue(); + if (current != null) { + for (Child s : songs) { + if (!current.contains(s)) current.add(s); + } + + if (current.size() < count / 2) { + fillWithRandom(count - current.size(), remainder -> { + for (Child r : remainder) { + if (!current.contains(r)) current.add(r); } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - instantMix.setValue(null); - } - }); + instantMix.postValue(current); + }); + } else { + instantMix.postValue(current); + } + } + }); return instantMix; } - public MutableLiveData> getRandomSample(int number, Integer fromYear, Integer toYear) { - MutableLiveData> randomSongsSample = new MutableLiveData<>(); + /** + * Overloaded method used by other Repositories + */ + public void getInstantMix(String id, SeedType type, int count, MediaCallbackInternal callback) { + new MediaCallbackAccumulator(callback, count).start(id, type); + } + private class MediaCallbackAccumulator { + private final MediaCallbackInternal originalCallback; + private final int targetCount; + private final List accumulatedSongs = new ArrayList<>(); + private final Set trackIds = new HashSet<>(); + private boolean isComplete = false; + + MediaCallbackAccumulator(MediaCallbackInternal callback, int count) { + this.originalCallback = callback; + this.targetCount = count; + } + + void start(String id, SeedType type) { + performSmartMix(id, type, targetCount, this::onBatchReceived); + } + + private void onBatchReceived(List batch) { + if (isComplete || batch == null || batch.isEmpty()) { + return; + } + + int added = 0; + for (Child song : batch) { + if (!trackIds.contains(song.getId()) && accumulatedSongs.size() < targetCount) { + trackIds.add(song.getId()); + accumulatedSongs.add(song); + added++; + } + } + + if (accumulatedSongs.size() >= targetCount) { + originalCallback.onSongsAvailable(new ArrayList<>(accumulatedSongs)); + isComplete = true; + } + } + } + + private void performSmartMix(final String id, final SeedType type, final int count, final MediaCallbackInternal callback) { + switch (type) { + case ARTIST: + fetchSimilarByArtist(id, count, callback); + break; + case ALBUM: + fetchAlbumSongsThenSimilar(id, count, callback); + break; + case TRACK: + fetchSingleTrackThenSimilar(id, count, callback); + break; + } + } + + private void fetchAlbumSongsThenSimilar(String albumId, int count, MediaCallbackInternal callback) { + App.getSubsonicClientInstance(false).getBrowsingClient().getAlbum(albumId).enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful() && response.body() != null && + response.body().getSubsonicResponse().getAlbum() != null) { + List albumSongs = response.body().getSubsonicResponse().getAlbum().getSongs(); + if (albumSongs != null && !albumSongs.isEmpty()) { + int fromAlbum = Math.min(count, albumSongs.size()); + List limitedAlbumSongs = albumSongs.subList(0, fromAlbum); + callback.onSongsAvailable(new ArrayList<>(limitedAlbumSongs)); + + int remaining = count - fromAlbum; + if (remaining > 0 && albumSongs.get(0).getArtistId() != null) { + fetchSimilarByArtist(albumSongs.get(0).getArtistId(), remaining, callback); + } else if (remaining > 0) { + Log.d(TAG, "No artistId available, skipping similar artist fetch"); + } + return; + } + } + + Log.d(TAG, "Album fetch failed or empty, calling fillWithRandom"); + fillWithRandom(count, callback); + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + Log.d(TAG, "Album fetch failed: " + t.getMessage()); + fillWithRandom(count, callback); + } + }); + } + + private void fetchSimilarByArtist(String artistId, final int count, final MediaCallbackInternal callback) { App.getSubsonicClientInstance(false) - .getAlbumSongListClient() - .getRandomSongs(number, fromYear, toYear) + .getBrowsingClient() + .getSimilarSongs2(artistId, count) .enqueue(new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - List songs = new ArrayList<>(); - - if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null && response.body().getSubsonicResponse().getRandomSongs().getSongs() != null) { - songs.addAll(response.body().getSubsonicResponse().getRandomSongs().getSongs()); + List similar = extractSongs(response, "similarSongs2"); + if (!similar.isEmpty()) { + List limitedSimilar = similar.subList(0, Math.min(count, similar.size())); + callback.onSongsAvailable(limitedSimilar); + } else { + fillWithRandom(count, callback); } - - randomSongsSample.setValue(songs); } - - @Override + + @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { - + fillWithRandom(count, callback); } }); + } + private void fetchSingleTrackThenSimilar(String trackId, int count, MediaCallbackInternal callback) { + App.getSubsonicClientInstance(false).getBrowsingClient().getSong(trackId).enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful() && response.body() != null) { + Child song = response.body().getSubsonicResponse().getSong(); + if (song != null) { + callback.onSongsAvailable(Collections.singletonList(song)); + int remaining = count - 1; + if (remaining > 0) { + fetchSimilarOnly(trackId, remaining, callback); + } + return; + } + } + fillWithRandom(count, callback); + } + @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { + fillWithRandom(count, callback); + } + }); + } + + private void fetchSimilarOnly(String id, int count, MediaCallbackInternal callback) { + App.getSubsonicClientInstance(false).getBrowsingClient().getSimilarSongs(id, count).enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + List songs = extractSongs(response, "similarSongs"); + if (!songs.isEmpty()) { + List limitedSongs = songs.subList(0, Math.min(count, songs.size())); + callback.onSongsAvailable(limitedSongs); + } else { + fillWithRandom(count, callback); + } + } + @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { + fillWithRandom(count, callback); + } + }); + } + + + private void fillWithRandom(int target, final MediaCallbackInternal callback) { + App.getSubsonicClientInstance(false) + .getAlbumSongListClient() + .getRandomSongs(target, null, null) + .enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + List random = extractSongs(response, "randomSongs"); + if (!random.isEmpty()) { + List limitedRandom = random.subList(0, Math.min(target, random.size())); + callback.onSongsAvailable(limitedRandom); + } else { + callback.onSongsAvailable(new ArrayList<>()); + } + } + @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { + callback.onSongsAvailable(new ArrayList<>()); + } + }); + } + + private List extractSongs(Response response, String type) { + if (response.isSuccessful() && response.body() != null) { + SubsonicResponse res = response.body().getSubsonicResponse(); + List list = null; + if (type.equals("similarSongs") && res.getSimilarSongs() != null) { + list = res.getSimilarSongs().getSongs(); + } else if (type.equals("similarSongs2") && res.getSimilarSongs2() != null) { + list = res.getSimilarSongs2().getSongs(); + } else if (type.equals("randomSongs") && res.getRandomSongs() != null) { + list = res.getRandomSongs().getSongs(); + } + return (list != null) ? list : new ArrayList<>(); + } + return new ArrayList<>(); + } + + public MutableLiveData> getRandomSample(int number, Integer fromYear, Integer toYear) { + MutableLiveData> randomSongsSample = new MutableLiveData<>(); + App.getSubsonicClientInstance(false).getAlbumSongListClient().getRandomSongs(number, fromYear, toYear).enqueue(new Callback() { + @Override public void onResponse(@NonNull Call call, @NonNull Response response) { + List songs = new ArrayList<>(); + if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null) { + songs.addAll(Objects.requireNonNull(response.body().getSubsonicResponse().getRandomSongs().getSongs())); + } + randomSongsSample.setValue(songs); + } + @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) {} + }); return randomSongsSample; } public MutableLiveData> getRandomSampleWithGenre(int number, Integer fromYear, Integer toYear, String genre) { MutableLiveData> randomSongsSample = new MutableLiveData<>(); - - App.getSubsonicClientInstance(false) - .getAlbumSongListClient() - .getRandomSongs(number, fromYear, toYear, genre) - .enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - List songs = new ArrayList<>(); - - if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null && response.body().getSubsonicResponse().getRandomSongs().getSongs() != null) { - songs.addAll(response.body().getSubsonicResponse().getRandomSongs().getSongs()); - } - - randomSongsSample.setValue(songs); - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - - } - }); - + App.getSubsonicClientInstance(false).getAlbumSongListClient().getRandomSongs(number, fromYear, toYear, genre).enqueue(new Callback() { + @Override public void onResponse(@NonNull Call call, @NonNull Response response) { + List songs = new ArrayList<>(); + if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null) { + songs.addAll(Objects.requireNonNull(response.body().getSubsonicResponse().getRandomSongs().getSongs())); + } + randomSongsSample.setValue(songs); + } + @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) {} + }); return randomSongsSample; } public void scrobble(String id, boolean submission) { - App.getSubsonicClientInstance(false) - .getMediaAnnotationClient() - .scrobble(id, submission) - .enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - - } - }); + App.getSubsonicClientInstance(false).getMediaAnnotationClient().scrobble(id, submission).enqueue(new Callback() { + @Override public void onResponse(@NonNull Call call, @NonNull Response response) {} + @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) {} + }); } public void setRating(String id, int rating) { - App.getSubsonicClientInstance(false) - .getMediaAnnotationClient() - .setRating(id, rating) - .enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - - } - }); + App.getSubsonicClientInstance(false).getMediaAnnotationClient().setRating(id, rating).enqueue(new Callback() { + @Override public void onResponse(@NonNull Call call, @NonNull Response response) {} + @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) {} + }); } public MutableLiveData> getSongsByGenre(String id, int page) { MutableLiveData> songsByGenre = new MutableLiveData<>(); - - App.getSubsonicClientInstance(false) - .getAlbumSongListClient() - .getSongsByGenre(id, 100, 100 * page) - .enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) { - songsByGenre.setValue(response.body().getSubsonicResponse().getSongsByGenre().getSongs()); - } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - - } - }); - + App.getSubsonicClientInstance(false).getAlbumSongListClient().getSongsByGenre(id, 100, 100 * page).enqueue(new Callback() { + @Override public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) { + songsByGenre.setValue(response.body().getSubsonicResponse().getSongsByGenre().getSongs()); + } + } + @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) {} + }); return songsByGenre; } public MutableLiveData> getSongsByGenres(ArrayList genresId) { MutableLiveData> songsByGenre = new MutableLiveData<>(); - - for (String id : genresId) - App.getSubsonicClientInstance(false) - .getAlbumSongListClient() - .getSongsByGenre(id, 500, 0) - .enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - List songs = new ArrayList<>(); - - if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) { - songs.addAll(response.body().getSubsonicResponse().getSongsByGenre().getSongs()); - } - - songsByGenre.setValue(songs); - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - - } - }); - + for (String id : genresId) { + App.getSubsonicClientInstance(false).getAlbumSongListClient().getSongsByGenre(id, 500, 0).enqueue(new Callback() { + @Override public void onResponse(@NonNull Call call, @NonNull Response response) { + List songs = new ArrayList<>(); + if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) { + songs.addAll(Objects.requireNonNull(response.body().getSubsonicResponse().getSongsByGenre().getSongs())); + } + songsByGenre.setValue(songs); + } + @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) {} + }); + } return songsByGenre; } public MutableLiveData getSong(String id) { MutableLiveData song = new MutableLiveData<>(); - - App.getSubsonicClientInstance(false) - .getBrowsingClient() - .getSong(id) - .enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - if (response.isSuccessful() && response.body() != null) { - song.setValue(response.body().getSubsonicResponse().getSong()); - } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - - } - }); - + App.getSubsonicClientInstance(false).getBrowsingClient().getSong(id).enqueue(new Callback() { + @Override public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful() && response.body() != null) { + song.setValue(response.body().getSubsonicResponse().getSong()); + } + } + @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) {} + }); return song; } public MutableLiveData getSongLyrics(Child song) { MutableLiveData lyrics = new MutableLiveData<>(null); - - App.getSubsonicClientInstance(false) - .getMediaRetrievalClient() - .getLyrics(song.getArtist(), song.getTitle()) - .enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getLyrics() != null) { - lyrics.setValue(response.body().getSubsonicResponse().getLyrics().getValue()); - } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - - } - }); - + App.getSubsonicClientInstance(false).getMediaRetrievalClient().getLyrics(song.getArtist(), song.getTitle()).enqueue(new Callback() { + @Override public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getLyrics() != null) { + lyrics.setValue(response.body().getSubsonicResponse().getLyrics().getValue()); + } + } + @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) {} + }); return lyrics; } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java index 02cbd239..ab591104 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java +++ b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java @@ -26,6 +26,7 @@ import com.cappielloantonio.tempo.repository.SongRepository; import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation; import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode; +import com.cappielloantonio.tempo.util.Constants.SeedType; import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; @@ -183,11 +184,13 @@ public class MediaManager { @OptIn(markerClass = UnstableApi.class) public static void startQueue(ListenableFuture mediaBrowserListenableFuture, List media, int startIndex) { if (mediaBrowserListenableFuture != null) { + mediaBrowserListenableFuture.addListener(() -> { try { if (mediaBrowserListenableFuture.isDone()) { final MediaBrowser browser = mediaBrowserListenableFuture.get(); final List items = MappingUtil.mapMediaItems(media); + new Handler(Looper.getMainLooper()).post(() -> { justStarted.set(true); browser.setMediaItems(items, startIndex, 0); @@ -196,28 +199,31 @@ public class MediaManager { Player.Listener timelineListener = new Player.Listener() { @Override public void onTimelineChanged(Timeline timeline, int reason) { + int itemCount = browser.getMediaItemCount(); if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) { browser.seekTo(startIndex, 0); browser.play(); browser.removeListener(this); + } else { + Log.d(TAG, "Cannot start playback: itemCount=" + itemCount + ", startIndex=" + startIndex); } } }; + browser.addListener(timelineListener); }); backgroundExecutor.execute(() -> { + Log.d(TAG, "Background: enqueuing to database"); enqueueDatabase(media, true, 0); }); } } catch (ExecutionException | InterruptedException e) { - Log.e(TAG, "Error executing startQueue logic: " + e.getMessage(), e); + Log.e(TAG, "Error in startQueue: " + e.getMessage(), e); } }, MoreExecutors.directExecutor()); } - - } public static void startQueue(ListenableFuture mediaBrowserListenableFuture, Child media) { @@ -442,7 +448,7 @@ public class MediaManager { if (mediaItem != null && Preferences.isContinuousPlayEnabled() && Preferences.isInstantMixUsable()) { Preferences.setLastInstantMix(); - LiveData> instantMix = getSongRepository().getInstantMix(mediaItem.mediaId, 10); + LiveData> instantMix = getSongRepository().getInstantMix(mediaItem.mediaId, SeedType.TRACK, 10); instantMix.observeForever(new Observer>() { @Override public void onChanged(List media) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/SimilarSongs.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/SimilarSongs.kt index d9bb2053..23a0ffea 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/SimilarSongs.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/SimilarSongs.kt @@ -1,8 +1,10 @@ package com.cappielloantonio.tempo.subsonic.models import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName @Keep class SimilarSongs { + @SerializedName("song") var songs: List? = null } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java index 9fbce6dc..0c850038 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java @@ -9,12 +9,12 @@ import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.Toast; import android.widget.ToggleButton; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.util.UnstableApi; import androidx.media3.session.MediaBrowser; @@ -32,6 +32,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback; import com.cappielloantonio.tempo.service.MediaManager; import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.subsonic.models.ArtistID3; +import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter; import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter; @@ -203,22 +204,27 @@ public class ArtistPageFragment extends Fragment implements ClickCallback { } }); } + private void initPlayButtons() { - bind.artistPageShuffleButton.setOnClickListener(v -> artistPageViewModel.getArtistShuffleList().observe(getViewLifecycleOwner(), songs -> { - if (!songs.isEmpty()) { - MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0); - activity.setBottomSheetInPeek(true); - } else { - Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_tracks), Toast.LENGTH_SHORT).show(); + bind.artistPageShuffleButton.setOnClickListener(v -> artistPageViewModel.getArtistShuffleList().observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(List songs) { + if (songs != null && !songs.isEmpty()) { + MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0); + activity.setBottomSheetInPeek(true); + artistPageViewModel.getArtistShuffleList().removeObserver(this); + } } })); - bind.artistPageRadioButton.setOnClickListener(v -> artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), songs -> { - if (songs != null && !songs.isEmpty()) { - MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0); - activity.setBottomSheetInPeek(true); - } else { - Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_radio), Toast.LENGTH_SHORT).show(); + bind.artistPageRadioButton.setOnClickListener(v -> artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(List songs) { + if (songs != null && !songs.isEmpty()) { + MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0); + activity.setBottomSheetInPeek(true); + artistPageViewModel.getArtistInstantMix().removeObserver(this); + } } })); } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java index 191c520d..d7f739ae 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java @@ -5,6 +5,8 @@ import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.os.Handler; +import android.os.Looper; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -1253,20 +1255,25 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { MediaBrowser.releaseFuture(mediaBrowserListenableFuture); } - @Override public void onMediaClick(Bundle bundle) { if (bundle.containsKey(Constants.MEDIA_MIX)) { - MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelable(Constants.TRACK_OBJECT)); + Child track = bundle.getParcelable(Constants.TRACK_OBJECT); activity.setBottomSheetInPeek(true); if (mediaBrowserListenableFuture != null) { - homeViewModel.getMediaInstantMix(getViewLifecycleOwner(), bundle.getParcelable(Constants.TRACK_OBJECT)).observe(getViewLifecycleOwner(), songs -> { - MusicUtil.ratingFilter(songs); + final boolean[] playbackStarted = {false}; - if (songs != null && !songs.isEmpty()) { - MediaManager.enqueue(mediaBrowserListenableFuture, songs, true); - } - }); + homeViewModel.getMediaInstantMix(getViewLifecycleOwner(), track) + .observe(getViewLifecycleOwner(), songs -> { + if (playbackStarted[0] || songs == null || songs.isEmpty()) return; + + new Handler(Looper.getMainLooper()).postDelayed(() -> { + if (playbackStarted[0]) return; + + MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0); + playbackStarted[0] = true; + }, 300); + }); } } else if (bundle.containsKey(Constants.MEDIA_CHRONOLOGY)) { List media = bundle.getParcelableArrayList(Constants.TRACKS_OBJECT); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java index a6167eed..ce4a8e08 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java @@ -5,6 +5,7 @@ import android.content.ClipboardManager; import android.content.ComponentName; import android.content.Context; import android.os.Bundle; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -43,7 +44,6 @@ import com.cappielloantonio.tempo.util.ExternalAudioReader; import com.cappielloantonio.tempo.viewmodel.AlbumBottomSheetViewModel; import com.cappielloantonio.tempo.viewmodel.HomeViewModel; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; -import com.google.android.material.snackbar.Snackbar; import com.google.common.util.concurrent.ListenableFuture; import java.util.ArrayList; @@ -61,7 +61,11 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements private List currentAlbumTracks = Collections.emptyList(); private List currentAlbumMediaItems = Collections.emptyList(); + private boolean playbackStarted = false; + private boolean dismissalScheduled = false; + private ListenableFuture mediaBrowserListenableFuture; + private static final String TAG = "AlbumBottomSheetDialog"; @Nullable @Override @@ -114,33 +118,74 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite); favoriteToggle.setChecked(albumBottomSheetViewModel.getAlbum().getStarred() != null); - favoriteToggle.setOnClickListener(v -> { - albumBottomSheetViewModel.setFavorite(requireContext()); - }); + favoriteToggle.setOnClickListener(v -> albumBottomSheetViewModel.setFavorite(requireContext())); TextView playRadio = view.findViewById(R.id.play_radio_text_view); playRadio.setOnClickListener(v -> { - AlbumRepository albumRepository = new AlbumRepository(); - albumRepository.getInstantMix(album, 20, new MediaCallback() { + playbackStarted = false; + dismissalScheduled = false; + Toast.makeText(requireContext(), R.string.bottom_sheet_generating_instant_mix, Toast.LENGTH_SHORT).show(); + final Runnable failsafeTimeout = () -> { + if (!playbackStarted && !dismissalScheduled) { + Log.w(TAG, "No response received within 3 seconds"); + if (isAdded() && getActivity() != null) { + Toast.makeText(getContext(), + R.string.bottom_sheet_problem_generating_instant_mix, + Toast.LENGTH_SHORT).show(); + dismissBottomSheet(); + } + } + }; + view.postDelayed(failsafeTimeout, 3000); + + new AlbumRepository().getInstantMix(album, 20, new MediaCallback() { @Override public void onError(Exception exception) { - exception.printStackTrace(); + view.removeCallbacks(failsafeTimeout); + Log.e(TAG, "Error: " + exception.getMessage()); + if (isAdded() && getActivity() != null) { + String message = isOffline(exception) ? + "You're offline" : "Network error"; + Toast.makeText(getContext(), message, Toast.LENGTH_SHORT).show(); + } + if (!playbackStarted && !dismissalScheduled) { + scheduleDelayedDismissal(v); + } } @Override public void onLoadMedia(List media) { + view.removeCallbacks(failsafeTimeout); + if (!isAdded() || getActivity() == null) { + return; + } + MusicUtil.ratingFilter((ArrayList) media); if (!media.isEmpty()) { + boolean isFirstBatch = !playbackStarted; MediaManager.startQueue(mediaBrowserListenableFuture, (ArrayList) media, 0); - ((MainActivity) requireActivity()).setBottomSheetInPeek(true); + playbackStarted = true; + + if (getActivity() instanceof MainActivity) { + ((MainActivity) getActivity()).setBottomSheetInPeek(true); + } + if (isFirstBatch && !dismissalScheduled) { + scheduleDelayedDismissal(v); + } + } else { + Toast.makeText(getContext(), + R.string.bottom_sheet_problem_generating_instant_mix, + Toast.LENGTH_SHORT).show(); + if (!playbackStarted && !dismissalScheduled) { + scheduleDelayedDismissal(v); + } } - - dismissBottomSheet(); } }); }); + TextView playRandom = view.findViewById(R.id.play_random_text_view); playRandom.setOnClickListener(v -> { AlbumRepository albumRepository = new AlbumRepository(); @@ -186,18 +231,16 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements }); TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view); - addToPlaylist.setOnClickListener(v -> { - albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> { - Bundle bundle = new Bundle(); - bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(songs)); + addToPlaylist.setOnClickListener(v -> albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> { + Bundle bundle = new Bundle(); + bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(songs)); - PlaylistChooserDialog dialog = new PlaylistChooserDialog(); - dialog.setArguments(bundle); - dialog.show(requireActivity().getSupportFragmentManager(), null); + PlaylistChooserDialog dialog = new PlaylistChooserDialog(); + dialog.setArguments(bundle); + dialog.show(requireActivity().getSupportFragmentManager(), null); - dismissBottomSheet(); - }); - }); + dismissBottomSheet(); + })); removeAllTextView = view.findViewById(R.id.remove_all_text_view); albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> { @@ -291,4 +334,31 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements private void refreshShares() { homeViewModel.refreshShares(requireActivity()); } + + private void scheduleDelayedDismissal(View view) { + if (dismissalScheduled) return; + dismissalScheduled = true; + + view.postDelayed(() -> { + try { + if (mediaBrowserListenableFuture.isDone()) { + MediaBrowser browser = mediaBrowserListenableFuture.get(); + if (browser != null && browser.isPlaying()) { + dismissBottomSheet(); + return; + } + } + } catch (Exception e) { + Log.e(TAG, "Error checking playback: " + e.getMessage()); + } + view.postDelayed(() -> dismissBottomSheet(), 200); + }, 300); + } + + private boolean isOffline(Exception exception) { + return exception != null && exception.getMessage() != null && + (exception.getMessage().contains("Network") || + exception.getMessage().contains("timeout") || + exception.getMessage().contains("offline")); + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java index 9ec9b549..3f6f1a5e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java @@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.fragment.bottomsheetdialog; import android.content.ComponentName; import android.os.Bundle; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -18,10 +19,12 @@ import androidx.media3.session.SessionToken; import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.glide.CustomGlideRequest; +import com.cappielloantonio.tempo.interfaces.MediaCallback; import com.cappielloantonio.tempo.repository.ArtistRepository; import com.cappielloantonio.tempo.service.MediaManager; import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.subsonic.models.ArtistID3; +import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.MusicUtil; @@ -29,6 +32,9 @@ import com.cappielloantonio.tempo.viewmodel.ArtistBottomSheetViewModel; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import com.google.common.util.concurrent.ListenableFuture; +import java.util.ArrayList; +import java.util.List; + @UnstableApi public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implements View.OnClickListener { private static final String TAG = "AlbumBottomSheetDialog"; @@ -38,6 +44,9 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement private ListenableFuture mediaBrowserListenableFuture; + private boolean playbackStarted = false; + private boolean dismissalScheduled = false; + @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @@ -86,20 +95,69 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement TextView playRadio = view.findViewById(R.id.play_radio_text_view); playRadio.setOnClickListener(v -> { - ArtistRepository artistRepository = new ArtistRepository(); + Log.d(TAG, "Artist instant mix clicked"); + Toast.makeText(requireContext(), R.string.bottom_sheet_generating_instant_mix, Toast.LENGTH_SHORT).show(); + playbackStarted = false; + dismissalScheduled = false; + final Runnable failsafeTimeout = () -> { + if (!playbackStarted && !dismissalScheduled) { + Log.w(TAG, "No response received within 3 seconds"); + if (isAdded() && getActivity() != null) { + Toast.makeText(getContext(), + R.string.bottom_sheet_problem_generating_instant_mix, + Toast.LENGTH_SHORT).show(); + dismissBottomSheet(); + } + } + }; + view.postDelayed(failsafeTimeout, 3000); - artistRepository.getInstantMix(artist, 20).observe(getViewLifecycleOwner(), songs -> { - // navidrome may return null for this - if (songs == null) - return; - MusicUtil.ratingFilter(songs); - - if (!songs.isEmpty()) { - MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0); - ((MainActivity) requireActivity()).setBottomSheetInPeek(true); + new ArtistRepository().getInstantMix(artist, 20, new MediaCallback() { + @Override + public void onError(Exception exception) { + view.removeCallbacks(failsafeTimeout); + Log.e(TAG, "Error: " + exception.getMessage()); + if (isAdded() && getActivity() != null) { + String message = isOffline(exception) ? + "You're offline" : "Network error"; + Toast.makeText(getContext(), message, Toast.LENGTH_SHORT).show(); + } + if (!playbackStarted && !dismissalScheduled) { + scheduleDelayedDismissal(v); + } } - dismissBottomSheet(); + @Override + public void onLoadMedia(List media) { + view.removeCallbacks(failsafeTimeout); + if (!isAdded() || getActivity() == null) { + return; + } + + Log.d(TAG, "Received " + media.size() + " songs for artist"); + + MusicUtil.ratingFilter((ArrayList) media); + + if (!media.isEmpty()) { + boolean isFirstBatch = !playbackStarted; + MediaManager.startQueue(mediaBrowserListenableFuture, (ArrayList) media, 0); + playbackStarted = true; + + if (getActivity() instanceof MainActivity) { + ((MainActivity) getActivity()).setBottomSheetInPeek(true); + } + if (isFirstBatch && !dismissalScheduled) { + scheduleDelayedDismissal(v); + } + } else { + Toast.makeText(getContext(), + R.string.bottom_sheet_problem_generating_instant_mix, + Toast.LENGTH_SHORT).show(); + if (!playbackStarted && !dismissalScheduled) { + scheduleDelayedDismissal(v); + } + } + } }); }); @@ -108,16 +166,10 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement ArtistRepository artistRepository = new ArtistRepository(); artistRepository.getRandomSong(artist, 50).observe(getViewLifecycleOwner(), songs -> { MusicUtil.ratingFilter(songs); - if (!songs.isEmpty()) { MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0); ((MainActivity) requireActivity()).setBottomSheetInPeek(true); - - dismissBottomSheet(); - } else { - Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_tracks), Toast.LENGTH_SHORT).show(); } - dismissBottomSheet(); }); }); @@ -139,4 +191,31 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement private void releaseMediaBrowser() { MediaBrowser.releaseFuture(mediaBrowserListenableFuture); } -} \ No newline at end of file + + private void scheduleDelayedDismissal(View view) { + if (dismissalScheduled) return; + dismissalScheduled = true; + + view.postDelayed(() -> { + try { + if (mediaBrowserListenableFuture.isDone()) { + MediaBrowser browser = mediaBrowserListenableFuture.get(); + if (browser != null && browser.isPlaying()) { + dismissBottomSheet(); + return; + } + } + } catch (Exception e) { + Log.e(TAG, "Error checking playback: " + e.getMessage()); + } + view.postDelayed(() -> dismissBottomSheet(), 200); + }, 300); + } + + private boolean isOffline(Exception exception) { + return exception != null && exception.getMessage() != null && + (exception.getMessage().contains("Network") || + exception.getMessage().contains("timeout") || + exception.getMessage().contains("offline")); + } +} 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 39ba4394..27f15b3d 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 @@ -5,6 +5,9 @@ import android.content.ClipboardManager; import android.content.ComponentName; import android.content.Context; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -23,6 +26,7 @@ import androidx.navigation.fragment.NavHostFragment; import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.glide.CustomGlideRequest; +import com.cappielloantonio.tempo.interfaces.MediaCallback; import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.service.MediaManager; import com.cappielloantonio.tempo.service.MediaService; @@ -50,6 +54,7 @@ import com.cappielloantonio.tempo.util.ExternalAudioWriter; import java.util.ArrayList; import java.util.Collections; +import java.util.List; @UnstableApi public class SongBottomSheetDialog extends BottomSheetDialogFragment implements View.OnClickListener { @@ -67,7 +72,11 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements private AssetLinkUtil.AssetLink currentAlbumLink; private AssetLinkUtil.AssetLink currentArtistLink; + private boolean playbackStarted = false; + private boolean dismissalScheduled = false; + private ListenableFuture mediaBrowserListenableFuture; + private static final String TAG = "SongBottomSheetDialog"; @Nullable @Override @@ -143,22 +152,68 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements TextView playRadio = view.findViewById(R.id.play_radio_text_view); playRadio.setOnClickListener(v -> { - MediaManager.startQueue(mediaBrowserListenableFuture, song); - ((MainActivity) requireActivity()).setBottomSheetInPeek(true); + playbackStarted = false; + dismissalScheduled = false; + Toast.makeText(requireContext(), R.string.bottom_sheet_generating_instant_mix, Toast.LENGTH_SHORT).show(); - songBottomSheetViewModel.getInstantMix(getViewLifecycleOwner(), song).observe(getViewLifecycleOwner(), songs -> { - MusicUtil.ratingFilter(songs); - - if (songs == null) { - dismissBottomSheet(); - return; + final Runnable failsafeTimeout = () -> { + if (!playbackStarted && !dismissalScheduled) { + Log.w(TAG, "No response received within 3 seconds"); + if (isAdded() && getActivity() != null) { + Toast.makeText(getContext(), + R.string.bottom_sheet_problem_generating_instant_mix, + Toast.LENGTH_SHORT).show(); + dismissBottomSheet(); + } + } + }; + view.postDelayed(failsafeTimeout, 3000); + songBottomSheetViewModel.getInstantMix(song, 20, new MediaCallback() { + @Override + public void onError(Exception exception) { + view.removeCallbacks(failsafeTimeout); + Log.e(TAG, "Error: " + exception.getMessage()); + if (isAdded() && getActivity() != null) { + String message = isOffline(exception) ? + "You're offline" : "Network error"; + Toast.makeText(getContext(), message, Toast.LENGTH_SHORT).show(); + } + if (!playbackStarted && !dismissalScheduled) { + scheduleDelayedDismissal(v); + } } - if (!songs.isEmpty()) { - MediaManager.enqueue(mediaBrowserListenableFuture, songs, true); - dismissBottomSheet(); + @Override + public void onLoadMedia(List media) { + view.removeCallbacks(failsafeTimeout); + if (!isAdded() || getActivity() == null) { + return; + } + + MusicUtil.ratingFilter((ArrayList) media); + + if (!media.isEmpty()) { + boolean isFirstBatch = !playbackStarted; + MediaManager.startQueue(mediaBrowserListenableFuture, (ArrayList) media, 0); + playbackStarted = true; + + if (getActivity() instanceof MainActivity) { + ((MainActivity) getActivity()).setBottomSheetInPeek(true); + } + if (isFirstBatch && !dismissalScheduled) { + scheduleDelayedDismissal(v); + } + } else { + Toast.makeText(getContext(), + R.string.bottom_sheet_problem_generating_instant_mix, + Toast.LENGTH_SHORT).show(); + if (!playbackStarted && !dismissalScheduled) { + scheduleDelayedDismissal(v); + } + } } }); + }); TextView playNext = view.findViewById(R.id.play_next_text_view); @@ -397,4 +452,31 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements private void refreshShares() { homeViewModel.refreshShares(requireActivity()); } + + private void scheduleDelayedDismissal(View view) { + if (dismissalScheduled) return; + dismissalScheduled = true; + + view.postDelayed(() -> { + try { + if (mediaBrowserListenableFuture.isDone()) { + MediaBrowser browser = mediaBrowserListenableFuture.get(); + if (browser != null && browser.isPlaying()) { + dismissBottomSheet(); + return; + } + } + } catch (Exception e) { + Log.e(TAG, "Error checking playback: " + e.getMessage()); + } + view.postDelayed(() -> dismissBottomSheet(), 200); + }, 300); + } + + private boolean isOffline(Exception exception) { + return exception != null && exception.getMessage() != null && + (exception.getMessage().contains("Network") || + exception.getMessage().contains("timeout") || + exception.getMessage().contains("offline")); + } } 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 c6a4e3a4..2ae3dbb0 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt @@ -133,4 +133,7 @@ object Constants { const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF = "android.media3.session.demo.REPEAT_OFF" const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE = "android.media3.session.demo.REPEAT_ONE" const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL = "android.media3.session.demo.REPEAT_ALL" + enum class SeedType { + ARTIST, ALBUM, TRACK + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumBottomSheetViewModel.java index da1ec831..07b17a48 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumBottomSheetViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumBottomSheetViewModel.java @@ -4,10 +4,12 @@ import android.app.Application; import android.content.Context; import androidx.annotation.NonNull; +import androidx.annotation.OptIn; import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; +import androidx.media3.common.util.UnstableApi; import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.interfaces.StarCallback; @@ -33,7 +35,6 @@ public class AlbumBottomSheetViewModel extends AndroidViewModel { private final ArtistRepository artistRepository; private final FavoriteRepository favoriteRepository; private final SharingRepository sharingRepository; - private AlbumID3 album; public AlbumBottomSheetViewModel(@NonNull Application application) { @@ -116,6 +117,7 @@ public class AlbumBottomSheetViewModel extends AndroidViewModel { MutableLiveData> tracksLiveData = albumRepository.getAlbumTracks(album.getId()); tracksLiveData.observeForever(new Observer>() { + @OptIn(markerClass = UnstableApi.class) @Override public void onChanged(List songs) { if (songs != null && !songs.isEmpty()) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistPageViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistPageViewModel.java index 871565d0..ab6cc609 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistPageViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistPageViewModel.java @@ -128,7 +128,6 @@ public class ArtistPageViewModel extends AndroidViewModel { MappingUtil.mapDownloads(songs), songs.stream().map(Download::new).collect(Collectors.toList()) ); - } else { } } }); diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java index 0a9892fc..86d1f456 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java @@ -24,6 +24,7 @@ import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Playlist; import com.cappielloantonio.tempo.subsonic.models.Share; +import com.cappielloantonio.tempo.util.Constants.SeedType; import com.cappielloantonio.tempo.util.Preferences; import com.google.common.reflect.TypeToken; import com.google.gson.Gson; @@ -34,7 +35,6 @@ import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; -import java.util.stream.Collectors; public class HomeViewModel extends AndroidViewModel { private static final String TAG = "HomeViewModel"; @@ -223,7 +223,7 @@ public class HomeViewModel extends AndroidViewModel { public LiveData> getMediaInstantMix(LifecycleOwner owner, Child media) { mediaInstantMix.setValue(Collections.emptyList()); - songRepository.getInstantMix(media.getId(), 20).observe(owner, mediaInstantMix::postValue); + songRepository.getInstantMix(media.getId(), SeedType.TRACK, 20).observe(owner, mediaInstantMix::postValue); return mediaInstantMix; } 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 df571690..19ced999 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java @@ -13,7 +13,6 @@ 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; @@ -278,7 +277,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel { public LiveData> getMediaInstantMix(LifecycleOwner owner, Child media) { instantMix.setValue(Collections.emptyList()); - songRepository.getInstantMix(media.getId(), 20).observe(owner, instantMix::postValue); + songRepository.getInstantMix(media.getId(), Constants.SeedType.TRACK, 20).observe(owner, instantMix::postValue); return instantMix; } 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 de379dcd..665756a7 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java @@ -10,6 +10,7 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.media3.common.util.UnstableApi; +import com.cappielloantonio.tempo.interfaces.MediaCallback; import com.cappielloantonio.tempo.interfaces.StarCallback; import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.repository.AlbumRepository; @@ -21,6 +22,7 @@ import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Share; +import com.cappielloantonio.tempo.util.Constants.SeedType; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.NetworkUtil; @@ -128,11 +130,22 @@ public class SongBottomSheetViewModel extends AndroidViewModel { public LiveData> getInstantMix(LifecycleOwner owner, Child media) { instantMix.setValue(Collections.emptyList()); - songRepository.getInstantMix(media.getId(), 20).observe(owner, instantMix::postValue); + songRepository.getInstantMix(media.getId(), SeedType.TRACK, 20).observe(owner, instantMix::postValue); return instantMix; } + public void getInstantMix(Child media, int count, MediaCallback callback) { + + songRepository.getInstantMix(media.getId(), SeedType.TRACK, count, songs -> { + if (songs != null && !songs.isEmpty()) { + callback.onLoadMedia(songs); + } else { + callback.onLoadMedia(Collections.emptyList()); + } + }); + } + public MutableLiveData shareTrack() { return sharingRepository.createShare(song.getId(), song.getTitle(), null); } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee8eebe5..0ae9be02 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,6 +51,8 @@ Ignore Don\'t ask again Disable + Generating instant mix... + Could not retrieve tracks from subsonic server. Cancel Enable data saver OK