diff --git a/USAGE.md b/USAGE.md
index 655f0404..009b85da 100644
--- a/USAGE.md
+++ b/USAGE.md
@@ -160,7 +160,23 @@ If your server supports it - add a internet radio station feed
## Android Auto
### Enabling on your head unit
-- You have to enable Android Auto developer options, which are different from actual Android dev options. Then you have to enable "Unknown sources" in Android Auto, otherwise the app won't appear as it isn't downloaded from Play Store. (screenshots needed)
+To allow the Tempus app on your car's head unit, "Unknown sources" needs to be enabled in the Android Auto "Developer settings". This is because Tempus isn't installed through Play Store. Note that the Android Auto developer settings are different from the global Android "Developer options".
+1. Switch to developer mode in the Android Auto settings by tapping ten times on the "Version" item at the bottom, followed by giving your permission.
+
+
+
+
+
+
+2. Go to the "Developer settings" by the menu at the top right.
+
+
+
+
+3. Scroll down to the bottom and check "Unknown sources".
+
+
+
### Server Settings
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