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/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); + } + } } 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 ) }