Merge branch 'development' into improve/update-zh

This commit is contained in:
eddyizm 2026-01-04 15:41:31 -08:00 committed by GitHub
commit 53ca88989f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 681 additions and 296 deletions

View file

@ -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.
<p align="left">
<img width="270" height="600" alt="1a" src="https://github.com/user-attachments/assets/f09f6999-9761-4b05-8ec7-bf221a15dda3" />
<img width="270" height="600" alt="1b" src="https://github.com/user-attachments/assets/0795e508-ba01-41c5-96a7-7c03b0156591" />
<img width="270" height="600" alt="1c" src="https://github.com/user-attachments/assets/51c15f67-fddb-452e-b5d3-5092edeab390" />
</p>
2. Go to the "Developer settings" by the menu at the top right.
<p align="left">
<img width="270" height="600" alt="2" src="https://github.com/user-attachments/assets/1ecd1f3e-026d-4d25-87f2-be7f12efbac6" />
</p>
3. Scroll down to the bottom and check "Unknown sources".
<p align="left">
<img width="270" height="600" alt="3" src="https://github.com/user-attachments/assets/37db88e9-1b76-417f-9c47-da9f3a750fff" />
</p>
### Server Settings

View file

@ -11,7 +11,7 @@ android {
targetSdk 35
versionCode 11
versionName '4.6.1'
versionName '4.6.2'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
javaCompileOptions {

View file

@ -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<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> 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<Child> 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<ApiResponse> call, @NonNull Throwable t) {
callback.onLoadMedia(new ArrayList<>());
}
});
if (songs != null && !songs.isEmpty()) {
callback.onLoadMedia(songs);
}
});
}
public MutableLiveData<List<Integer>> getDecades() {
@ -248,7 +227,7 @@ public class AlbumRepository {
@Override
public void onLoadYear(int last) {
if (first != -1 && last != -1) {
List<Integer> decadeList = new ArrayList();
List<Integer> decadeList = new ArrayList<>();
int startDecade = first - (first % 10);
int lastDecade = last - (last % 10);

View file

@ -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<List<Child>> getInstantMix(ArtistID3 artist, int count) {
MutableLiveData<List<Child>> instantMix = new MutableLiveData<>();
App.getSubsonicClientInstance(false)
.getBrowsingClient()
.getSimilarSongs2(artist.getId(), count)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs2() != null) {
instantMix.setValue(response.body().getSubsonicResponse().getSimilarSongs2().getSongs());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> 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<List<Child>> getRandomSong(ArtistID3 artist, int count) {
MutableLiveData<List<Child>> randomSongs = new MutableLiveData<>();

View file

@ -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<Child> songs);
}
public MutableLiveData<List<Child>> getStarredSongs(boolean random, int size) {
MutableLiveData<List<Child>> starredSongs = new MutableLiveData<>(Collections.emptyList());
@ -42,219 +53,332 @@ public class SongRepository {
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
});
return starredSongs;
}
public MutableLiveData<List<Child>> getInstantMix(String id, int count) {
MutableLiveData<List<Child>> instantMix = new MutableLiveData<>();
/**
* Used by ViewModels. Updates the LiveData list incrementally as songs are found.
*/
public MutableLiveData<List<Child>> getInstantMix(String id, SeedType type, int count) {
MutableLiveData<List<Child>> instantMix = new MutableLiveData<>(new ArrayList<>());
App.getSubsonicClientInstance(false)
.getBrowsingClient()
.getSimilarSongs2(id, count)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs2() != null) {
instantMix.setValue(response.body().getSubsonicResponse().getSimilarSongs2().getSongs());
performSmartMix(id, type, count, songs -> {
List<Child> 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<ApiResponse> call, @NonNull Throwable t) {
instantMix.setValue(null);
}
});
instantMix.postValue(current);
});
} else {
instantMix.postValue(current);
}
}
});
return instantMix;
}
public MutableLiveData<List<Child>> getRandomSample(int number, Integer fromYear, Integer toYear) {
MutableLiveData<List<Child>> 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<Child> accumulatedSongs = new ArrayList<>();
private final Set<String> 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<Child> 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<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null &&
response.body().getSubsonicResponse().getAlbum() != null) {
List<Child> albumSongs = response.body().getSubsonicResponse().getAlbum().getSongs();
if (albumSongs != null && !albumSongs.isEmpty()) {
int fromAlbum = Math.min(count, albumSongs.size());
List<Child> 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<ApiResponse> 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<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> 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<Child> similar = extractSongs(response, "similarSongs2");
if (!similar.isEmpty()) {
List<Child> 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<ApiResponse> 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<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> 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<ApiResponse> 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<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> songs = extractSongs(response, "similarSongs");
if (!songs.isEmpty()) {
List<Child> limitedSongs = songs.subList(0, Math.min(count, songs.size()));
callback.onSongsAvailable(limitedSongs);
} else {
fillWithRandom(count, callback);
}
}
@Override public void onFailure(@NonNull Call<ApiResponse> 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<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> random = extractSongs(response, "randomSongs");
if (!random.isEmpty()) {
List<Child> limitedRandom = random.subList(0, Math.min(target, random.size()));
callback.onSongsAvailable(limitedRandom);
} else {
callback.onSongsAvailable(new ArrayList<>());
}
}
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
callback.onSongsAvailable(new ArrayList<>());
}
});
}
private List<Child> extractSongs(Response<ApiResponse> response, String type) {
if (response.isSuccessful() && response.body() != null) {
SubsonicResponse res = response.body().getSubsonicResponse();
List<Child> 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<List<Child>> getRandomSample(int number, Integer fromYear, Integer toYear) {
MutableLiveData<List<Child>> randomSongsSample = new MutableLiveData<>();
App.getSubsonicClientInstance(false).getAlbumSongListClient().getRandomSongs(number, fromYear, toYear).enqueue(new Callback<ApiResponse>() {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> 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<ApiResponse> call, @NonNull Throwable t) {}
});
return randomSongsSample;
}
public MutableLiveData<List<Child>> getRandomSampleWithGenre(int number, Integer fromYear, Integer toYear, String genre) {
MutableLiveData<List<Child>> randomSongsSample = new MutableLiveData<>();
App.getSubsonicClientInstance(false)
.getAlbumSongListClient()
.getRandomSongs(number, fromYear, toYear, genre)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> 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<ApiResponse> call, @NonNull Throwable t) {
}
});
App.getSubsonicClientInstance(false).getAlbumSongListClient().getRandomSongs(number, fromYear, toYear, genre).enqueue(new Callback<ApiResponse>() {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> 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<ApiResponse> call, @NonNull Throwable t) {}
});
return randomSongsSample;
}
public void scrobble(String id, boolean submission) {
App.getSubsonicClientInstance(false)
.getMediaAnnotationClient()
.scrobble(id, submission)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
App.getSubsonicClientInstance(false).getMediaAnnotationClient().scrobble(id, submission).enqueue(new Callback<ApiResponse>() {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {}
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
});
}
public void setRating(String id, int rating) {
App.getSubsonicClientInstance(false)
.getMediaAnnotationClient()
.setRating(id, rating)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
App.getSubsonicClientInstance(false).getMediaAnnotationClient().setRating(id, rating).enqueue(new Callback<ApiResponse>() {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {}
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
});
}
public MutableLiveData<List<Child>> getSongsByGenre(String id, int page) {
MutableLiveData<List<Child>> songsByGenre = new MutableLiveData<>();
App.getSubsonicClientInstance(false)
.getAlbumSongListClient()
.getSongsByGenre(id, 100, 100 * page)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) {
songsByGenre.setValue(response.body().getSubsonicResponse().getSongsByGenre().getSongs());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
App.getSubsonicClientInstance(false).getAlbumSongListClient().getSongsByGenre(id, 100, 100 * page).enqueue(new Callback<ApiResponse>() {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) {
songsByGenre.setValue(response.body().getSubsonicResponse().getSongsByGenre().getSongs());
}
}
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
});
return songsByGenre;
}
public MutableLiveData<List<Child>> getSongsByGenres(ArrayList<String> genresId) {
MutableLiveData<List<Child>> songsByGenre = new MutableLiveData<>();
for (String id : genresId)
App.getSubsonicClientInstance(false)
.getAlbumSongListClient()
.getSongsByGenre(id, 500, 0)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> 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<ApiResponse> call, @NonNull Throwable t) {
}
});
for (String id : genresId) {
App.getSubsonicClientInstance(false).getAlbumSongListClient().getSongsByGenre(id, 500, 0).enqueue(new Callback<ApiResponse>() {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> 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<ApiResponse> call, @NonNull Throwable t) {}
});
}
return songsByGenre;
}
public MutableLiveData<Child> getSong(String id) {
MutableLiveData<Child> song = new MutableLiveData<>();
App.getSubsonicClientInstance(false)
.getBrowsingClient()
.getSong(id)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null) {
song.setValue(response.body().getSubsonicResponse().getSong());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
App.getSubsonicClientInstance(false).getBrowsingClient().getSong(id).enqueue(new Callback<ApiResponse>() {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null) {
song.setValue(response.body().getSubsonicResponse().getSong());
}
}
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
});
return song;
}
public MutableLiveData<String> getSongLyrics(Child song) {
MutableLiveData<String> lyrics = new MutableLiveData<>(null);
App.getSubsonicClientInstance(false)
.getMediaRetrievalClient()
.getLyrics(song.getArtist(), song.getTitle())
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getLyrics() != null) {
lyrics.setValue(response.body().getSubsonicResponse().getLyrics().getValue());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
App.getSubsonicClientInstance(false).getMediaRetrievalClient().getLyrics(song.getArtist(), song.getTitle()).enqueue(new Callback<ApiResponse>() {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getLyrics() != null) {
lyrics.setValue(response.body().getSubsonicResponse().getLyrics().getValue());
}
}
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
});
return lyrics;
}
}
}

