From ffcfd81c28f448ccebacf35d41ef23572353b8a4 Mon Sep 17 00:00:00 2001 From: Thomas Anderson <127358482+zc-devs@users.noreply.github.com> Date: Fri, 5 Sep 2025 15:45:00 +0300 Subject: [PATCH 01/23] Check also underlaying transport --- .../tempo/subsonic/utils/CacheUtil.java | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/CacheUtil.java b/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/CacheUtil.java index 047b0010..69dc9dd4 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/CacheUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/CacheUtil.java @@ -38,21 +38,36 @@ public class CacheUtil { return chain.proceed(request); }; + private boolean isConnected() { ConnectivityManager connectivityManager = (ConnectivityManager) App.getContext().getSystemService(Context.CONNECTIVITY_SERVICE); - - if (connectivityManager != null) { - Network network = connectivityManager.getActiveNetwork(); - - if (network != null) { - NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network); - - if (capabilities != null) { - return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); - } - } + if (connectivityManager == null) { + return false; } - return false; + Network network = connectivityManager.getActiveNetwork(); + if (network == null) { + return false; + } + + NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network); + if (capabilities == null) { + return false; + } + + boolean hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); + if (!hasInternet) { + return false; + } + + boolean hasAppropriateTransport = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) + || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET); + if (!hasAppropriateTransport) { + return false; + } + + return true; } + } From 6c637dcbcbc92591c7cdeaff7a3f9fc6f56ac1e0 Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Thu, 9 Oct 2025 00:08:10 +1030 Subject: [PATCH 02/23] feat: Make all objects in Tempo references for quick access --- app/src/main/AndroidManifest.xml | 10 + .../tempo/repository/PlaylistRepository.java | 27 +++ .../tempo/ui/activity/MainActivity.java | 55 ++++- .../tempo/ui/dialog/TrackInfoDialog.java | 76 ++++++- .../tempo/ui/fragment/AlbumPageFragment.java | 51 ++++- .../fragment/PlayerBottomSheetFragment.java | 1 + .../ui/fragment/PlayerControllerFragment.java | 121 ++++++++++- .../SongBottomSheetDialog.java | 106 +++++++++- .../tempo/util/AssetLinkNavigator.java | 188 ++++++++++++++++++ .../tempo/util/AssetLinkUtil.java | 188 ++++++++++++++++++ .../tempo/util/MappingUtil.java | 6 + .../tempo/widget/WidgetProvider.java | 46 ++++- .../tempo/widget/WidgetUpdateManager.java | 51 ++++- app/src/main/res/drawable/ic_link.xml | 10 + .../res/layout/bottom_sheet_song_dialog.xml | 10 +- ...nner_fragment_player_controller_layout.xml | 15 +- .../main/res/layout/view_asset_link_row.xml | 55 +++++ app/src/main/res/values/ids.xml | 3 + app/src/main/res/values/strings.xml | 16 ++ .../tempo/service/MediaService.kt | 15 +- .../tempo/service/MediaService.kt | 15 +- 21 files changed, 1030 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkNavigator.java create mode 100644 app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkUtil.java create mode 100644 app/src/main/res/drawable/ic_link.xml create mode 100644 app/src/main/res/layout/view_asset_link_row.xml create mode 100644 app/src/main/res/values/ids.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 816683ca..b8d72d8b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,6 +42,16 @@ + + + + + + + + getPlaylist(String id) { + MutableLiveData playlistLiveData = new MutableLiveData<>(); + + App.getSubsonicClientInstance(false) + .getPlaylistClient() + .getPlaylist(id) + .enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful() + && response.body() != null + && response.body().getSubsonicResponse().getPlaylist() != null) { + playlistLiveData.setValue(response.body().getSubsonicResponse().getPlaylist()); + } else { + playlistLiveData.setValue(null); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + playlistLiveData.setValue(null); + } + }); + + return playlistLiveData; + } + public void addSongToPlaylist(String playlistId, ArrayList songsId) { if (songsId.isEmpty()) { Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show(); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java index db76b98d..9aa558f8 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java @@ -37,6 +37,8 @@ import com.cappielloantonio.tempo.ui.dialog.ConnectionAlertDialog; import com.cappielloantonio.tempo.ui.dialog.GithubTempoUpdateDialog; import com.cappielloantonio.tempo.ui.dialog.ServerUnreachableDialog; import com.cappielloantonio.tempo.ui.fragment.PlayerBottomSheetFragment; +import com.cappielloantonio.tempo.util.AssetLinkNavigator; +import com.cappielloantonio.tempo.util.AssetLinkUtil; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.MainViewModel; @@ -60,6 +62,8 @@ public class MainActivity extends BaseActivity { private BottomNavigationView bottomNavigationView; public NavController navController; private BottomSheetBehavior bottomSheetBehavior; + private AssetLinkNavigator assetLinkNavigator; + private AssetLinkUtil.AssetLink pendingAssetLink; ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver; private Intent pendingDownloadPlaybackIntent; @@ -76,6 +80,7 @@ public class MainActivity extends BaseActivity { setContentView(view); mainViewModel = new ViewModelProvider(this).get(MainViewModel.class); + assetLinkNavigator = new AssetLinkNavigator(this); connectivityStatusBroadcastReceiver = new ConnectivityStatusBroadcastReceiver(this); connectivityStatusReceiverManager(true); @@ -311,6 +316,24 @@ public class MainActivity extends BaseActivity { public void goFromLogin() { setBottomSheetInPeek(mainViewModel.isQueueLoaded()); goToHome(); + consumePendingAssetLink(); + } + + public void openAssetLink(@NonNull AssetLinkUtil.AssetLink assetLink) { + openAssetLink(assetLink, true); + } + + public void openAssetLink(@NonNull AssetLinkUtil.AssetLink assetLink, boolean collapsePlayer) { + if (!isUserAuthenticated()) { + pendingAssetLink = assetLink; + return; + } + if (collapsePlayer) { + setBottomSheetInPeek(true); + } + if (assetLinkNavigator != null) { + assetLinkNavigator.open(assetLink); + } } public void quit() { @@ -443,6 +466,7 @@ public class MainActivity extends BaseActivity { || intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) { pendingDownloadPlaybackIntent = new Intent(intent); } + handleAssetLinkIntent(intent); } private void consumePendingPlaybackIntent() { @@ -452,6 +476,35 @@ public class MainActivity extends BaseActivity { playDownloadedMedia(intent); } + private void handleAssetLinkIntent(Intent intent) { + AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.parse(intent); + if (assetLink == null) { + return; + } + if (!isUserAuthenticated()) { + pendingAssetLink = assetLink; + intent.setData(null); + return; + } + if (assetLinkNavigator != null) { + assetLinkNavigator.open(assetLink); + } + intent.setData(null); + } + + private boolean isUserAuthenticated() { + return Preferences.getPassword() != null + || (Preferences.getToken() != null && Preferences.getSalt() != null); + } + + private void consumePendingAssetLink() { + if (pendingAssetLink == null || assetLinkNavigator == null) { + return; + } + assetLinkNavigator.open(pendingAssetLink); + pendingAssetLink = null; + } + private void playDownloadedMedia(Intent intent) { String uriString = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_URI); if (TextUtils.isEmpty(uriString)) { @@ -500,4 +553,4 @@ public class MainActivity extends BaseActivity { MediaManager.playDownloadedMediaItem(getMediaBrowserListenableFuture(), mediaItem); } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java index f866c250..e6b91f01 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java @@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.dialog; import android.app.Dialog; import android.os.Bundle; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.fragment.app.DialogFragment; @@ -10,6 +11,7 @@ import androidx.media3.common.MediaMetadata; import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.databinding.DialogTrackInfoBinding; import com.cappielloantonio.tempo.glide.CustomGlideRequest; +import com.cappielloantonio.tempo.util.AssetLinkUtil; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; @@ -21,6 +23,11 @@ public class TrackInfoDialog extends DialogFragment { private DialogTrackInfoBinding bind; private final MediaMetadata mediaMetadata; + private AssetLinkUtil.AssetLink songLink; + private AssetLinkUtil.AssetLink albumLink; + private AssetLinkUtil.AssetLink artistLink; + private AssetLinkUtil.AssetLink genreLink; + private AssetLinkUtil.AssetLink yearLink; public TrackInfoDialog(MediaMetadata mediaMetadata) { this.mediaMetadata = mediaMetadata; @@ -52,6 +59,8 @@ public class TrackInfoDialog extends DialogFragment { } private void setTrackInfo() { + genreLink = null; + yearLink = null; bind.trakTitleInfoTextView.setText(mediaMetadata.title); bind.trakArtistInfoTextView.setText( mediaMetadata.artist != null @@ -61,17 +70,41 @@ public class TrackInfoDialog extends DialogFragment { : ""); if (mediaMetadata.extras != null) { + songLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_SONG, mediaMetadata.extras.getString("id")); + albumLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ALBUM, mediaMetadata.extras.getString("albumId")); + artistLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ARTIST, mediaMetadata.extras.getString("artistId")); + genreLink = AssetLinkUtil.parseLinkString(mediaMetadata.extras.getString("assetLinkGenre")); + yearLink = AssetLinkUtil.parseLinkString(mediaMetadata.extras.getString("assetLinkYear")); + CustomGlideRequest.Builder .from(requireContext(), mediaMetadata.extras.getString("coverArtId", ""), CustomGlideRequest.ResourceType.Song) .build() .into(bind.trackCoverInfoImageView); - bind.titleValueSector.setText(mediaMetadata.extras.getString("title", getString(R.string.label_placeholder))); - bind.albumValueSector.setText(mediaMetadata.extras.getString("album", getString(R.string.label_placeholder))); - bind.artistValueSector.setText(mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder))); + bindAssetLink(bind.trackCoverInfoImageView, albumLink != null ? albumLink : songLink); + bindAssetLink(bind.trakTitleInfoTextView, songLink); + bindAssetLink(bind.trakArtistInfoTextView, artistLink != null ? artistLink : songLink); + + String titleValue = mediaMetadata.extras.getString("title", getString(R.string.label_placeholder)); + String albumValue = mediaMetadata.extras.getString("album", getString(R.string.label_placeholder)); + String artistValue = mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder)); + String genreValue = mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder)); + int yearValue = mediaMetadata.extras.getInt("year", 0); + + if (genreLink == null && genreValue != null && !genreValue.isEmpty() && !getString(R.string.label_placeholder).contentEquals(genreValue)) { + genreLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_GENRE, genreValue); + } + + if (yearLink == null && yearValue != 0) { + yearLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(yearValue)); + } + + bind.titleValueSector.setText(titleValue); + bind.albumValueSector.setText(albumValue); + bind.artistValueSector.setText(artistValue); bind.trackNumberValueSector.setText(mediaMetadata.extras.getInt("track", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("track", 0)) : getString(R.string.label_placeholder)); - bind.yearValueSector.setText(mediaMetadata.extras.getInt("year", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("year", 0)) : getString(R.string.label_placeholder)); - bind.genreValueSector.setText(mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder))); + bind.yearValueSector.setText(yearValue != 0 ? String.valueOf(yearValue) : getString(R.string.label_placeholder)); + bind.genreValueSector.setText(genreValue); bind.sizeValueSector.setText(mediaMetadata.extras.getLong("size", 0) != 0 ? MusicUtil.getReadableByteCount(mediaMetadata.extras.getLong("size", 0)) : getString(R.string.label_placeholder)); bind.contentTypeValueSector.setText(mediaMetadata.extras.getString("contentType", getString(R.string.label_placeholder))); bind.suffixValueSector.setText(mediaMetadata.extras.getString("suffix", getString(R.string.label_placeholder))); @@ -83,6 +116,12 @@ public class TrackInfoDialog extends DialogFragment { bind.bitDepthValueSector.setText(mediaMetadata.extras.getInt("bitDepth", 0) != 0 ? mediaMetadata.extras.getInt("bitDepth", 0) + " bits" : getString(R.string.label_placeholder)); bind.pathValueSector.setText(mediaMetadata.extras.getString("path", getString(R.string.label_placeholder))); bind.discNumberValueSector.setText(mediaMetadata.extras.getInt("discNumber", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("discNumber", 0)) : getString(R.string.label_placeholder)); + + bindAssetLink(bind.titleValueSector, songLink); + bindAssetLink(bind.albumValueSector, albumLink); + bindAssetLink(bind.artistValueSector, artistLink); + bindAssetLink(bind.genreValueSector, genreLink); + bindAssetLink(bind.yearValueSector, yearLink); } } @@ -135,4 +174,31 @@ public class TrackInfoDialog extends DialogFragment { bind.trakTranscodingInfoTextView.setText(info); } } + + private void bindAssetLink(android.view.View view, AssetLinkUtil.AssetLink assetLink) { + if (view == null) return; + if (assetLink == null) { + AssetLinkUtil.clearLinkAppearance(view); + view.setOnClickListener(null); + view.setOnLongClickListener(null); + view.setClickable(false); + view.setLongClickable(false); + return; + } + + view.setClickable(true); + view.setLongClickable(true); + AssetLinkUtil.applyLinkAppearance(view); + view.setOnClickListener(v -> { + dismissAllowingStateLoss(); + boolean collapse = !AssetLinkUtil.TYPE_SONG.equals(assetLink.type); + ((com.cappielloantonio.tempo.ui.activity.MainActivity) requireActivity()).openAssetLink(assetLink, collapse); + }); + view.setOnLongClickListener(v -> { + AssetLinkUtil.copyToClipboard(requireContext(), assetLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show(); + return true; + }); + } + } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java index d0b417d7..a7f5d174 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java @@ -35,6 +35,7 @@ import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter; import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog; import com.cappielloantonio.tempo.ui.dialog.RatingDialog; +import com.cappielloantonio.tempo.util.AssetLinkUtil; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.MappingUtil; @@ -177,8 +178,35 @@ public class AlbumPageFragment extends Fragment implements ClickCallback { bind.albumNameLabel.setText(album.getName()); bind.albumArtistLabel.setText(album.getArtist()); + AssetLinkUtil.applyLinkAppearance(bind.albumArtistLabel); + AssetLinkUtil.AssetLink artistLink = buildArtistLink(album); + bind.albumArtistLabel.setOnLongClickListener(v -> { + if (artistLink != null) { + AssetLinkUtil.copyToClipboard(requireContext(), artistLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, artistLink.id), Toast.LENGTH_SHORT).show(); + return true; + } + return false; + }); bind.albumReleaseYearLabel.setText(album.getYear() != 0 ? String.valueOf(album.getYear()) : ""); - bind.albumReleaseYearLabel.setVisibility(album.getYear() != 0 ? View.VISIBLE : View.GONE); + if (album.getYear() != 0) { + bind.albumReleaseYearLabel.setVisibility(View.VISIBLE); + AssetLinkUtil.applyLinkAppearance(bind.albumReleaseYearLabel); + bind.albumReleaseYearLabel.setOnClickListener(v -> openYearLink(album.getYear())); + bind.albumReleaseYearLabel.setOnLongClickListener(v -> { + AssetLinkUtil.AssetLink yearLink = buildYearLink(album.getYear()); + if (yearLink != null) { + AssetLinkUtil.copyToClipboard(requireContext(), yearLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, yearLink.id), Toast.LENGTH_SHORT).show(); + } + return true; + }); + } else { + bind.albumReleaseYearLabel.setVisibility(View.GONE); + bind.albumReleaseYearLabel.setOnClickListener(null); + bind.albumReleaseYearLabel.setOnLongClickListener(null); + AssetLinkUtil.clearLinkAppearance(bind.albumReleaseYearLabel); + } bind.albumSongCountDurationTextview.setText(getString(R.string.album_page_tracks_count_and_duration, album.getSongCount(), album.getDuration() != null ? album.getDuration() / 60 : 0)); if (album.getGenre() != null && !album.getGenre().isEmpty()) { bind.albumGenresTextview.setText(album.getGenre()); @@ -347,4 +375,23 @@ public class AlbumPageFragment extends Fragment implements ClickCallback { private void setMediaBrowserListenableFuture() { songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); } -} \ No newline at end of file + + private void openYearLink(int year) { + AssetLinkUtil.AssetLink link = buildYearLink(year); + if (link != null) { + activity.openAssetLink(link); + } + } + + private AssetLinkUtil.AssetLink buildYearLink(int year) { + if (year <= 0) return null; + return AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year)); + } + + private AssetLinkUtil.AssetLink buildArtistLink(AlbumID3 album) { + if (album == null || album.getArtistId() == null || album.getArtistId().isEmpty()) { + return null; + } + return AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ARTIST, album.getArtistId()); + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java index 6841f247..e2bca343 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java @@ -195,6 +195,7 @@ public class PlayerBottomSheetFragment extends Fragment { } } + private void setMediaControllerUI(MediaBrowser mediaBrowser) { if (mediaBrowser.getMediaMetadata().extras != null) { switch (mediaBrowser.getMediaMetadata().extras.getString("type", Constants.MEDIA_TYPE_MUSIC)) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java index e63436c7..e3155b56 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java @@ -13,9 +13,10 @@ import android.view.ViewGroup; import android.widget.Button; import android.widget.ImageButton; import android.widget.LinearLayout; +import android.widget.RatingBar; import android.widget.TextView; import android.widget.ToggleButton; -import android.widget.RatingBar; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.constraintlayout.widget.ConstraintLayout; @@ -41,12 +42,14 @@ import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.dialog.RatingDialog; import com.cappielloantonio.tempo.ui.dialog.TrackInfoDialog; import com.cappielloantonio.tempo.ui.fragment.pager.PlayerControllerHorizontalPager; +import com.cappielloantonio.tempo.util.AssetLinkUtil; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel; import com.cappielloantonio.tempo.viewmodel.RatingViewModel; import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; import com.google.android.material.elevation.SurfaceColors; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -76,6 +79,10 @@ public class PlayerControllerFragment extends Fragment { private ImageButton playerTrackInfo; private LinearLayout ratingContainer; private ImageButton equalizerButton; + private ChipGroup assetLinkChipGroup; + private Chip playerSongLinkChip; + private Chip playerAlbumLinkChip; + private Chip playerArtistLinkChip; private MainActivity activity; private PlayerBottomSheetViewModel playerBottomSheetViewModel; @@ -139,6 +146,10 @@ public class PlayerControllerFragment extends Fragment { songRatingBar = bind.getRoot().findViewById(R.id.song_rating_bar); ratingContainer = bind.getRoot().findViewById(R.id.rating_container); equalizerButton = bind.getRoot().findViewById(R.id.player_open_equalizer_button); + assetLinkChipGroup = bind.getRoot().findViewById(R.id.asset_link_chip_group); + playerSongLinkChip = bind.getRoot().findViewById(R.id.asset_link_song_chip); + playerAlbumLinkChip = bind.getRoot().findViewById(R.id.asset_link_album_chip); + playerArtistLinkChip = bind.getRoot().findViewById(R.id.asset_link_artist_chip); checkAndSetRatingContainerVisibility(); } @@ -219,6 +230,8 @@ public class PlayerControllerFragment extends Fragment { || mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) && mediaMetadata.extras.getString("uri") != null ? View.VISIBLE : View.GONE); + + updateAssetLinkChips(mediaMetadata); } private void setMediaInfo(MediaMetadata mediaMetadata) { @@ -259,6 +272,110 @@ public class PlayerControllerFragment extends Fragment { }); } + private void updateAssetLinkChips(MediaMetadata mediaMetadata) { + if (assetLinkChipGroup == null) return; + String mediaType = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type", Constants.MEDIA_TYPE_MUSIC) : Constants.MEDIA_TYPE_MUSIC; + if (!Constants.MEDIA_TYPE_MUSIC.equals(mediaType)) { + clearAssetLinkChip(playerSongLinkChip); + clearAssetLinkChip(playerAlbumLinkChip); + clearAssetLinkChip(playerArtistLinkChip); + syncAssetLinkGroupVisibility(); + return; + } + + String songId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("id") : null; + String albumId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("albumId") : null; + String artistId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("artistId") : null; + + AssetLinkUtil.AssetLink songLink = bindAssetLinkChip(playerSongLinkChip, AssetLinkUtil.TYPE_SONG, songId); + AssetLinkUtil.AssetLink albumLink = bindAssetLinkChip(playerAlbumLinkChip, AssetLinkUtil.TYPE_ALBUM, albumId); + AssetLinkUtil.AssetLink artistLink = bindAssetLinkChip(playerArtistLinkChip, AssetLinkUtil.TYPE_ARTIST, artistId); + bindAssetLinkView(playerMediaTitleLabel, songLink); + bindAssetLinkView(playerArtistNameLabel, artistLink != null ? artistLink : songLink); + bindAssetLinkView(playerMediaCoverViewPager, songLink); + syncAssetLinkGroupVisibility(); + } + + private AssetLinkUtil.AssetLink bindAssetLinkChip(Chip chip, String type, String id) { + if (chip == null) return null; + if (TextUtils.isEmpty(id)) { + clearAssetLinkChip(chip); + return null; + } + + String label = getString(AssetLinkUtil.getLabelRes(type)); + AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.buildAssetLink(type, id); + if (assetLink == null) { + clearAssetLinkChip(chip); + return null; + } + + chip.setText(getString(R.string.asset_link_chip_text, label, assetLink.id)); + chip.setVisibility(View.VISIBLE); + + chip.setOnClickListener(v -> { + if (assetLink != null) { + activity.openAssetLink(assetLink); + } + }); + + chip.setOnLongClickListener(v -> { + if (assetLink != null) { + AssetLinkUtil.copyToClipboard(requireContext(), assetLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, id), Toast.LENGTH_SHORT).show(); + } + return true; + }); + + return assetLink; + } + + private void clearAssetLinkChip(Chip chip) { + if (chip == null) return; + chip.setVisibility(View.GONE); + chip.setText(""); + chip.setOnClickListener(null); + chip.setOnLongClickListener(null); + } + + private void bindAssetLinkView(View view, AssetLinkUtil.AssetLink assetLink) { + if (view == null) return; + if (assetLink == null) { + AssetLinkUtil.clearLinkAppearance(view); + view.setOnClickListener(null); + view.setOnLongClickListener(null); + view.setClickable(false); + view.setLongClickable(false); + return; + } + + view.setClickable(true); + view.setLongClickable(true); + AssetLinkUtil.applyLinkAppearance(view); + view.setOnClickListener(v -> { + boolean collapse = !AssetLinkUtil.TYPE_SONG.equals(assetLink.type); + activity.openAssetLink(assetLink, collapse); + }); + view.setOnLongClickListener(v -> { + AssetLinkUtil.copyToClipboard(requireContext(), assetLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show(); + return true; + }); + } + + private void syncAssetLinkGroupVisibility() { + if (assetLinkChipGroup == null) return; + boolean hasVisible = false; + for (int i = 0; i < assetLinkChipGroup.getChildCount(); i++) { + View child = assetLinkChipGroup.getChildAt(i); + if (child.getVisibility() == View.VISIBLE) { + hasVisible = true; + break; + } + } + assetLinkChipGroup.setVisibility(hasVisible ? View.VISIBLE : View.GONE); + } + private void setMediaControllerUI(MediaBrowser mediaBrowser) { initPlaybackSpeedButton(mediaBrowser); @@ -548,4 +665,4 @@ public class PlayerControllerFragment extends Fragment { isServiceBound = false; } } -} \ No newline at end of file +} 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 90e32793..39ba4394 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 @@ -30,6 +30,7 @@ import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog; import com.cappielloantonio.tempo.ui.dialog.RatingDialog; +import com.cappielloantonio.tempo.util.AssetLinkUtil; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.ExternalAudioReader; @@ -39,6 +40,8 @@ import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.HomeViewModel; import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; import com.google.common.util.concurrent.ListenableFuture; import android.content.Intent; @@ -56,6 +59,13 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements private TextView downloadButton; private TextView removeButton; + private ChipGroup assetLinkChipGroup; + private Chip songLinkChip; + private Chip albumLinkChip; + private Chip artistLinkChip; + private AssetLinkUtil.AssetLink currentSongLink; + private AssetLinkUtil.AssetLink currentAlbumLink; + private AssetLinkUtil.AssetLink currentArtistLink; private ListenableFuture mediaBrowserListenableFuture; @@ -109,6 +119,11 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements TextView artistSong = view.findViewById(R.id.song_artist_text_view); artistSong.setText(songBottomSheetViewModel.getSong().getArtist()); + initAssetLinkChips(view); + bindAssetLinkView(coverSong, currentSongLink); + bindAssetLinkView(titleSong, currentSongLink); + bindAssetLinkView(artistSong, currentArtistLink != null ? currentArtistLink : currentSongLink); + ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite); favoriteToggle.setChecked(songBottomSheetViewModel.getSong().getStarred() != null); favoriteToggle.setOnClickListener(v -> { @@ -282,6 +297,95 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements } } + private void initAssetLinkChips(View root) { + assetLinkChipGroup = root.findViewById(R.id.asset_link_chip_group); + songLinkChip = root.findViewById(R.id.asset_link_song_chip); + albumLinkChip = root.findViewById(R.id.asset_link_album_chip); + artistLinkChip = root.findViewById(R.id.asset_link_artist_chip); + + currentSongLink = bindAssetLinkChip(songLinkChip, AssetLinkUtil.TYPE_SONG, song.getId()); + currentAlbumLink = bindAssetLinkChip(albumLinkChip, AssetLinkUtil.TYPE_ALBUM, song.getAlbumId()); + currentArtistLink = bindAssetLinkChip(artistLinkChip, AssetLinkUtil.TYPE_ARTIST, song.getArtistId()); + syncAssetLinkGroupVisibility(); + } + + private AssetLinkUtil.AssetLink bindAssetLinkChip(@Nullable Chip chip, String type, @Nullable String id) { + if (chip == null) return null; + if (id == null || id.isEmpty()) { + clearAssetLinkChip(chip); + return null; + } + + String label = getString(AssetLinkUtil.getLabelRes(type)); + AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.buildAssetLink(type, id); + if (assetLink == null) { + clearAssetLinkChip(chip); + return null; + } + + chip.setText(getString(R.string.asset_link_chip_text, label, assetLink.id)); + chip.setVisibility(View.VISIBLE); + + chip.setOnClickListener(v -> { + if (assetLink != null) { + ((MainActivity) requireActivity()).openAssetLink(assetLink); + } + }); + + chip.setOnLongClickListener(v -> { + if (assetLink != null) { + AssetLinkUtil.copyToClipboard(requireContext(), assetLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, id), Toast.LENGTH_SHORT).show(); + } + return true; + }); + + return assetLink; + } + + private void clearAssetLinkChip(@Nullable Chip chip) { + if (chip == null) return; + chip.setVisibility(View.GONE); + chip.setText(""); + chip.setOnClickListener(null); + chip.setOnLongClickListener(null); + } + + private void syncAssetLinkGroupVisibility() { + if (assetLinkChipGroup == null) return; + boolean hasVisible = false; + for (int i = 0; i < assetLinkChipGroup.getChildCount(); i++) { + View child = assetLinkChipGroup.getChildAt(i); + if (child.getVisibility() == View.VISIBLE) { + hasVisible = true; + break; + } + } + assetLinkChipGroup.setVisibility(hasVisible ? View.VISIBLE : View.GONE); + } + + private void bindAssetLinkView(@Nullable View view, @Nullable AssetLinkUtil.AssetLink assetLink) { + if (view == null) return; + if (assetLink == null) { + AssetLinkUtil.clearLinkAppearance(view); + view.setOnClickListener(null); + view.setOnLongClickListener(null); + view.setClickable(false); + view.setLongClickable(false); + return; + } + + view.setClickable(true); + view.setLongClickable(true); + AssetLinkUtil.applyLinkAppearance(view); + view.setOnClickListener(v -> ((MainActivity) requireActivity()).openAssetLink(assetLink, !AssetLinkUtil.TYPE_SONG.equals(assetLink.type))); + view.setOnLongClickListener(v -> { + AssetLinkUtil.copyToClipboard(requireContext(), assetLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show(); + return true; + }); + } + private void initializeMediaBrowser() { mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync(); } @@ -293,4 +397,4 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements private void refreshShares() { homeViewModel.refreshShares(requireActivity()); } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkNavigator.java b/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkNavigator.java new file mode 100644 index 00000000..9d3ba966 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkNavigator.java @@ -0,0 +1,188 @@ +package com.cappielloantonio.tempo.util; + +import android.os.Bundle; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavController; +import androidx.navigation.NavOptions; + +import com.cappielloantonio.tempo.BuildConfig; +import com.cappielloantonio.tempo.R; +import com.cappielloantonio.tempo.repository.AlbumRepository; +import com.cappielloantonio.tempo.repository.ArtistRepository; +import com.cappielloantonio.tempo.repository.PlaylistRepository; +import com.cappielloantonio.tempo.repository.SongRepository; +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.Playlist; +import com.cappielloantonio.tempo.subsonic.models.Genre; +import com.cappielloantonio.tempo.ui.activity.MainActivity; +import com.cappielloantonio.tempo.ui.fragment.bottomsheetdialog.SongBottomSheetDialog; +import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel; + +public final class AssetLinkNavigator { + private final MainActivity activity; + private final SongRepository songRepository = new SongRepository(); + private final AlbumRepository albumRepository = new AlbumRepository(); + private final ArtistRepository artistRepository = new ArtistRepository(); + private final PlaylistRepository playlistRepository = new PlaylistRepository(); + + public AssetLinkNavigator(@NonNull MainActivity activity) { + this.activity = activity; + } + + public void open(@Nullable AssetLinkUtil.AssetLink assetLink) { + if (assetLink == null) { + return; + } + switch (assetLink.type) { + case AssetLinkUtil.TYPE_SONG: + openSong(assetLink.id); + break; + case AssetLinkUtil.TYPE_ALBUM: + openAlbum(assetLink.id); + break; + case AssetLinkUtil.TYPE_ARTIST: + openArtist(assetLink.id); + break; + case AssetLinkUtil.TYPE_PLAYLIST: + openPlaylist(assetLink.id); + break; + case AssetLinkUtil.TYPE_GENRE: + openGenre(assetLink.id); + break; + case AssetLinkUtil.TYPE_YEAR: + openYear(assetLink.id); + break; + default: + Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show(); + break; + } + } + + private void openSong(@NonNull String id) { + MutableLiveData liveData = songRepository.getSong(id); + Observer observer = new Observer() { + @Override + public void onChanged(Child child) { + liveData.removeObserver(this); + if (child == null) { + Toast.makeText(activity, R.string.asset_link_error_song, Toast.LENGTH_SHORT).show(); + return; + } + SongBottomSheetViewModel viewModel = new ViewModelProvider(activity).get(SongBottomSheetViewModel.class); + viewModel.setSong(child); + SongBottomSheetDialog dialog = new SongBottomSheetDialog(); + Bundle args = new Bundle(); + args.putParcelable(Constants.TRACK_OBJECT, child); + dialog.setArguments(args); + dialog.show(activity.getSupportFragmentManager(), null); + } + }; + liveData.observe(activity, observer); + } + + private void openAlbum(@NonNull String id) { + MutableLiveData liveData = albumRepository.getAlbum(id); + Observer observer = new Observer() { + @Override + public void onChanged(AlbumID3 album) { + liveData.removeObserver(this); + if (album == null) { + Toast.makeText(activity, R.string.asset_link_error_album, Toast.LENGTH_SHORT).show(); + return; + } + Bundle args = new Bundle(); + args.putParcelable(Constants.ALBUM_OBJECT, album); + navigateSafely(R.id.albumPageFragment, args); + } + }; + liveData.observe(activity, observer); + } + + private void openArtist(@NonNull String id) { + MutableLiveData liveData = artistRepository.getArtist(id); + Observer observer = new Observer() { + @Override + public void onChanged(ArtistID3 artist) { + liveData.removeObserver(this); + if (artist == null) { + Toast.makeText(activity, R.string.asset_link_error_artist, Toast.LENGTH_SHORT).show(); + return; + } + Bundle args = new Bundle(); + args.putParcelable(Constants.ARTIST_OBJECT, artist); + navigateSafely(R.id.artistPageFragment, args); + } + }; + liveData.observe(activity, observer); + } + + private void openPlaylist(@NonNull String id) { + MutableLiveData liveData = playlistRepository.getPlaylist(id); + Observer observer = new Observer() { + @Override + public void onChanged(Playlist playlist) { + liveData.removeObserver(this); + if (playlist == null) { + Toast.makeText(activity, R.string.asset_link_error_playlist, Toast.LENGTH_SHORT).show(); + return; + } + Bundle args = new Bundle(); + args.putParcelable(Constants.PLAYLIST_OBJECT, playlist); + navigateSafely(R.id.playlistPageFragment, args); + } + }; + liveData.observe(activity, observer); + } + + private void openGenre(@NonNull String genreName) { + String trimmed = genreName.trim(); + if (trimmed.isEmpty()) { + Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show(); + return; + } + + Genre genre = new Genre(); + genre.setGenre(trimmed); + genre.setSongCount(0); + genre.setAlbumCount(0); + Bundle args = new Bundle(); + args.putParcelable(Constants.GENRE_OBJECT, genre); + args.putString(Constants.MEDIA_BY_GENRE, Constants.MEDIA_BY_GENRE); + navigateSafely(R.id.songListPageFragment, args); + } + + private void openYear(@NonNull String yearValue) { + try { + int year = Integer.parseInt(yearValue.trim()); + Bundle args = new Bundle(); + args.putInt("year_object", year); + args.putString(Constants.MEDIA_BY_YEAR, Constants.MEDIA_BY_YEAR); + navigateSafely(R.id.songListPageFragment, args); + } catch (NumberFormatException ex) { + Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show(); + } + } + + private void navigateSafely(int destinationId, @Nullable Bundle args) { + activity.runOnUiThread(() -> { + NavController navController = activity.navController; + if (navController == null) { + return; + } + if (navController.getCurrentDestination() != null + && navController.getCurrentDestination().getId() == destinationId) { + navController.navigate(destinationId, args, new NavOptions.Builder().setLaunchSingleTop(true).build()); + } else { + navController.navigate(destinationId, args); + } + }); + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkUtil.java new file mode 100644 index 00000000..1609a88a --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkUtil.java @@ -0,0 +1,188 @@ +package com.cappielloantonio.tempo.util; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.text.TextUtils; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.core.content.ContextCompat; + +import com.cappielloantonio.tempo.R; + +import java.util.Objects; + +import com.google.android.material.color.MaterialColors; + +public final class AssetLinkUtil { + public static final String SCHEME = "tempo"; + public static final String HOST_ASSET = "asset"; + + public static final String TYPE_SONG = "song"; + public static final String TYPE_ALBUM = "album"; + public static final String TYPE_ARTIST = "artist"; + public static final String TYPE_PLAYLIST = "playlist"; + public static final String TYPE_GENRE = "genre"; + public static final String TYPE_YEAR = "year"; + + private AssetLinkUtil() { + } + + @Nullable + public static AssetLink parse(@Nullable Intent intent) { + if (intent == null) return null; + return parse(intent.getData()); + } + + @Nullable + public static AssetLink parse(@Nullable Uri uri) { + if (uri == null) { + return null; + } + + if (!SCHEME.equalsIgnoreCase(uri.getScheme())) { + return null; + } + + String host = uri.getHost(); + if (!HOST_ASSET.equalsIgnoreCase(host)) { + return null; + } + + if (uri.getPathSegments().size() < 2) { + return null; + } + + String type = uri.getPathSegments().get(0); + String id = uri.getPathSegments().get(1); + if (TextUtils.isEmpty(type) || TextUtils.isEmpty(id)) { + return null; + } + + if (!isSupportedType(type)) { + return null; + } + + return new AssetLink(type, id, uri); + } + + public static boolean isSupportedType(@Nullable String type) { + if (type == null) return false; + switch (type) { + case TYPE_SONG: + case TYPE_ALBUM: + case TYPE_ARTIST: + case TYPE_PLAYLIST: + case TYPE_GENRE: + case TYPE_YEAR: + return true; + default: + return false; + } + } + + @NonNull + public static Uri buildUri(@NonNull String type, @NonNull String id) { + return new Uri.Builder() + .scheme(SCHEME) + .authority(HOST_ASSET) + .appendPath(type) + .appendPath(id) + .build(); + } + + @Nullable + public static String buildLink(@Nullable String type, @Nullable String id) { + if (TextUtils.isEmpty(type) || TextUtils.isEmpty(id) || !isSupportedType(type)) { + return null; + } + return buildUri(Objects.requireNonNull(type), Objects.requireNonNull(id)).toString(); + } + + @Nullable + public static AssetLink buildAssetLink(@Nullable String type, @Nullable String id) { + String link = buildLink(type, id); + return parseLinkString(link); + } + + @Nullable + public static AssetLink parseLinkString(@Nullable String link) { + if (TextUtils.isEmpty(link)) { + return null; + } + return parse(Uri.parse(link)); + } + + public static void copyToClipboard(@NonNull Context context, @NonNull AssetLink assetLink) { + ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboardManager == null) { + return; + } + ClipData clipData = ClipData.newPlainText(context.getString(R.string.asset_link_clipboard_label), assetLink.uri.toString()); + clipboardManager.setPrimaryClip(clipData); + } + + @StringRes + public static int getLabelRes(@NonNull String type) { + switch (type) { + case TYPE_SONG: + return R.string.asset_link_label_song; + case TYPE_ALBUM: + return R.string.asset_link_label_album; + case TYPE_ARTIST: + return R.string.asset_link_label_artist; + case TYPE_PLAYLIST: + return R.string.asset_link_label_playlist; + case TYPE_GENRE: + return R.string.asset_link_label_genre; + case TYPE_YEAR: + return R.string.asset_link_label_year; + default: + return R.string.asset_link_label_unknown; + } + } + + public static void applyLinkAppearance(@NonNull View view) { + if (view instanceof TextView) { + TextView textView = (TextView) view; + if (textView.getTag(R.id.tag_link_original_color) == null) { + textView.setTag(R.id.tag_link_original_color, textView.getCurrentTextColor()); + } + int accent = MaterialColors.getColor(view, com.google.android.material.R.attr.colorPrimary, + ContextCompat.getColor(view.getContext(), android.R.color.holo_blue_light)); + textView.setTextColor(accent); + } + } + + public static void clearLinkAppearance(@NonNull View view) { + if (view instanceof TextView) { + TextView textView = (TextView) view; + Object original = textView.getTag(R.id.tag_link_original_color); + if (original instanceof Integer) { + textView.setTextColor((Integer) original); + } else { + int defaultColor = MaterialColors.getColor(view, com.google.android.material.R.attr.colorOnSurface, + ContextCompat.getColor(view.getContext(), android.R.color.primary_text_light)); + textView.setTextColor(defaultColor); + } + } + } + + public static final class AssetLink { + public final String type; + public final String id; + public final Uri uri; + + AssetLink(@NonNull String type, @NonNull String id, @NonNull Uri uri) { + this.type = type; + this.id = id; + this.uri = uri; + } + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java index 9bd7fcad..558aa218 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java @@ -74,6 +74,12 @@ public class MappingUtil { bundle.putInt("originalWidth", media.getOriginalWidth() != null ? media.getOriginalWidth() : 0); bundle.putInt("originalHeight", media.getOriginalHeight() != null ? media.getOriginalHeight() : 0); bundle.putString("uri", uri.toString()); + bundle.putString("assetLinkSong", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, media.getId())); + bundle.putString("assetLinkAlbum", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, media.getAlbumId())); + bundle.putString("assetLinkArtist", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, media.getArtistId())); + bundle.putString("assetLinkGenre", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_GENRE, media.getGenre())); + Integer year = media.getYear(); + bundle.putString("assetLinkYear", year != null && year != 0 ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year)) : null); return new MediaItem.Builder() .setMediaId(media.getId()) diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java index 06986145..93a1a7e2 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java @@ -5,17 +5,20 @@ import android.appwidget.AppWidgetManager; import android.appwidget.AppWidgetProvider; import android.content.Context; import android.content.Intent; +import android.net.Uri; +import android.text.TextUtils; import android.widget.RemoteViews; import com.cappielloantonio.tempo.R; import android.app.TaskStackBuilder; -import android.app.PendingIntent; import com.cappielloantonio.tempo.ui.activity.MainActivity; import android.util.Log; +import androidx.annotation.Nullable; + public class WidgetProvider extends AppWidgetProvider { private static final String TAG = "TempoWidget"; public static final String ACT_PLAY_PAUSE = "tempo.widget.PLAY_PAUSE"; @@ -28,7 +31,7 @@ public class WidgetProvider extends AppWidgetProvider { public void onUpdate(Context ctx, AppWidgetManager mgr, int[] ids) { for (int id : ids) { RemoteViews rv = WidgetUpdateManager.chooseBuild(ctx, id); - attachIntents(ctx, rv, id); + attachIntents(ctx, rv, id, null, null, null); mgr.updateAppWidget(id, rv); } } @@ -50,16 +53,23 @@ public class WidgetProvider extends AppWidgetProvider { public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, android.os.Bundle newOptions) { super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions); RemoteViews rv = WidgetUpdateManager.chooseBuild(context, appWidgetId); - attachIntents(context, rv, appWidgetId); + attachIntents(context, rv, appWidgetId, null, null, null); appWidgetManager.updateAppWidget(appWidgetId, rv); WidgetUpdateManager.refreshFromController(context); } public static void attachIntents(Context ctx, RemoteViews rv) { - attachIntents(ctx, rv, 0); + attachIntents(ctx, rv, 0, null, null, null); } public static void attachIntents(Context ctx, RemoteViews rv, int requestCodeBase) { + attachIntents(ctx, rv, requestCodeBase, null, null, null); + } + + public static void attachIntents(Context ctx, RemoteViews rv, int requestCodeBase, + String songLink, + String albumLink, + String artistLink) { PendingIntent playPause = PendingIntent.getBroadcast( ctx, requestCodeBase + 0, @@ -97,9 +107,31 @@ public class WidgetProvider extends AppWidgetProvider { rv.setOnClickPendingIntent(R.id.btn_shuffle, shuffle); rv.setOnClickPendingIntent(R.id.btn_repeat, repeat); - PendingIntent launch = TaskStackBuilder.create(ctx) - .addNextIntentWithParentStack(new Intent(ctx, MainActivity.class)) - .getPendingIntent(requestCodeBase + 10, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + PendingIntent launch = buildMainActivityPendingIntent(ctx, requestCodeBase + 10, null); rv.setOnClickPendingIntent(R.id.root, launch); + + PendingIntent songPending = buildMainActivityPendingIntent(ctx, requestCodeBase + 20, songLink); + PendingIntent artistPending = buildMainActivityPendingIntent(ctx, requestCodeBase + 21, artistLink); + PendingIntent albumPending = buildMainActivityPendingIntent(ctx, requestCodeBase + 22, albumLink); + + PendingIntent fallback = launch; + rv.setOnClickPendingIntent(R.id.album_art, songPending != null ? songPending : fallback); + rv.setOnClickPendingIntent(R.id.title, songPending != null ? songPending : fallback); + rv.setOnClickPendingIntent(R.id.subtitle, + artistPending != null ? artistPending : (songPending != null ? songPending : fallback)); + rv.setOnClickPendingIntent(R.id.album, albumPending != null ? albumPending : fallback); + } + + private static PendingIntent buildMainActivityPendingIntent(Context ctx, int requestCode, @Nullable String link) { + Intent intent; + if (!TextUtils.isEmpty(link)) { + intent = new Intent(Intent.ACTION_VIEW, Uri.parse(link), ctx, MainActivity.class); + } else { + intent = new Intent(ctx, MainActivity.class); + } + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + TaskStackBuilder stackBuilder = TaskStackBuilder.create(ctx); + stackBuilder.addNextIntentWithParentStack(intent); + return stackBuilder.getPendingIntent(requestCode, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java index 4132511e..c1fa409b 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java @@ -4,8 +4,9 @@ import android.appwidget.AppWidgetManager; import android.content.ComponentName; import android.content.Context; import android.graphics.Bitmap; -import android.text.TextUtils; import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.text.TextUtils; import com.bumptech.glide.request.target.CustomTarget; import com.bumptech.glide.request.transition.Transition; @@ -17,6 +18,7 @@ import androidx.media3.session.MediaController; import androidx.media3.session.SessionToken; import com.cappielloantonio.tempo.service.MediaService; +import com.cappielloantonio.tempo.util.AssetLinkUtil; import com.cappielloantonio.tempo.util.MusicUtil; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -34,7 +36,10 @@ public final class WidgetUpdateManager { boolean shuffleEnabled, int repeatMode, long positionMs, - long durationMs) { + long durationMs, + String songLink, + String albumLink, + String artistLink) { if (TextUtils.isEmpty(title)) title = ctx.getString(R.string.widget_not_playing); if (TextUtils.isEmpty(artist)) artist = ctx.getString(R.string.widget_placeholder_subtitle); if (TextUtils.isEmpty(album)) album = ""; @@ -46,7 +51,7 @@ public final class WidgetUpdateManager { for (int id : ids) { android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, album, art, playing, timing.elapsedText, timing.totalText, timing.progress, shuffleEnabled, repeatMode, id); - WidgetProvider.attachIntents(ctx, rv, id); + WidgetProvider.attachIntents(ctx, rv, id, songLink, albumLink, artistLink); mgr.updateAppWidget(id, rv); } } @@ -56,7 +61,7 @@ public final class WidgetUpdateManager { int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class)); for (int id : ids) { android.widget.RemoteViews rv = chooseBuild(ctx, id); - WidgetProvider.attachIntents(ctx, rv, id); + WidgetProvider.attachIntents(ctx, rv, id, null, null, null); mgr.updateAppWidget(id, rv); } } @@ -70,7 +75,10 @@ public final class WidgetUpdateManager { boolean shuffleEnabled, int repeatMode, long positionMs, - long durationMs) { + long durationMs, + String songLink, + String albumLink, + String artistLink) { final Context appCtx = ctx.getApplicationContext(); final String t = TextUtils.isEmpty(title) ? appCtx.getString(R.string.widget_not_playing) : title; final String a = TextUtils.isEmpty(artist) ? appCtx.getString(R.string.widget_placeholder_subtitle) : artist; @@ -79,6 +87,9 @@ public final class WidgetUpdateManager { final boolean sh = shuffleEnabled; final int rep = repeatMode; final TimingInfo timing = createTimingInfo(positionMs, durationMs); + final String songLinkFinal = songLink; + final String albumLinkFinal = albumLink; + final String artistLinkFinal = artistLink; if (!TextUtils.isEmpty(coverArtId)) { CustomGlideRequest.loadAlbumArtBitmap( @@ -93,7 +104,7 @@ public final class WidgetUpdateManager { for (int id : ids) { android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, resource, p, timing.elapsedText, timing.totalText, timing.progress, sh, rep, id); - WidgetProvider.attachIntents(appCtx, rv, id); + WidgetProvider.attachIntents(appCtx, rv, id, songLinkFinal, albumLinkFinal, artistLinkFinal); mgr.updateAppWidget(id, rv); } } @@ -105,7 +116,7 @@ public final class WidgetUpdateManager { for (int id : ids) { android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p, timing.elapsedText, timing.totalText, timing.progress, sh, rep, id); - WidgetProvider.attachIntents(appCtx, rv, id); + WidgetProvider.attachIntents(appCtx, rv, id, songLinkFinal, albumLinkFinal, artistLinkFinal); mgr.updateAppWidget(id, rv); } } @@ -117,7 +128,7 @@ public final class WidgetUpdateManager { for (int id : ids) { android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p, timing.elapsedText, timing.totalText, timing.progress, sh, rep, id); - WidgetProvider.attachIntents(appCtx, rv, id); + WidgetProvider.attachIntents(appCtx, rv, id, songLinkFinal, albumLinkFinal, artistLinkFinal); mgr.updateAppWidget(id, rv); } } @@ -133,6 +144,7 @@ public final class WidgetUpdateManager { MediaController c = future.get(); androidx.media3.common.MediaItem mi = c.getCurrentMediaItem(); String title = null, artist = null, album = null, coverId = null; + String songLink = null, albumLink = null, artistLink = null; if (mi != null && mi.mediaMetadata != null) { if (mi.mediaMetadata.title != null) title = mi.mediaMetadata.title.toString(); if (mi.mediaMetadata.artist != null) @@ -140,10 +152,26 @@ public final class WidgetUpdateManager { if (mi.mediaMetadata.albumTitle != null) album = mi.mediaMetadata.albumTitle.toString(); if (mi.mediaMetadata.extras != null) { + Bundle extras = mi.mediaMetadata.extras; if (title == null) title = mi.mediaMetadata.extras.getString("title"); if (artist == null) artist = mi.mediaMetadata.extras.getString("artist"); if (album == null) album = mi.mediaMetadata.extras.getString("album"); - coverId = mi.mediaMetadata.extras.getString("coverArtId"); + coverId = extras.getString("coverArtId"); + + songLink = extras.getString("assetLinkSong"); + if (songLink == null) { + songLink = AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras.getString("id")); + } + + albumLink = extras.getString("assetLinkAlbum"); + if (albumLink == null) { + albumLink = AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras.getString("albumId")); + } + + artistLink = extras.getString("assetLinkArtist"); + if (artistLink == null) { + artistLink = AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras.getString("artistId")); + } } } long position = c.getCurrentPosition(); @@ -159,7 +187,10 @@ public final class WidgetUpdateManager { c.getShuffleModeEnabled(), c.getRepeatMode(), position, - duration); + duration, + songLink, + albumLink, + artistLink); c.release(); } catch (ExecutionException | InterruptedException ignored) { } diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml new file mode 100644 index 00000000..5592db28 --- /dev/null +++ b/app/src/main/res/drawable/ic_link.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/bottom_sheet_song_dialog.xml b/app/src/main/res/layout/bottom_sheet_song_dialog.xml index 2105f941..7cf98440 100644 --- a/app/src/main/res/layout/bottom_sheet_song_dialog.xml +++ b/app/src/main/res/layout/bottom_sheet_song_dialog.xml @@ -68,6 +68,14 @@ + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/inner_fragment_player_controller_layout.xml b/app/src/main/res/layout/inner_fragment_player_controller_layout.xml index 99e2c90f..29747587 100644 --- a/app/src/main/res/layout/inner_fragment_player_controller_layout.xml +++ b/app/src/main/res/layout/inner_fragment_player_controller_layout.xml @@ -57,6 +57,17 @@ + + + app:layout_constraintTop_toBottomOf="@+id/player_asset_link_row" /> - \ No newline at end of file + diff --git a/app/src/main/res/layout/view_asset_link_row.xml b/app/src/main/res/layout/view_asset_link_row.xml new file mode 100644 index 00000000..7060db54 --- /dev/null +++ b/app/src/main/res/layout/view_asset_link_row.xml @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 00000000..c29aa81f --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b8e025a..01f53610 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -410,6 +410,22 @@ Update share Expiration date: %1$s Sharing is not supported or not enabled + Tempo asset link + Song UID + Album UID + Artist UID + Playlist UID + Genre UID + Year UID + Asset UID + Unsupported asset link + Song could not be opened + Album could not be opened + Artist could not be opened + Playlist could not be opened + %1$s • %2$s + Copied %1$s to clipboard + Asset link: %1$s Description Expiration date Cancel diff --git a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt index 27214f90..28e6c561 100644 --- a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -20,6 +20,7 @@ import androidx.media3.session.* import androidx.media3.session.MediaSession.ControllerInfo import com.cappielloantonio.tempo.R import com.cappielloantonio.tempo.ui.activity.MainActivity +import com.cappielloantonio.tempo.util.AssetLinkUtil import com.cappielloantonio.tempo.util.Constants import com.cappielloantonio.tempo.util.DownloadUtil import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory @@ -421,7 +422,14 @@ class MediaService : MediaLibraryService() { ?: mi?.mediaMetadata?.extras?.getString("artist") val album = mi?.mediaMetadata?.albumTitle?.toString() ?: mi?.mediaMetadata?.extras?.getString("album") - val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId") + val extras = mi?.mediaMetadata?.extras + val coverId = extras?.getString("coverArtId") + val songLink = extras?.getString("assetLinkSong") + ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id")) + val albumLink = extras?.getString("assetLinkAlbum") + ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId")) + val artistLink = extras?.getString("assetLinkArtist") + ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId")) val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L WidgetUpdateManager.updateFromState( @@ -434,7 +442,10 @@ class MediaService : MediaLibraryService() { player.shuffleModeEnabled, player.repeatMode, position, - duration + duration, + songLink, + albumLink, + artistLink ) } diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt index 51292761..82675ba1 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -22,6 +22,7 @@ import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession.ControllerInfo import com.cappielloantonio.tempo.repository.AutomotiveRepository import com.cappielloantonio.tempo.ui.activity.MainActivity +import com.cappielloantonio.tempo.util.AssetLinkUtil import com.cappielloantonio.tempo.util.Constants import com.cappielloantonio.tempo.util.DownloadUtil import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory @@ -262,7 +263,14 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { ?: mi?.mediaMetadata?.extras?.getString("artist") val album = mi?.mediaMetadata?.albumTitle?.toString() ?: mi?.mediaMetadata?.extras?.getString("album") - val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId") + val extras = mi?.mediaMetadata?.extras + val coverId = extras?.getString("coverArtId") + val songLink = extras?.getString("assetLinkSong") + ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id")) + val albumLink = extras?.getString("assetLinkAlbum") + ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId")) + val artistLink = extras?.getString("assetLinkArtist") + ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId")) val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L WidgetUpdateManager.updateFromState( @@ -275,7 +283,10 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { player.shuffleModeEnabled, player.repeatMode, position, - duration + duration, + songLink, + albumLink, + artistLink ) } From 2c53f36a181d2ee083e915a9511dcc2dab37821e Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Thu, 9 Oct 2025 11:27:30 +1030 Subject: [PATCH 03/23] fix: Support content URIs for external downloader --- .../tempo/util/ExternalAudioWriter.java | 107 +++++++++++++++--- 1 file changed, 89 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java index cf0f768e..efd97350 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java @@ -17,6 +17,9 @@ import com.cappielloantonio.tempo.repository.DownloadRepository; import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.ui.activity.MainActivity; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; @@ -102,35 +105,76 @@ public class ExternalAudioWriter { ExternalDownloadMetadataStore.remove(metadataKey); return; } - String scheme = mediaUri.getScheme(); - if (scheme == null || (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https"))) { - notifyFailure(context, "Unsupported media URI."); - ExternalDownloadMetadataStore.remove(metadataKey); - return; - } + + String scheme = mediaUri.getScheme() != null ? mediaUri.getScheme().toLowerCase(Locale.ROOT) : ""; HttpURLConnection connection = null; + DocumentFile sourceDocument = null; + File sourceFile = null; + long remoteLength = -1; + String mimeType = null; DocumentFile targetFile = null; - try { - connection = (HttpURLConnection) new URL(mediaUri.toString()).openConnection(); - connection.setConnectTimeout(CONNECT_TIMEOUT_MS); - connection.setReadTimeout(READ_TIMEOUT_MS); - connection.setRequestProperty("Accept-Encoding", "identity"); - connection.connect(); - int responseCode = connection.getResponseCode(); - if (responseCode >= HttpURLConnection.HTTP_BAD_REQUEST) { - notifyFailure(context, "Server returned " + responseCode); + try { + if (scheme.equals("http") || scheme.equals("https")) { + connection = (HttpURLConnection) new URL(mediaUri.toString()).openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setRequestProperty("Accept-Encoding", "identity"); + connection.connect(); + + int responseCode = connection.getResponseCode(); + if (responseCode >= HttpURLConnection.HTTP_BAD_REQUEST) { + notifyFailure(context, "Server returned " + responseCode); + ExternalDownloadMetadataStore.remove(metadataKey); + return; + } + + mimeType = connection.getContentType(); + remoteLength = connection.getContentLengthLong(); + } else if (scheme.equals("content")) { + sourceDocument = DocumentFile.fromSingleUri(context, mediaUri); + mimeType = context.getContentResolver().getType(mediaUri); + if (sourceDocument != null) { + remoteLength = sourceDocument.length(); + } + } else if (scheme.equals("file")) { + String path = mediaUri.getPath(); + if (path != null) { + sourceFile = new File(path); + if (sourceFile.exists()) { + remoteLength = sourceFile.length(); + } + } + String ext = MimeTypeMap.getFileExtensionFromUrl(mediaUri.toString()); + if (ext != null && !ext.isEmpty()) { + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext); + } + } else { + notifyFailure(context, "Unsupported media URI."); ExternalDownloadMetadataStore.remove(metadataKey); return; } - String mimeType = connection.getContentType(); if (mimeType == null || mimeType.isEmpty()) { mimeType = "application/octet-stream"; } String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); + if ((extension == null || extension.isEmpty()) && sourceDocument != null && sourceDocument.getName() != null) { + String name = sourceDocument.getName(); + int dot = name.lastIndexOf('.'); + if (dot >= 0 && dot < name.length() - 1) { + extension = name.substring(dot + 1); + } + } + if ((extension == null || extension.isEmpty()) && sourceFile != null) { + String name = sourceFile.getName(); + int dot = name.lastIndexOf('.'); + if (dot >= 0 && dot < name.length() - 1) { + extension = name.substring(dot + 1); + } + } if (extension == null || extension.isEmpty()) { String suffix = child.getSuffix(); if (suffix != null && !suffix.isEmpty()) { @@ -146,7 +190,6 @@ public class ExternalAudioWriter { String fileName = sanitized + "." + extension; DocumentFile existingFile = findFile(directory, fileName); - long remoteLength = connection.getContentLengthLong(); Long recordedSize = ExternalDownloadMetadataStore.getSize(metadataKey); if (existingFile != null && existingFile.exists()) { long localLength = existingFile.length(); @@ -175,7 +218,7 @@ public class ExternalAudioWriter { } Uri targetUri = targetFile.getUri(); - try (InputStream in = connection.getInputStream(); + try (InputStream in = openInputStream(context, mediaUri, scheme, connection, sourceFile); OutputStream out = context.getContentResolver().openOutputStream(targetUri)) { if (out == null) { notifyFailure(context, "Cannot open output stream."); @@ -319,4 +362,32 @@ public class ExternalAudioWriter { PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE ); } + + private static InputStream openInputStream(Context context, + Uri mediaUri, + String scheme, + HttpURLConnection connection, + File sourceFile) throws IOException { + switch (scheme) { + case "http": + case "https": + if (connection == null) { + throw new IOException("Connection not initialized"); + } + return connection.getInputStream(); + case "content": + InputStream contentStream = context.getContentResolver().openInputStream(mediaUri); + if (contentStream == null) { + throw new IOException("Cannot open content stream"); + } + return contentStream; + case "file": + if (sourceFile == null || !sourceFile.exists()) { + throw new IOException("Missing source file"); + } + return new FileInputStream(sourceFile); + default: + throw new IOException("Unsupported scheme " + scheme); + } + } } From c5ef274916fb521d52566f17c55ac24b15d7363e Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Mon, 6 Oct 2025 00:14:24 +1030 Subject: [PATCH 04/23] fix: Glide module incorrectly encoding IPv6 addresses --- .../tempo/glide/CustomGlideModule.java | 9 ++ .../tempo/glide/CustomGlideRequest.java | 2 +- .../tempo/glide/IPv6StringLoader.java | 110 ++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/cappielloantonio/tempo/glide/IPv6StringLoader.java diff --git a/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideModule.java b/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideModule.java index b2fd1a06..ccbffb21 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideModule.java +++ b/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideModule.java @@ -4,14 +4,18 @@ import android.content.Context; import androidx.annotation.NonNull; +import com.bumptech.glide.Glide; import com.bumptech.glide.GlideBuilder; import com.bumptech.glide.annotation.GlideModule; import com.bumptech.glide.load.DecodeFormat; import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory; +import com.bumptech.glide.Registry; import com.bumptech.glide.module.AppGlideModule; import com.bumptech.glide.request.RequestOptions; import com.cappielloantonio.tempo.util.Preferences; +import java.io.InputStream; + @GlideModule public class CustomGlideModule extends AppGlideModule { @Override @@ -20,4 +24,9 @@ public class CustomGlideModule extends AppGlideModule { builder.setDiskCache(new InternalCacheDiskCacheFactory(context, "cache", diskCacheSize)); builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565)); } + + @Override + public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { + registry.replace(String.class, InputStream.class, new IPv6StringLoader.Factory()); + } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java b/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java index 8e49111f..a6e650e2 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java +++ b/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java @@ -125,7 +125,7 @@ public class CustomGlideRequest { public static class Builder { private final RequestManager requestManager; - private Object item; + private String item; private Builder(Context context, String item, ResourceType type) { this.requestManager = Glide.with(context); diff --git a/app/src/main/java/com/cappielloantonio/tempo/glide/IPv6StringLoader.java b/app/src/main/java/com/cappielloantonio/tempo/glide/IPv6StringLoader.java new file mode 100644 index 00000000..85307ac9 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/glide/IPv6StringLoader.java @@ -0,0 +1,110 @@ +package com.cappielloantonio.tempo.glide; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.data.DataFetcher; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; +import com.bumptech.glide.signature.ObjectKey; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +public class IPv6StringLoader implements ModelLoader { + private static final int DEFAULT_TIMEOUT_MS = 2500; + + @Override + public boolean handles(@NonNull String model) { + return model.startsWith("http://") || model.startsWith("https://"); + } + + @Override + public LoadData buildLoadData(@NonNull String model, int width, int height, @NonNull Options options) { + if (!handles(model)) { + return null; + } + return new LoadData<>(new ObjectKey(model), new IPv6StreamFetcher(model)); + } + + private static class IPv6StreamFetcher implements DataFetcher { + private final String model; + private InputStream stream; + private HttpURLConnection connection; + + IPv6StreamFetcher(String model) { + this.model = model; + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataCallback callback) { + try { + URL url = new URL(model); + connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(DEFAULT_TIMEOUT_MS); + connection.setReadTimeout(DEFAULT_TIMEOUT_MS); + connection.setUseCaches(true); + connection.setDoInput(true); + connection.connect(); + + if (connection.getResponseCode() / 100 != 2) { + callback.onLoadFailed(new IOException("Request failed with status code: " + connection.getResponseCode())); + return; + } + + stream = connection.getInputStream(); + callback.onDataReady(stream); + } catch (IOException e) { + callback.onLoadFailed(e); + } + } + + @Override + public void cleanup() { + if (stream != null) { + try { + stream.close(); + } catch (IOException ignored) { + } + } + if (connection != null) { + connection.disconnect(); + } + } + + @Override + public void cancel() { + // HttpURLConnection does not provide a direct cancel mechanism. + } + + @NonNull + @Override + public Class getDataClass() { + return InputStream.class; + } + + @NonNull + @Override + public DataSource getDataSource() { + return DataSource.REMOTE; + } + } + + public static class Factory implements ModelLoaderFactory { + @NonNull + @Override + public ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new IPv6StringLoader(); + } + + @Override + public void teardown() { + // No-op + } + } +} \ No newline at end of file From 4cc4cc736307d561bfc3bcb40c6c4e404b29a65d Mon Sep 17 00:00:00 2001 From: skajmer <64442855+skajmer@users.noreply.github.com> Date: Thu, 9 Oct 2025 21:41:12 +0200 Subject: [PATCH 05/23] Update Polish translation Stuff from: #140 #135 #98 #152 --- app/src/main/res/values-pl/strings.xml | 30 ++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 1dc81873..5c7e8b6c 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -88,6 +88,9 @@ Wymagane wymagany jest prefiks http lub https Pobieranie + Wyłącz serce + Włącz serce + Ładowanie… Wybierz dwa lub więcej filtrów Filtry Filtruj wykonawców @@ -213,8 +216,9 @@ Anuluj Utwórz Dodaj do playlisty - Dodano piosenkę do playlisty - Nie udało się dodać piosenki do playlisty + Dodano piosenki do playlisty + Nie udało się dodać piosenek do playlisty + Pominięto wszystkie piosenki jako duplikaty %1$d utworów • %2$s Długość • %1$s Przytrzymaj aby usunąć @@ -281,6 +285,8 @@ Tempo jest otwarto-źródłowym i lekkim klientem muzycznym dla Subsonic, stworzonym i zbudowanym natywnie dla Androida. O aplikacji Always on display + Zezwalaj na dodawania duplikatów do playlist + Jeżeli włączone, duplikaty nie będą sprawdzane podczas dodawania do playlisty. Format transkodowania Jeżeli włączone, Tempo nie będzię wymuszał pobierania utworu z ustawieniami transkodowania wybranymi poniżej. Priorytetyzuj ustawienia serwera używanego do strumieniowania w pobieraniach @@ -296,6 +302,8 @@ Priorytet przy transkodowaniu utworu danego serwerowi Strategia buforowania Aby zmiany przyniosły efekt, musisz ręcznie zrestartować aplikację. + Wybierz folder dla pobranych plików muzycznych + Wyczyść folder pobierania Pozwala muzyce odtwarzać się dalej po końcu playlisty, odtwarza podobne piosenki Odtwarzanie bez przerwy Rozmiar cache dla okładek @@ -304,6 +312,9 @@ Zatwierdzenie nieodwracalnie usunie wszystkie zapisane elementy Usuń zapisane elementy Pamięć do pobierania + Utworzono folder pobierania. + Wybrano folder pobierania + Ustaw folder pobierania Zmień ustawienia audio Korektor systemowy https://github.com/eddyizm/tempo @@ -312,6 +323,7 @@ https://github.com/eddyizm/tempo/discussions Dołącz do dyskusji i wsparcia społeczności Wsparcie użytkowników + Skanowanie: naliczono %1$d utworów Rozdzielczość obrazów Język Wyloguj @@ -332,6 +344,8 @@ Timer synchronizacji Jeżeli włączone, użytkownik będzie miał możliwość zapisania kolejki i będzie miał możliwość załadowania jej stanu przy otwarciu aplikacji. Synchronizuj kolejkę odtwarzania dla tego użytkownika [Niedokończone] + Pokaż przycisk odtwarzania losowego + Jeżeli włączone, pokazuje przycisk losowego odtwarzania, i usuwa przycisk serca w mini odtwarzaczu Pokaż radio Jeżeli włączone, widoczna będzie sekcja radia. Zrestartuj aplikację aby, zmiany przyniosły pełny efekt. Automatyczne pobieranie tesktów @@ -366,6 +380,7 @@ Motyw Dane Ogólne + Playlisty Oceny Wzmocnienie głośności przy ponownym odtwarzaniu Scrobble @@ -454,6 +469,17 @@ unDraw Specjalne podziękowania dla unDraw bez którego ilustracji nie mogliśmy uczynić tej aplikacji jeszcze piękniejszą. https://undraw.co/ + Widget Tempo + Nie odtwarza + Otwórz Tempo + 0:00 + 0:00 + Okładka albumu + Play lub pauza + Następny utwór + Poprzedni utwór + Przełącznik odtwarzania losowego + Zmień tryb powtarzania %d album do zsynchronizowania %d albumów do zsynchrpnizowania From a4121e8d49babf1475365e4663cf81a4a02c0c4c Mon Sep 17 00:00:00 2001 From: eddyizm Date: Thu, 9 Oct 2025 21:53:52 -0700 Subject: [PATCH 06/23] chore: adding screenshot and docs for 4 icons/buttons in player control --- USAGE.md | 15 ++++++++++++++- mockup/usage/player_icons.png | Bin 0 -> 44475 bytes 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 mockup/usage/player_icons.png diff --git a/USAGE.md b/USAGE.md index 7cf5c25b..2aab22d0 100644 --- a/USAGE.md +++ b/USAGE.md @@ -60,7 +60,20 @@ This app works with any service that implements the Subsonic API, including: **TODO** ### Now Playing Screen -**TODO** + +On the main player control screen, tapping on the artwork will reveal a small collection of 4 buttons/icons. +

+ +

+ +*marked the icons with numbers for clarity* + +1. Downloads the track (there is a notification if the android screen but not a pop toast currently ) +2. Adds track to playlist - pops up playlist dialog. +3. Adds tracks to the queue via instant mix function +4. Saves play queue (if the feature is enabled in the settings) + * if the setting is not enabled, it toggles a view of the lyrics if available (slides to the right) + ## Navigation diff --git a/mockup/usage/player_icons.png b/mockup/usage/player_icons.png new file mode 100644 index 0000000000000000000000000000000000000000..59d6fa4ff7bcaa083b14772914d1a7034f0a960e GIT binary patch literal 44475 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 jh%@L0l*?Ez+3x=b0ze1_ Date: Fri, 10 Oct 2025 22:02:01 -0700 Subject: [PATCH 07/23] chore: updated release info --- README.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 44caa050..d882f1eb 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,16 @@ sha256 signing key fingerprint This fork is my attempt to keep development moving forward and merge in PR's that have been sitting for a while in the main repo. Thankful to @CappielloAntonio for the amazing app and hopefully we can continue to build on top of it. I will only be releasing on github and if I am not able to merge back to the main repo, I plan to rename the app to be able to publish it to fdroid and possibly google play? We will see. +### Releases + +Please note the two variants in the release assets include release/debug and 32/64 bit flavors. + +`app-tempo` <- The github release with all the android auto/chromecast features + +`app-notquitemy*` <- The f-droid release that goes without any of the google stuff. It was last released at 3.8.1 from the original repo. Since I don't have access to that original repo, I am releasing the apk's here on github. + +As mentioned above, I am working towards a rebrand to get into app stores with a new name an icon. + Moved details to [CHANGELOG.md](https://github.com/eddyizm/tempo/blob/main/CHANGELOG.md) Fork [**sponsorship here**](https://ko-fi.com/eddyizm). @@ -50,12 +60,9 @@ Fork [**sponsorship here**](https://ko-fi.com/eddyizm). - **Transcoding Support**: Activate transcoding of tracks on your Subsonic server, allowing you to set a transcoding profile for optimized streaming directly from the app. This feature requires support from your Subsonic server. - **Android Auto Support**: Enjoy your favorite music on the go with full Android Auto integration, allowing you to seamlessly control and listen to your tracks directly from your mobile device while driving. -## Sponsors +## Credits Thanks to the original repo/creator [CappielloAntonio](https://github.com/CappielloAntonio) (3.9.0) -Tempo is an open-source project developed and maintained solely by me. I would like to express my heartfelt thanks to all the users who have shown their love and support for Tempo. Your contributions and encouragement mean a lot to me, and they help drive the development and improvement of the app. - - ## Screenshot

@@ -90,6 +97,16 @@ Tempo is an open-source project developed and maintained solely by me. I would l

+## Contributing + +Please fork and open PR's against the development branch. Make sure your PR builds successfully. + +If there is a UI change, please provide a before/after screenshot and a short video/gif if that helps elaborating the fix/feature in the PR. + +Currently there are not tests but I would love to start on some unit tests. + +Not a hard requirement but any new feature/change should ideally include an update to the nacent documention. + ## License Tempo is released under the [GNU General Public License v3.0](LICENSE). Feel free to modify, distribute, and use the app in accordance with the terms of the license. Contributions to the project are also welcome. From 82c22ed247ceece47a8961e03e317a14008aaeb1 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Fri, 10 Oct 2025 22:17:09 -0700 Subject: [PATCH 08/23] chore: bumped version for release --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 43bbc967..b0e8e5d0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { minSdkVersion 24 targetSdk 35 - versionCode 34 - versionName '3.16.6' + versionCode 35 + versionName '3.17.0' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' javaCompileOptions { From fdc41b299c54a6f08d2df5be02474fe0375c669c Mon Sep 17 00:00:00 2001 From: eddyizm Date: Fri, 10 Oct 2025 22:29:41 -0700 Subject: [PATCH 09/23] chore: updated ignore file for release apk files --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1047677c..48ad5d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ .vscode/settings.json # release / debug files tempus-release-key.jks -app/tempo/ \ No newline at end of file +app/tempo/ +app/notquitemy/ From 5b6a4fab620f8155bc85130609b2106e14a0fb79 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Fri, 10 Oct 2025 22:30:55 -0700 Subject: [PATCH 10/23] chore: updated changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e4e59a..5cb58441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ***This log is for this fork to detail updates since 3.9.0 from the main repo.*** +## [3.17.0](https://github.com/eddyizm/tempo/releases/tag/v3.17.0) (2025-10-10) +## What's Changed +* chore: adding screenshot and docs for 4 icons/buttons in player control by @eddyizm in https://github.com/eddyizm/tempo/pull/162 +* Update Polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/160 +* feat: Make all objects in Tempo references for quick access by @le-firehawk in https://github.com/eddyizm/tempo/pull/158 +* fix: Glide module incorrectly encoding IPv6 addresses by @le-firehawk in https://github.com/eddyizm/tempo/pull/159 + +**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.16.6...v3.17.0 + ## [3.16.6](https://github.com/eddyizm/tempo/releases/tag/v3.16.6) (2025-10-08) ## What's Changed * chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempo/pull/151 From 8d8087f2d60ad2672e7b4a21ba802c2f961b4e65 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Sat, 11 Oct 2025 09:07:38 -0700 Subject: [PATCH 11/23] chore: fix some grammar in readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d882f1eb..e1677f42 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Tempo does not rely on magic algorithms to decide what you should listen to. Ins ## Fork sha256 signing key fingerprint -`SHA256: B7:85:01:B9:34:D0:4E:0A:CA:8D:94:AF:D6:72:6A:4D:1D:CE:65:79:7F:1D:41:71:0F:64:3C:29:00:EB:1D:1D` +`B7:85:01:B9:34:D0:4E:0A:CA:8D:94:AF:D6:72:6A:4D:1D:CE:65:79:7F:1D:41:71:0F:64:3C:29:00:EB:1D:1D` This fork is my attempt to keep development moving forward and merge in PR's that have been sitting for a while in the main repo. Thankful to @CappielloAntonio for the amazing app and hopefully we can continue to build on top of it. I will only be releasing on github and if I am not able to merge back to the main repo, I plan to rename the app to be able to publish it to fdroid and possibly google play? We will see. @@ -101,9 +101,9 @@ Thanks to the original repo/creator [CappielloAntonio](https://github.com/Cappie Please fork and open PR's against the development branch. Make sure your PR builds successfully. -If there is a UI change, please provide a before/after screenshot and a short video/gif if that helps elaborating the fix/feature in the PR. +If there is an UI change, please include a before/after screenshot and a short video/gif if that helps elaborating the fix/feature in the PR. -Currently there are not tests but I would love to start on some unit tests. +Currently there are no tests but I would love to start on some unit tests. Not a hard requirement but any new feature/change should ideally include an update to the nacent documention. From 78e7032903ab2a25190b7173c1e3ca1ca348bde8 Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Sat, 20 Sep 2025 13:54:58 +0930 Subject: [PATCH 12/23] fix: When creating MediaService, restore player from previous queue --- .../tempo/service/MediaService.kt | 34 ++++++++++++++++++- .../tempo/service/MediaService.kt | 30 ++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt index 28e6c561..870a1537 100644 --- a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -14,16 +14,19 @@ import androidx.media3.common.* import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.TrackGroupArray import androidx.media3.exoplayer.trackselection.TrackSelectionArray import androidx.media3.session.* import androidx.media3.session.MediaSession.ControllerInfo import com.cappielloantonio.tempo.R +import com.cappielloantonio.tempo.repository.QueueRepository import com.cappielloantonio.tempo.ui.activity.MainActivity import com.cappielloantonio.tempo.util.AssetLinkUtil import com.cappielloantonio.tempo.util.Constants import com.cappielloantonio.tempo.util.DownloadUtil import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory +import com.cappielloantonio.tempo.util.MappingUtil import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.ReplayGainUtil import com.cappielloantonio.tempo.widget.WidgetUpdateManager @@ -84,6 +87,7 @@ class MediaService : MediaLibraryService() { initializeCustomCommands() initializePlayer() initializeMediaLibrarySession() + restorePlayerFromQueue() initializePlayerListener() initializeEqualizerManager() @@ -226,7 +230,7 @@ class MediaService : MediaLibraryService() { private fun initializePlayer() { player = ExoPlayer.Builder(this) .setRenderersFactory(getRenderersFactory()) - .setMediaSourceFactory(DynamicMediaSourceFactory(this)) + .setMediaSourceFactory(getMediaSourceFactory()) .setAudioAttributes(AudioAttributes.DEFAULT, true) .setHandleAudioBecomingNoisy(true) .setWakeMode(C.WAKE_MODE_NETWORK) @@ -269,6 +273,33 @@ class MediaService : MediaLibraryService() { } } + private fun restorePlayerFromQueue() { + if (player.mediaItemCount > 0) return + + val queueRepository = QueueRepository() + val storedQueue = queueRepository.media + if (storedQueue.isNullOrEmpty()) return + + val mediaItems = MappingUtil.mapMediaItems(storedQueue) + if (mediaItems.isEmpty()) return + + val lastIndex = try { + queueRepository.lastPlayedMediaIndex + } catch (_: Exception) { + 0 + }.coerceIn(0, mediaItems.size - 1) + + val lastPosition = try { + queueRepository.lastPlayedMediaTimestamp + } catch (_: Exception) { + 0L + }.let { if (it < 0L) 0L else it } + + player.setMediaItems(mediaItems, lastIndex, lastPosition) + player.prepare() + updateWidget() + } + private fun initializePlayerListener() { player.addListener(object : Player.Listener { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { @@ -464,6 +495,7 @@ class MediaService : MediaLibraryService() { private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) + private fun getMediaSourceFactory(): MediaSource.Factory = DynamicMediaSourceFactory(this) } private const val WIDGET_UPDATE_INTERVAL_MS = 1000L diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt index 82675ba1..7ae707bc 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -21,11 +21,13 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession.ControllerInfo import com.cappielloantonio.tempo.repository.AutomotiveRepository +import com.cappielloantonio.tempo.repository.QueueRepository import com.cappielloantonio.tempo.ui.activity.MainActivity import com.cappielloantonio.tempo.util.AssetLinkUtil import com.cappielloantonio.tempo.util.Constants import com.cappielloantonio.tempo.util.DownloadUtil import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory +import com.cappielloantonio.tempo.util.MappingUtil import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.ReplayGainUtil import com.cappielloantonio.tempo.widget.WidgetUpdateManager @@ -73,6 +75,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { initializePlayer() initializeCastPlayer() initializeMediaLibrarySession() + restorePlayerFromQueue() initializePlayerListener() initializeEqualizerManager() @@ -166,6 +169,33 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { .build() } + private fun restorePlayerFromQueue() { + if (player.mediaItemCount > 0) return + + val queueRepository = QueueRepository() + val storedQueue = queueRepository.media + if (storedQueue.isNullOrEmpty()) return + + val mediaItems = MappingUtil.mapMediaItems(storedQueue) + if (mediaItems.isEmpty()) return + + val lastIndex = try { + queueRepository.lastPlayedMediaIndex + } catch (_: Exception) { + 0 + }.coerceIn(0, mediaItems.size - 1) + + val lastPosition = try { + queueRepository.lastPlayedMediaTimestamp + } catch (_: Exception) { + 0L + }.let { if (it < 0L) 0L else it } + + player.setMediaItems(mediaItems, lastIndex, lastPosition) + player.prepare() + updateWidget() + } + private fun createLibrarySessionCallback(): MediaLibrarySessionCallback { return MediaLibrarySessionCallback(this, automotiveRepository) } From 44679855cd2746bba7addcbb7a9ded77726bc86a Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Thu, 18 Sep 2025 18:51:06 +0930 Subject: [PATCH 13/23] fix: Replace poor syntax that created warnings during build --- .../tempo/subsonic/models/Share.kt | 22 +++++++++---------- .../tempo/service/MediaService.kt | 6 ++--- .../tempo/service/MediaService.kt | 14 +++++++++--- .../cappielloantonio/tempo/util/Flavors.java | 4 +++- 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt index 193cefc8..83332068 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt @@ -8,15 +8,15 @@ import java.util.* @Keep @Parcelize -class Share : Parcelable { +data class Share( @SerializedName("entry") - var entries: List? = null - var id: String? = null - var url: String? = null - var description: String? = null - var username: String? = null - var created: Date? = null - var expires: Date? = null - var lastVisited: Date? = null - var visitCount = 0 -} \ No newline at end of file + var entries: List? = null, + var id: String? = null, + var url: String? = null, + var description: String? = null, + var username: String? = null, + var created: Date? = null, + var expires: Date? = null, + var lastVisited: Date? = null, + var visitCount: Int = 0 +) : Parcelable \ No newline at end of file diff --git a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt index 870a1537..613650bc 100644 --- a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -123,9 +123,9 @@ class MediaService : MediaLibraryService() { val connectionResult = super.onConnect(session, controller) val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() - shuffleCommands.forEach { commandButton -> + shuffleCommands.forEach { // TODO: Aggiungere i comandi personalizzati - // commandButton.sessionCommand?.let { availableSessionCommands.add(it) } + // it.sessionCommand?.let { availableSessionCommands.add(it) } } return MediaSession.ConnectionResult.accept( @@ -430,7 +430,7 @@ class MediaService : MediaLibraryService() { .build() } - private fun ignoreFuture(customLayout: ListenableFuture) { + private fun ignoreFuture(@Suppress("UNUSED_PARAMETER") customLayout: ListenableFuture) { /* Do nothing. */ } diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt index 7ae707bc..125ef0dd 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -8,6 +8,7 @@ import android.os.Binder import android.os.IBinder import android.os.Handler import android.os.Looper +import androidx.core.content.ContextCompat import androidx.media3.cast.CastPlayer import androidx.media3.cast.SessionAvailabilityListener import androidx.media3.common.AudioAttributes @@ -73,10 +74,10 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { initializeRepository() initializePlayer() - initializeCastPlayer() initializeMediaLibrarySession() restorePlayerFromQueue() initializePlayerListener() + initializeCastPlayer() initializeEqualizerManager() setPlayer( @@ -150,8 +151,15 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { if (GoogleApiAvailability.getInstance() .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS ) { - castPlayer = CastPlayer(CastContext.getSharedInstance(this)) - castPlayer.setSessionAvailabilityListener(this) + CastContext.getSharedInstance(this, ContextCompat.getMainExecutor(this)) + .addOnSuccessListener { castContext -> + castPlayer = CastPlayer(castContext) + castPlayer.setSessionAvailabilityListener(this@MediaService) + + if (castPlayer.isCastSessionAvailable && this::mediaLibrarySession.isInitialized) { + setPlayer(player, castPlayer) + } + } } } diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/util/Flavors.java b/app/src/tempo/java/com/cappielloantonio/tempo/util/Flavors.java index 4bed2921..73c7e43b 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/util/Flavors.java +++ b/app/src/tempo/java/com/cappielloantonio/tempo/util/Flavors.java @@ -2,6 +2,8 @@ package com.cappielloantonio.tempo.util; import android.content.Context; +import androidx.core.content.ContextCompat; + import com.google.android.gms.cast.framework.CastContext; import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.GoogleApiAvailability; @@ -9,6 +11,6 @@ import com.google.android.gms.common.GoogleApiAvailability; public class Flavors { public static void initializeCastContext(Context context) { if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS) - CastContext.getSharedInstance(context); + CastContext.getSharedInstance(context, ContextCompat.getMainExecutor(context)); } } From 17372fc4d075f6581644a07feb8a19fe230f5f46 Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Fri, 3 Oct 2025 23:30:55 +0930 Subject: [PATCH 14/23] fix: Resolve parcel serialization build warnings --- .../tempo/model/Chronology.kt | 12 ++-- .../cappielloantonio/tempo/model/Download.kt | 18 +++--- .../com/cappielloantonio/tempo/model/Queue.kt | 16 +++-- .../tempo/subsonic/models/AlbumID3.kt | 60 +++++++++---------- .../subsonic/models/AlbumWithSongsID3.kt | 6 +- .../tempo/subsonic/models/Artist.kt | 14 ++--- .../tempo/subsonic/models/ArtistID3.kt | 16 ++--- .../subsonic/models/ArtistWithAlbumsID3.kt | 6 +- .../tempo/subsonic/models/Directory.kt | 20 +++---- .../tempo/subsonic/models/DiscTitle.kt | 8 +-- .../tempo/subsonic/models/Genre.kt | 10 ++-- .../subsonic/models/InternetRadioStation.kt | 12 ++-- .../tempo/subsonic/models/ItemDate.kt | 13 ++-- .../tempo/subsonic/models/ItemGenre.kt | 6 +- .../tempo/subsonic/models/MusicFolder.kt | 8 +-- .../tempo/subsonic/models/NowPlayingEntry.kt | 13 ++-- .../tempo/subsonic/models/Playlist.kt | 46 +++++++++++--- .../subsonic/models/PlaylistWithSongs.kt | 7 +-- .../tempo/subsonic/models/Playlists.kt | 2 +- .../tempo/subsonic/models/PodcastChannel.kt | 23 +++---- .../tempo/subsonic/models/PodcastEpisode.kt | 53 ++++++++-------- .../tempo/subsonic/models/RecordLabel.kt | 7 ++- .../tempo/subsonic/models/Share.kt | 3 +- .../service/MediaLibraryServiceCallback.kt | 7 +-- .../tempo/service/MediaService.kt | 1 + .../cappielloantonio/tempo/util/Flavors.java | 1 + 26 files changed, 205 insertions(+), 183 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/Chronology.kt b/app/src/main/java/com/cappielloantonio/tempo/model/Chronology.kt index 18a77bfa..a3b142e2 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/model/Chronology.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/model/Chronology.kt @@ -8,18 +8,18 @@ import androidx.room.PrimaryKey import com.cappielloantonio.tempo.subsonic.models.Child import com.cappielloantonio.tempo.util.Preferences import kotlinx.parcelize.Parcelize -import java.util.* +import java.util.Date @Keep @Parcelize @Entity(tableName = "chronology") -class Chronology(@PrimaryKey override val id: String) : Child(id) { +class Chronology( + @PrimaryKey override val id: String, @ColumnInfo(name = "timestamp") - var timestamp: Long = System.currentTimeMillis() - + var timestamp: Long = System.currentTimeMillis(), @ColumnInfo(name = "server") - var server: String? = null - + var server: String? = null, +) : Child(id) { constructor(mediaItem: MediaItem) : this(mediaItem.mediaMetadata.extras!!.getString("id")!!) { parentId = mediaItem.mediaMetadata.extras!!.getString("parentId") isDir = mediaItem.mediaMetadata.extras!!.getBoolean("isDir") diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/Download.kt b/app/src/main/java/com/cappielloantonio/tempo/model/Download.kt index e5cc34b3..0c54e1cd 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/model/Download.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/model/Download.kt @@ -10,19 +10,17 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize @Entity(tableName = "download") -class Download(@PrimaryKey override val id: String) : Child(id) { +class Download( + @PrimaryKey override val id: String, @ColumnInfo(name = "playlist_id") - var playlistId: String? = null - + var playlistId: String? = null, @ColumnInfo(name = "playlist_name") - var playlistName: String? = null - + var playlistName: String? = null, @ColumnInfo(name = "download_state", defaultValue = "1") - var downloadState: Int = 0 - + var downloadState: Int = 0, @ColumnInfo(name = "download_uri", defaultValue = "") - var downloadUri: String? = null - + var downloadUri: String? = null, +) : Child(id) { constructor(child: Child) : this(child.id) { parentId = child.parentId isDir = child.isDir @@ -62,5 +60,5 @@ class Download(@PrimaryKey override val id: String) : Child(id) { @Keep data class DownloadStack( var id: String, - var view: String? + var view: String?, ) \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/Queue.kt b/app/src/main/java/com/cappielloantonio/tempo/model/Queue.kt index ca2300c2..87840178 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/model/Queue.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/model/Queue.kt @@ -10,20 +10,18 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize @Entity(tableName = "queue") -class Queue(override val id: String) : Child(id) { +class Queue( + override val id: String, @PrimaryKey @ColumnInfo(name = "track_order") - var trackOrder: Int = 0 - + var trackOrder: Int = 0, @ColumnInfo(name = "last_play") - var lastPlay: Long = 0 - + var lastPlay: Long = 0, @ColumnInfo(name = "playing_changed") - var playingChanged: Long = 0 - + var playingChanged: Long = 0, @ColumnInfo(name = "stream_id") - var streamId: String? = null - + var streamId: String? = null, +) : Child(id) { constructor(child: Child) : this(child.id) { parentId = child.parentId isDir = child.isDir diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumID3.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumID3.kt index 3072a3a4..95c67da2 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumID3.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumID3.kt @@ -4,38 +4,36 @@ import android.os.Parcelable import androidx.annotation.Keep import com.google.gson.annotations.SerializedName import kotlinx.parcelize.Parcelize -import java.time.Instant -import java.time.LocalDate -import java.util.* +import java.util.Date @Keep @Parcelize -open class AlbumID3 : Parcelable { - var id: String? = null - var name: String? = null - var artist: String? = null - var artistId: String? = null +open class AlbumID3( + var id: String? = null, + var name: String? = null, + var artist: String? = null, + var artistId: String? = null, @SerializedName("coverArt") - var coverArtId: String? = null - var songCount: Int? = 0 - var duration: Int? = 0 - var playCount: Long? = 0 - var created: Date? = null - var starred: Date? = null - var year: Int = 0 - var genre: String? = null - var played: Date? = Date(0) - var userRating: Int? = 0 - var recordLabels: List? = null - var musicBrainzId: String? = null - var genres: List? = null - var artists: List? = null - var displayArtist: String? = null - var releaseTypes: List? = null - var moods: List? = null - var sortName: String? = null - var originalReleaseDate: ItemDate? = null - var releaseDate: ItemDate? = null - var isCompilation: Boolean? = null - var discTitles: List? = null -} \ No newline at end of file + var coverArtId: String? = null, + var songCount: Int? = 0, + var duration: Int? = 0, + var playCount: Long? = 0, + var created: Date? = null, + var starred: Date? = null, + var year: Int = 0, + var genre: String? = null, + var played: Date? = Date(0), + var userRating: Int? = 0, + var recordLabels: List? = null, + var musicBrainzId: String? = null, + var genres: List? = null, + var artists: List? = null, + var displayArtist: String? = null, + var releaseTypes: List? = null, + var moods: List? = null, + var sortName: String? = null, + var originalReleaseDate: ItemDate? = null, + var releaseDate: ItemDate? = null, + var isCompilation: Boolean? = null, + var discTitles: List? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumWithSongsID3.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumWithSongsID3.kt index 79c56092..8498e777 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumWithSongsID3.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumWithSongsID3.kt @@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -class AlbumWithSongsID3 : AlbumID3(), Parcelable { +class AlbumWithSongsID3( @SerializedName("song") - var songs: List? = null -} \ No newline at end of file + var songs: List? = null, +) : AlbumID3(), Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Artist.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Artist.kt index a13d169d..22aa527b 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Artist.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Artist.kt @@ -7,10 +7,10 @@ import java.util.Date @Keep @Parcelize -class Artist : Parcelable { - var id: String? = null - var name: String? = null - var starred: Date? = null - var userRating: Int? = null - var averageRating: Double? = null -} \ No newline at end of file +class Artist( + var id: String? = null, + var name: String? = null, + var starred: Date? = null, + var userRating: Int? = null, + var averageRating: Double? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistID3.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistID3.kt index a17f4aa3..ccf4ee7e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistID3.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistID3.kt @@ -4,15 +4,15 @@ import android.os.Parcelable import androidx.annotation.Keep import com.google.gson.annotations.SerializedName import kotlinx.parcelize.Parcelize -import java.util.* +import java.util.Date @Keep @Parcelize -open class ArtistID3 : Parcelable { - var id: String? = null - var name: String? = null +open class ArtistID3( + var id: String? = null, + var name: String? = null, @SerializedName("coverArt") - var coverArtId: String? = null - var albumCount = 0 - var starred: Date? = null -} \ No newline at end of file + var coverArtId: String? = null, + var albumCount: Int = 0, + var starred: Date? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistWithAlbumsID3.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistWithAlbumsID3.kt index c22c8207..2e21e111 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistWithAlbumsID3.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistWithAlbumsID3.kt @@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -class ArtistWithAlbumsID3 : ArtistID3(), Parcelable { +class ArtistWithAlbumsID3( @SerializedName("album") - var albums: List? = null -} \ No newline at end of file + var albums: List? = null, +) : ArtistID3(), Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Directory.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Directory.kt index 45395ede..f189589e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Directory.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Directory.kt @@ -8,15 +8,15 @@ import java.util.Date @Keep @Parcelize -class Directory : Parcelable { +class Directory( @SerializedName("child") - var children: List? = null - var id: String? = null + var children: List? = null, + var id: String? = null, @SerializedName("parent") - var parentId: String? = null - var name: String? = null - var starred: Date? = null - var userRating: Int? = null - var averageRating: Double? = null - var playCount: Long? = null -} \ No newline at end of file + var parentId: String? = null, + var name: String? = null, + var starred: Date? = null, + var userRating: Int? = null, + var averageRating: Double? = null, + var playCount: Long? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/DiscTitle.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/DiscTitle.kt index 2910d4bf..32caa8cc 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/DiscTitle.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/DiscTitle.kt @@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -open class DiscTitle : Parcelable { - var disc: Int? = null - var title: String? = null -} \ No newline at end of file +open class DiscTitle( + var disc: Int? = null, + var title: String? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Genre.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Genre.kt index cb1b7719..1db88c40 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Genre.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Genre.kt @@ -7,9 +7,9 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -class Genre : Parcelable { +class Genre( @SerializedName("value") - var genre: String? = null - var songCount = 0 - var albumCount = 0 -} \ No newline at end of file + var genre: String? = null, + var songCount: Int = 0, + var albumCount: Int = 0, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/InternetRadioStation.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/InternetRadioStation.kt index 0d312ce0..07f00c5b 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/InternetRadioStation.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/InternetRadioStation.kt @@ -6,9 +6,9 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -class InternetRadioStation : Parcelable { - var id: String? = null - var name: String? = null - var streamUrl: String? = null - var homePageUrl: String? = null -} \ No newline at end of file +class InternetRadioStation( + var id: String? = null, + var name: String? = null, + var streamUrl: String? = null, + var homePageUrl: String? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemDate.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemDate.kt index 5de2fbc6..385b7fd7 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemDate.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemDate.kt @@ -9,11 +9,11 @@ import java.util.Locale @Keep @Parcelize -open class ItemDate : Parcelable { - var year: Int? = null - var month: Int? = null - var day: Int? = null - +open class ItemDate( + var year: Int? = null, + var month: Int? = null, + var day: Int? = null, +) : Parcelable { fun getFormattedDate(): String? { if (year == null && month == null && day == null) return null @@ -22,8 +22,7 @@ open class ItemDate : Parcelable { SimpleDateFormat("yyyy", Locale.getDefault()) } else if (day == null) { SimpleDateFormat("MMMM yyyy", Locale.getDefault()) - } - else{ + } else { SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault()) } diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemGenre.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemGenre.kt index ce164fb6..971809ff 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemGenre.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemGenre.kt @@ -6,6 +6,6 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -open class ItemGenre : Parcelable { - var name: String? = null -} \ No newline at end of file +open class ItemGenre( + var name: String? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/MusicFolder.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/MusicFolder.kt index 7c277df4..e31bf7c8 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/MusicFolder.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/MusicFolder.kt @@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -class MusicFolder : Parcelable { - var id: String? = null - var name: String? = null -} \ No newline at end of file +class MusicFolder( + var id: String? = null, + var name: String? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/NowPlayingEntry.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/NowPlayingEntry.kt index e5391872..bd69808c 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/NowPlayingEntry.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/NowPlayingEntry.kt @@ -8,10 +8,9 @@ import kotlinx.parcelize.Parcelize @Parcelize class NowPlayingEntry( @SerializedName("_id") - override val id: String -) : Child(id) { - var username: String? = null - var minutesAgo = 0 - var playerId = 0 - var playerName: String? = null -} \ No newline at end of file + override val id: String, + var username: String? = null, + var minutesAgo: Int = 0, + var playerId: Int = 0, + var playerName: String? = null, +) : Child(id) \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlist.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlist.kt index 2d85271e..926c2390 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlist.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlist.kt @@ -7,8 +7,9 @@ import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize -import java.util.* +import java.util.Date @Keep @Parcelize @@ -16,27 +17,56 @@ import java.util.* open class Playlist( @PrimaryKey @ColumnInfo(name = "id") - open var id: String -) : Parcelable { + open var id: String, @ColumnInfo(name = "name") - var name: String? = null + var name: String? = null, + @ColumnInfo(name = "duration") + var duration: Long = 0, + @ColumnInfo(name = "coverArt") + var coverArtId: String? = null, +) : Parcelable { @Ignore + @IgnoredOnParcel var comment: String? = null @Ignore + @IgnoredOnParcel var owner: String? = null @Ignore + @IgnoredOnParcel @SerializedName("public") var isUniversal: Boolean? = null @Ignore + @IgnoredOnParcel var songCount: Int = 0 - @ColumnInfo(name = "duration") - var duration: Long = 0 @Ignore + @IgnoredOnParcel var created: Date? = null @Ignore + @IgnoredOnParcel var changed: Date? = null - @ColumnInfo(name = "coverArt") - var coverArtId: String? = null @Ignore + @IgnoredOnParcel var allowedUsers: List? = null + @Ignore + constructor( + id: String, + name: String?, + comment: String?, + owner: String?, + isUniversal: Boolean?, + songCount: Int, + duration: Long, + created: Date?, + changed: Date?, + coverArtId: String?, + allowedUsers: List?, + ) : this(id, name, duration, coverArtId) { + this.comment = comment + this.owner = owner + this.isUniversal = isUniversal + this.songCount = songCount + this.created = created + this.changed = changed + this.allowedUsers = allowedUsers + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PlaylistWithSongs.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PlaylistWithSongs.kt index 92e194c8..350dcbd4 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PlaylistWithSongs.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PlaylistWithSongs.kt @@ -9,8 +9,7 @@ import kotlinx.parcelize.Parcelize @Parcelize class PlaylistWithSongs( @SerializedName("_id") - override var id: String -) : Playlist(id), Parcelable { + override var id: String, @SerializedName("entry") - var entries: List? = null -} \ No newline at end of file + var entries: List? = null, +) : Playlist(id), Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlists.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlists.kt index 8aa271a4..34079c76 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlists.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlists.kt @@ -6,5 +6,5 @@ import com.google.gson.annotations.SerializedName @Keep class Playlists( @SerializedName("playlist") - var playlists: List? = null + var playlists: List? = null, ) \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastChannel.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastChannel.kt index 088ed97e..b4d124ff 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastChannel.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastChannel.kt @@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models import android.os.Parcelable import androidx.annotation.Keep import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Keep @Parcelize -class PodcastChannel : Parcelable { +class PodcastChannel( @SerializedName("episode") - var episodes: List? = null - var id: String? = null - var url: String? = null - var title: String? = null - var description: String? = null + var episodes: List? = null, + var id: String? = null, + var url: String? = null, + var title: String? = null, + var description: String? = null, @SerializedName("coverArt") - var coverArtId: String? = null - var originalImageUrl: String? = null - var status: String? = null - var errorMessage: String? = null -} \ No newline at end of file + var coverArtId: String? = null, + var originalImageUrl: String? = null, + var status: String? = null, + var errorMessage: String? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastEpisode.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastEpisode.kt index f8893224..fc3fab21 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastEpisode.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastEpisode.kt @@ -3,37 +3,38 @@ package com.cappielloantonio.tempo.subsonic.models import android.os.Parcelable import androidx.annotation.Keep import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import java.util.* @Keep @Parcelize -class PodcastEpisode : Parcelable { - var id: String? = null +class PodcastEpisode( + var id: String? = null, @SerializedName("parent") - var parentId: String? = null - var isDir = false - var title: String? = null - var album: String? = null - var artist: String? = null - var year: Int? = null - var genre: String? = null + var parentId: String? = null, + var isDir: Boolean = false, + var title: String? = null, + var album: String? = null, + var artist: String? = null, + var year: Int? = null, + var genre: String? = null, @SerializedName("coverArt") - var coverArtId: String? = null - var size: Long? = null - var contentType: String? = null - var suffix: String? = null - var duration: Int? = null + var coverArtId: String? = null, + var size: Long? = null, + var contentType: String? = null, + var suffix: String? = null, + var duration: Int? = null, @SerializedName("bitRate") - var bitrate: Int? = null - var path: String? = null - var isVideo: Boolean = false - var created: Date? = null - var artistId: String? = null - var type: String? = null - var streamId: String? = null - var channelId: String? = null - var description: String? = null - var status: String? = null - var publishDate: Date? = null -} \ No newline at end of file + var bitrate: Int? = null, + var path: String? = null, + var isVideo: Boolean = false, + var created: Date? = null, + var artistId: String? = null, + var type: String? = null, + var streamId: String? = null, + var channelId: String? = null, + var description: String? = null, + var status: String? = null, + var publishDate: Date? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/RecordLabel.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/RecordLabel.kt index 687f3fd7..52531d90 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/RecordLabel.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/RecordLabel.kt @@ -2,10 +2,11 @@ package com.cappielloantonio.tempo.subsonic.models import android.os.Parcelable import androidx.annotation.Keep +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Keep @Parcelize -open class RecordLabel : Parcelable { - var name: String? = null -} \ No newline at end of file +open class RecordLabel( + var name: String? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt index 83332068..986e4b50 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt @@ -3,8 +3,9 @@ package com.cappielloantonio.tempo.subsonic.models import android.os.Parcelable import androidx.annotation.Keep import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize -import java.util.* +import java.util.Date @Keep @Parcelize diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt index a01a2644..1bc0b15f 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt +++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt @@ -295,11 +295,6 @@ open class MediaLibrarySessionCallback( args: Bundle ): ListenableFuture { - val mediaItemId = args.getString( - MediaConstants.EXTRA_KEY_MEDIA_ID, - session.player.currentMediaItem?.mediaId - ) - when (customCommand.customAction) { CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> { session.player.shuffleModeEnabled = true @@ -398,4 +393,4 @@ open class MediaLibrarySessionCallback( ): ListenableFuture>> { return MediaBrowserTree.search(query) } -} \ No newline at end of file +} diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt index 125ef0dd..2ff81ac4 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -147,6 +147,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { player.repeatMode = Preferences.getRepeatMode() } + @Suppress("DEPRECATION") private fun initializeCastPlayer() { if (GoogleApiAvailability.getInstance() .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/util/Flavors.java b/app/src/tempo/java/com/cappielloantonio/tempo/util/Flavors.java index 73c7e43b..1ec0cd92 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/util/Flavors.java +++ b/app/src/tempo/java/com/cappielloantonio/tempo/util/Flavors.java @@ -9,6 +9,7 @@ import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.GoogleApiAvailability; public class Flavors { + @SuppressWarnings("deprecation") public static void initializeCastContext(Context context) { if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS) CastContext.getSharedInstance(context, ContextCompat.getMainExecutor(context)); From 0689272046d9854379f27dd1608f90363036d6bc Mon Sep 17 00:00:00 2001 From: eddyizm Date: Sun, 12 Oct 2025 09:56:52 -0700 Subject: [PATCH 15/23] fix: workflow trigger updated for my tagging convention --- .github/workflows/github_release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/github_release.yml b/.github/workflows/github_release.yml index 7591281f..d026f23a 100644 --- a/.github/workflows/github_release.yml +++ b/.github/workflows/github_release.yml @@ -3,7 +3,7 @@ name: Github Release Workflow on: push: tags: - - '[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+' jobs: build: From 5d3ca8acfa66bf04ddee65dbccf37d6b39b9cc1c Mon Sep 17 00:00:00 2001 From: eddyizm Date: Sun, 12 Oct 2025 09:57:35 -0700 Subject: [PATCH 16/23] chore: added multi library documentation --- README.md | 1 + USAGE.md | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e1677f42..1073595b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Fork [**sponsorship here**](https://ko-fi.com/eddyizm). - **Podcasts and Radio**: If your Subsonic server supports it, listen to podcasts and radio shows directly within Tempo, expanding your audio entertainment options. - **Transcoding Support**: Activate transcoding of tracks on your Subsonic server, allowing you to set a transcoding profile for optimized streaming directly from the app. This feature requires support from your Subsonic server. - **Android Auto Support**: Enjoy your favorite music on the go with full Android Auto integration, allowing you to seamlessly control and listen to your tracks directly from your mobile device while driving. +- **Multiple Libraries**: Tempo handles multi-library setups gracefully. They are displayed as Library folders. ## Credits Thanks to the original repo/creator [CappielloAntonio](https://github.com/CappielloAntonio) (3.9.0) diff --git a/USAGE.md b/USAGE.md index 2aab22d0..9ce9496b 100644 --- a/USAGE.md +++ b/USAGE.md @@ -57,7 +57,14 @@ This app works with any service that implements the Subsonic API, including: ## Main Features ### Library View -**TODO** + +**Multi-library** + +Tempo handles multi-library setups gracefully. They are displayed as Library folders. + +However, if you want to limit or change libraries you could use a workaround, if your server supports it. + +You can create multiple users , one for each library, and save each of them in Tempo app. ### Now Playing Screen From 2854ac6354450394db52541d686f44f1e6e1e6e0 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Sun, 12 Oct 2025 22:12:53 -0700 Subject: [PATCH 17/23] fix: persist album sort preference. --- .../ui/fragment/AlbumCatalogueFragment.java | 76 +++++++++++++++---- .../tempo/util/Preferences.kt | 12 +++ .../res/layout/fragment_album_catalogue.xml | 43 +++++++---- 3 files changed, 102 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumCatalogueFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumCatalogueFragment.java index bb62e939..4061cccd 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumCatalogueFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumCatalogueFragment.java @@ -32,17 +32,19 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback; import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter; import com.cappielloantonio.tempo.util.Constants; +import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.AlbumCatalogueViewModel; @OptIn(markerClass = UnstableApi.class) public class AlbumCatalogueFragment extends Fragment implements ClickCallback { - private static final String TAG = "ArtistCatalogueFragment"; + private static final String TAG = "AlbumCatalogueFragment"; private FragmentAlbumCatalogueBinding bind; private MainActivity activity; private AlbumCatalogueViewModel albumCatalogueViewModel; private AlbumCatalogueAdapter albumAdapter; + private String currentSortOrder; @Override public void onCreate(@Nullable Bundle savedInstanceState) { @@ -115,7 +117,10 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback { albumAdapter = new AlbumCatalogueAdapter(this, true); albumAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY); bind.albumCatalogueRecyclerView.setAdapter(albumAdapter); - albumCatalogueViewModel.getAlbumList().observe(getViewLifecycleOwner(), albums -> albumAdapter.setItems(albums)); + albumCatalogueViewModel.getAlbumList().observe(getViewLifecycleOwner(), albums -> { + albumAdapter.setItems(albums); + applySavedSortOrder(); + }); bind.albumCatalogueRecyclerView.setOnTouchListener((v, event) -> { hideKeyboard(v); @@ -137,6 +142,44 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback { }); } + private void applySavedSortOrder() { + String savedSortOrder = Preferences.getAlbumSortOrder(); + currentSortOrder = savedSortOrder; + albumAdapter.sort(savedSortOrder); + updateSortIndicator(); + } + + private void updateSortIndicator() { + if (bind == null) return; + + String sortText = getSortDisplayText(currentSortOrder); + bind.albumListSortTextView.setText(sortText); + bind.albumListSortTextView.setVisibility(View.VISIBLE); + } + + private String getSortDisplayText(String sortOrder) { + if (sortOrder == null) return ""; + + switch (sortOrder) { + case Constants.ALBUM_ORDER_BY_NAME: + return getString(R.string.menu_sort_name); + case Constants.ALBUM_ORDER_BY_ARTIST: + return getString(R.string.menu_group_by_artist); + case Constants.ALBUM_ORDER_BY_YEAR: + return getString(R.string.menu_sort_year); + case Constants.ALBUM_ORDER_BY_RANDOM: + return getString(R.string.menu_sort_random); + case Constants.ALBUM_ORDER_BY_RECENTLY_ADDED: + return getString(R.string.menu_sort_recently_added); + case Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED: + return getString(R.string.menu_sort_recently_played); + case Constants.ALBUM_ORDER_BY_MOST_PLAYED: + return getString(R.string.menu_sort_most_played); + default: + return ""; + } + } + @Override public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { inflater.inflate(R.menu.toolbar_menu, menu); @@ -172,26 +215,29 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback { popup.getMenuInflater().inflate(menuResource, popup.getMenu()); popup.setOnMenuItemClickListener(menuItem -> { + String newSortOrder = null; + if (menuItem.getItemId() == R.id.menu_album_sort_name) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_NAME); - return true; + newSortOrder = Constants.ALBUM_ORDER_BY_NAME; } else if (menuItem.getItemId() == R.id.menu_album_sort_artist) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_ARTIST); - return true; + newSortOrder = Constants.ALBUM_ORDER_BY_ARTIST; } else if (menuItem.getItemId() == R.id.menu_album_sort_year) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_YEAR); - return true; + newSortOrder = Constants.ALBUM_ORDER_BY_YEAR; } else if (menuItem.getItemId() == R.id.menu_album_sort_random) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_RANDOM); - return true; + newSortOrder = Constants.ALBUM_ORDER_BY_RANDOM; } else if (menuItem.getItemId() == R.id.menu_album_sort_recently_added) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_RECENTLY_ADDED); - return true; + newSortOrder = Constants.ALBUM_ORDER_BY_RECENTLY_ADDED; } else if (menuItem.getItemId() == R.id.menu_album_sort_recently_played) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED); - return true; + newSortOrder = Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED; } else if (menuItem.getItemId() == R.id.menu_album_sort_most_played) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_MOST_PLAYED); + newSortOrder = Constants.ALBUM_ORDER_BY_MOST_PLAYED; + } + + if (newSortOrder != null) { + currentSortOrder = newSortOrder; + albumAdapter.sort(newSortOrder); + Preferences.setAlbumSortOrder(newSortOrder); + updateSortIndicator(); return true; } diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt index 62a5e276..cd7459e8 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt @@ -76,6 +76,8 @@ object Preferences { private const val EQUALIZER_ENABLED = "equalizer_enabled" private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels" private const val MINI_SHUFFLE_BUTTON_VISIBILITY = "mini_shuffle_button_visibility" + private const val ALBUM_SORT_ORDER = "album_sort_order" + private const val DEFAULT_ALBUM_SORT_ORDER = Constants.ALBUM_ORDER_BY_NAME @JvmStatic fun getServer(): String? { @@ -638,4 +640,14 @@ object Preferences { if (parts.size < bandCount) return ShortArray(bandCount.toInt()) return ShortArray(bandCount.toInt()) { i -> parts[i].toShortOrNull() ?: 0 } } + + @JvmStatic + fun getAlbumSortOrder(): String { + return App.getInstance().preferences.getString(ALBUM_SORT_ORDER, DEFAULT_ALBUM_SORT_ORDER) ?: DEFAULT_ALBUM_SORT_ORDER + } + + @JvmStatic + fun setAlbumSortOrder(sortOrder: String) { + App.getInstance().preferences.edit().putString(ALBUM_SORT_ORDER, sortOrder).apply() + } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_album_catalogue.xml b/app/src/main/res/layout/fragment_album_catalogue.xml index c0928866..71977634 100644 --- a/app/src/main/res/layout/fragment_album_catalogue.xml +++ b/app/src/main/res/layout/fragment_album_catalogue.xml @@ -41,23 +41,40 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" /> -