View file

@ -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<MediaBrowser> mediaBrowserListenableFuture, List<Child> media, int startIndex) {
if (mediaBrowserListenableFuture != null) {
mediaBrowserListenableFuture.addListener(() -> {
try {
if (mediaBrowserListenableFuture.isDone()) {
final MediaBrowser browser = mediaBrowserListenableFuture.get();
final List<MediaItem> 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<MediaBrowser> mediaBrowserListenableFuture, Child media) {
@ -442,7 +448,7 @@ public class MediaManager {
if (mediaItem != null && Preferences.isContinuousPlayEnabled() && Preferences.isInstantMixUsable()) {
Preferences.setLastInstantMix();
LiveData<List<Child>> instantMix = getSongRepository().getInstantMix(mediaItem.mediaId, 10);
LiveData<List<Child>> instantMix = getSongRepository().getInstantMix(mediaItem.mediaId, SeedType.TRACK, 10);
instantMix.observeForever(new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> media) {

View file

@ -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<Child>? = null
}

View file

@ -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<List<Child>>() {
@Override
public void onChanged(List<Child> 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<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {
if (songs != null && !songs.isEmpty()) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
activity.setBottomSheetInPeek(true);
artistPageViewModel.getArtistInstantMix().removeObserver(this);
}
}
}));
}

View file

@ -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<Child> media = bundle.getParcelableArrayList(Constants.TRACKS_OBJECT);

View file

@ -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<Child> currentAlbumTracks = Collections.emptyList();
private List<MediaItem> currentAlbumMediaItems = Collections.emptyList();
private boolean playbackStarted = false;
private boolean dismissalScheduled = false;
private ListenableFuture<MediaBrowser> 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<Child>) media);
if (!media.isEmpty()) {
boolean isFirstBatch = !playbackStarted;
MediaManager.startQueue(mediaBrowserListenableFuture, (ArrayList<Child>) 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"));
}
}

View file

@ -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<MediaBrowser> 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<Child>) media);
if (!media.isEmpty()) {
boolean isFirstBatch = !playbackStarted;
MediaManager.startQueue(mediaBrowserListenableFuture, (ArrayList<Child>) 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);
}
}
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"));
}
}

View file

@ -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<MediaBrowser> 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<Child>) media);
if (!media.isEmpty()) {
boolean isFirstBatch = !playbackStarted;
MediaManager.startQueue(mediaBrowserListenableFuture, (ArrayList<Child>) 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"));
}
}

View file

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

View file

@ -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<List<Child>> tracksLiveData = albumRepository.getAlbumTracks(album.getId());
tracksLiveData.observeForever(new Observer<List<Child>>() {
@OptIn(markerClass = UnstableApi.class)
@Override
public void onChanged(List<Child> songs) {
if (songs != null && !songs.isEmpty()) {

View file

@ -128,7 +128,6 @@ public class ArtistPageViewModel extends AndroidViewModel {
MappingUtil.mapDownloads(songs),
songs.stream().map(Download::new).collect(Collectors.toList())
);
} else {
}
}
});

View file

@ -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<List<Child>> 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;
}

View file

@ -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<List<Child>> 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;
}

View file

@ -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<List<Child>> 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<Share> shareTrack() {
return sharingRepository.createShare(song.getId(), song.getTitle(), null);
}

View file

@ -51,6 +51,8 @@
<string name="battery_optimization_negative_button">Ignore</string>
<string name="battery_optimization_neutral_button">Don\'t ask again</string>
<string name="battery_optimization_positive_button">Disable</string>
<string name="bottom_sheet_generating_instant_mix">Generating instant mix...</string>
<string name="bottom_sheet_problem_generating_instant_mix">Could not retrieve tracks from subsonic server.</string>
<string name="connection_alert_dialog_negative_button">Cancel</string>
<string name="connection_alert_dialog_neutral_button">Enable data saver</string>
<string name="connection_alert_dialog_positive_button">OK</string>