From 94b02a0aac9817aee1059aabff568abe987a0123 Mon Sep 17 00:00:00 2001 From: beeetfarmer <176325048+beeetfarmer@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:28:13 +0530 Subject: [PATCH 01/48] feat: enhance artist detail page with categorized album carousels, circular similar artists, and improved top songs display --- .../subsonic/models/ArtistWithAlbumsID3.kt | 2 + .../ui/adapter/AlbumCarouselAdapter.java | 86 +++++++++ .../ui/adapter/ArtistCarouselAdapter.java | 80 ++++++++ .../ui/fragment/AlbumListPageFragment.java | 4 + .../tempo/ui/fragment/ArtistPageFragment.java | 108 ++++++++--- .../cappielloantonio/tempo/util/Constants.kt | 2 + .../viewmodel/AlbumListPageViewModel.java | 4 + .../tempo/viewmodel/ArtistPageViewModel.java | 63 +++++++ .../main/res/layout/fragment_artist_page.xml | 173 ++++++++++++++++-- .../main/res/layout/item_album_carousel.xml | 45 +++++ .../main/res/layout/item_artist_carousel.xml | 33 ++++ app/src/main/res/values/strings.xml | 5 +- app/src/main/res/values/styles.xml | 4 + 13 files changed, 568 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCarouselAdapter.java create mode 100644 app/src/main/java/com/cappielloantonio/tempo/ui/adapter/ArtistCarouselAdapter.java create mode 100644 app/src/main/res/layout/item_album_carousel.xml create mode 100644 app/src/main/res/layout/item_artist_carousel.xml 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 2e21e111..8818aeeb 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 @@ -10,4 +10,6 @@ import kotlinx.parcelize.Parcelize class ArtistWithAlbumsID3( @SerializedName("album") var albums: List? = null, + @SerializedName("appearsOn") + var appearsOn: List? = null, ) : ArtistID3(), Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCarouselAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCarouselAdapter.java new file mode 100644 index 00000000..5e06d853 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCarouselAdapter.java @@ -0,0 +1,86 @@ +package com.cappielloantonio.tempo.ui.adapter; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.cappielloantonio.tempo.databinding.ItemAlbumCarouselBinding; +import com.cappielloantonio.tempo.glide.CustomGlideRequest; +import com.cappielloantonio.tempo.interfaces.ClickCallback; +import com.cappielloantonio.tempo.subsonic.models.AlbumID3; +import com.cappielloantonio.tempo.util.Constants; + +import java.util.Collections; +import java.util.List; + +public class AlbumCarouselAdapter extends RecyclerView.Adapter { + private final ClickCallback click; + private List albums; + private boolean showArtist; + + public AlbumCarouselAdapter(ClickCallback click, boolean showArtist) { + this.click = click; + this.albums = Collections.emptyList(); + this.showArtist = showArtist; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + ItemAlbumCarouselBinding view = ItemAlbumCarouselBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + AlbumID3 album = albums.get(position); + + holder.item.albumNameLabel.setText(album.getName()); + holder.item.artistNameLabel.setText(album.getArtist()); + holder.item.artistNameLabel.setVisibility(showArtist ? View.VISIBLE : View.GONE); + + CustomGlideRequest.Builder + .from(holder.itemView.getContext(), album.getCoverArtId(), CustomGlideRequest.ResourceType.Album) + .build() + .into(holder.item.albumCoverImageView); + } + + @Override + public int getItemCount() { + return albums.size(); + } + + public void setItems(List albums) { + this.albums = albums; + notifyDataSetChanged(); + } + + public class ViewHolder extends RecyclerView.ViewHolder { + ItemAlbumCarouselBinding item; + + ViewHolder(ItemAlbumCarouselBinding item) { + super(item.getRoot()); + this.item = item; + + itemView.setOnClickListener(v -> { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.ALBUM_OBJECT, albums.get(getBindingAdapterPosition())); + click.onAlbumClick(bundle); + }); + + itemView.setOnLongClickListener(v -> { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.ALBUM_OBJECT, albums.get(getBindingAdapterPosition())); + click.onAlbumLongClick(bundle); + return true; + }); + + item.albumNameLabel.setSelected(true); + item.artistNameLabel.setSelected(true); + } + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/ArtistCarouselAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/ArtistCarouselAdapter.java new file mode 100644 index 00000000..802725ba --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/ArtistCarouselAdapter.java @@ -0,0 +1,80 @@ +package com.cappielloantonio.tempo.ui.adapter; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.cappielloantonio.tempo.databinding.ItemArtistCarouselBinding; +import com.cappielloantonio.tempo.glide.CustomGlideRequest; +import com.cappielloantonio.tempo.interfaces.ClickCallback; +import com.cappielloantonio.tempo.subsonic.models.ArtistID3; +import com.cappielloantonio.tempo.util.Constants; + +import java.util.Collections; +import java.util.List; + +public class ArtistCarouselAdapter extends RecyclerView.Adapter { + private final ClickCallback click; + private List artists; + + public ArtistCarouselAdapter(ClickCallback click) { + this.click = click; + this.artists = Collections.emptyList(); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + ItemArtistCarouselBinding view = ItemArtistCarouselBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + ArtistID3 artist = artists.get(position); + + holder.item.artistNameLabel.setText(artist.getName()); + + CustomGlideRequest.Builder + .from(holder.itemView.getContext(), artist.getCoverArtId(), CustomGlideRequest.ResourceType.Artist) + .build() + .into(holder.item.artistCoverImageView); + } + + @Override + public int getItemCount() { + return artists.size(); + } + + public void setItems(List artists) { + this.artists = artists; + notifyDataSetChanged(); + } + + public class ViewHolder extends RecyclerView.ViewHolder { + ItemArtistCarouselBinding item; + + ViewHolder(ItemArtistCarouselBinding item) { + super(item.getRoot()); + this.item = item; + + itemView.setOnClickListener(v -> { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.ARTIST_OBJECT, artists.get(getBindingAdapterPosition())); + click.onArtistClick(bundle); + }); + + itemView.setOnLongClickListener(v -> { + Bundle bundle = new Bundle(); + bundle.putParcelable(Constants.ARTIST_OBJECT, artists.get(getBindingAdapterPosition())); + click.onArtistLongClick(bundle); + return true; + }); + + item.artistNameLabel.setSelected(true); + } + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumListPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumListPageFragment.java index 38dc9b10..574825bc 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumListPageFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumListPageFragment.java @@ -93,6 +93,10 @@ private void init() { albumListPageViewModel.artist = requireArguments().getParcelable(Constants.ARTIST_OBJECT); albumListPageViewModel.title = Constants.ALBUM_FROM_ARTIST; bind.pageTitleLabel.setText(albumListPageViewModel.artist.getName()); + } else if (requireArguments().getParcelableArrayList(Constants.ALBUMS_OBJECT) != null) { + albumListPageViewModel.albums = requireArguments().getParcelableArrayList(Constants.ALBUMS_OBJECT); + albumListPageViewModel.title = requireArguments().getString(Constants.ALBUM_LIST_TITLE, ""); + bind.pageTitleLabel.setText(albumListPageViewModel.title); } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java index e995d209..63ea91e8 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java @@ -37,7 +37,8 @@ import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.ui.activity.MainActivity; -import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter; +import com.cappielloantonio.tempo.ui.adapter.AlbumCarouselAdapter; +import com.cappielloantonio.tempo.ui.adapter.ArtistCarouselAdapter; import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter; import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter; import com.cappielloantonio.tempo.util.Constants; @@ -59,8 +60,11 @@ public class ArtistPageFragment extends Fragment implements ClickCallback { private PlaybackViewModel playbackViewModel; private SongHorizontalAdapter songHorizontalAdapter; - private AlbumCatalogueAdapter albumCatalogueAdapter; - private ArtistCatalogueAdapter artistCatalogueAdapter; + private AlbumCarouselAdapter mainAlbumAdapter; + private AlbumCarouselAdapter epAdapter; + private AlbumCarouselAdapter singleAdapter; + private AlbumCarouselAdapter appearsOnAdapter; + private ArtistCarouselAdapter similarArtistAdapter; private ListenableFuture mediaBrowserListenableFuture; @@ -84,7 +88,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, initArtistInfo(); initPlayButtons(); initTopSongsView(); - initAlbumsView(); + initCategorizedAlbumsView(); initSimilarArtistsView(); return view; @@ -118,6 +122,7 @@ public void onDestroyView() { private void init(View view) { artistPageViewModel.setArtist(requireArguments().getParcelable(Constants.ARTIST_OBJECT)); + artistPageViewModel.fetchCategorizedAlbums(getViewLifecycleOwner()); bind.mostStreamedSongTextViewClickable.setOnClickListener(v -> { Bundle bundle = new Bundle(); @@ -275,40 +280,95 @@ private void initTopSongsView() { if (songs == null) { if (bind != null) bind.artistPageTopSongsSector.setVisibility(View.GONE); } else { - if (bind != null) + if (bind != null) { bind.artistPageTopSongsSector.setVisibility(!songs.isEmpty() ? View.VISIBLE : View.GONE); - songHorizontalAdapter.setItems(songs); + bind.mostStreamedSongTextViewClickable.setVisibility(songs.size() > 10 ? View.VISIBLE : View.GONE); + } + songHorizontalAdapter.setItems(songs.stream().limit(10).collect(java.util.stream.Collectors.toList())); reapplyPlayback(); } }); } - private void initAlbumsView() { - bind.albumsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount)); - bind.albumsRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false)); - bind.albumsRecyclerView.setHasFixedSize(true); + private void initCategorizedAlbumsView() { + // Main Albums + bind.mainAlbumsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); + bind.mainAlbumsRecyclerView.setHasFixedSize(true); + mainAlbumAdapter = new AlbumCarouselAdapter(this, false); + bind.mainAlbumsRecyclerView.setAdapter(mainAlbumAdapter); + artistPageViewModel.getMainAlbums().observe(getViewLifecycleOwner(), albums -> { + if (bind != null) { + bind.artistPageMainAlbumsSector.setVisibility(albums != null && !albums.isEmpty() ? View.VISIBLE : View.GONE); + if (albums != null) { + bind.mainAlbumsSeeAllTextView.setVisibility(albums.size() > 5 ? View.VISIBLE : View.GONE); + mainAlbumAdapter.setItems(albums); + bind.mainAlbumsSeeAllTextView.setOnClickListener(v -> navigateToAlbumList(getString(R.string.artist_page_title_album_section), albums)); + } + } + }); + + // EPs + bind.epsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); + bind.epsRecyclerView.setHasFixedSize(true); + epAdapter = new AlbumCarouselAdapter(this, false); + bind.epsRecyclerView.setAdapter(epAdapter); + artistPageViewModel.getEPs().observe(getViewLifecycleOwner(), albums -> { + if (bind != null) { + bind.artistPageEpsSector.setVisibility(albums != null && !albums.isEmpty() ? View.VISIBLE : View.GONE); + if (albums != null) { + bind.epsSeeAllTextView.setVisibility(albums.size() > 5 ? View.VISIBLE : View.GONE); + epAdapter.setItems(albums); + bind.epsSeeAllTextView.setOnClickListener(v -> navigateToAlbumList(getString(R.string.artist_page_title_ep_section), albums)); + } + } + }); - albumCatalogueAdapter = new AlbumCatalogueAdapter(this, false); - bind.albumsRecyclerView.setAdapter(albumCatalogueAdapter); + // Singles + bind.singlesRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); + bind.singlesRecyclerView.setHasFixedSize(true); + singleAdapter = new AlbumCarouselAdapter(this, false); + bind.singlesRecyclerView.setAdapter(singleAdapter); + artistPageViewModel.getSingles().observe(getViewLifecycleOwner(), albums -> { + if (bind != null) { + bind.artistPageSinglesSector.setVisibility(albums != null && !albums.isEmpty() ? View.VISIBLE : View.GONE); + if (albums != null) { + bind.singlesSeeAllTextView.setVisibility(albums.size() > 5 ? View.VISIBLE : View.GONE); + singleAdapter.setItems(albums); + bind.singlesSeeAllTextView.setOnClickListener(v -> navigateToAlbumList(getString(R.string.artist_page_title_single_section), albums)); + } + } + }); - artistPageViewModel.getAlbumList().observe(getViewLifecycleOwner(), albums -> { - if (albums == null) { - if (bind != null) bind.artistPageAlbumsSector.setVisibility(View.GONE); - } else { - if (bind != null) - bind.artistPageAlbumsSector.setVisibility(!albums.isEmpty() ? View.VISIBLE : View.GONE); - albumCatalogueAdapter.setItems(albums); + // Appears On + bind.appearsOnRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); + bind.appearsOnRecyclerView.setHasFixedSize(true); + appearsOnAdapter = new AlbumCarouselAdapter(this, true); // Show artist name for Appears On + bind.appearsOnRecyclerView.setAdapter(appearsOnAdapter); + artistPageViewModel.getAppearsOn().observe(getViewLifecycleOwner(), albums -> { + if (bind != null) { + bind.artistPageAppearsOnSector.setVisibility(albums != null && !albums.isEmpty() ? View.VISIBLE : View.GONE); + if (albums != null) { + bind.appearsOnSeeAllTextView.setVisibility(albums.size() > 5 ? View.VISIBLE : View.GONE); + appearsOnAdapter.setItems(albums); + bind.appearsOnSeeAllTextView.setOnClickListener(v -> navigateToAlbumList(getString(R.string.artist_page_title_appears_on_section), albums)); + } } }); } + private void navigateToAlbumList(String title, List albums) { + Bundle bundle = new Bundle(); + bundle.putString(Constants.ALBUM_LIST_TITLE, title); + bundle.putParcelableArrayList(Constants.ALBUMS_OBJECT, new ArrayList<>(albums)); + Navigation.findNavController(requireView()).navigate(R.id.albumListPageFragment, bundle); + } + private void initSimilarArtistsView() { - bind.similarArtistsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount)); - bind.similarArtistsRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false)); + bind.similarArtistsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); bind.similarArtistsRecyclerView.setHasFixedSize(true); - artistCatalogueAdapter = new ArtistCatalogueAdapter(this); - bind.similarArtistsRecyclerView.setAdapter(artistCatalogueAdapter); + similarArtistAdapter = new ArtistCarouselAdapter(this); + bind.similarArtistsRecyclerView.setAdapter(similarArtistAdapter); artistPageViewModel.getArtistInfo(artistPageViewModel.getArtist().getId()).observe(getViewLifecycleOwner(), artist -> { if (artist == null) { @@ -323,7 +383,7 @@ private void initSimilarArtistsView() { artists.addAll(artist.getSimilarArtists()); } - artistCatalogueAdapter.setItems(artists); + similarArtistAdapter.setItems(artists); } }); diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt index 7d2224ed..baa8dbf6 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt @@ -18,6 +18,8 @@ object Constants { const val MUSIC_DIRECTORY_OBJECT = "MUSIC_DIRECTORY_OBJECT" const val MUSIC_INDEX_OBJECT = "MUSIC_DIRECTORY_OBJECT" const val MUSIC_DIRECTORY_ID = "MUSIC_DIRECTORY_ID" + const val ALBUMS_OBJECT = "ALBUMS_OBJECT" + const val ALBUM_LIST_TITLE = "ALBUM_LIST_TITLE" const val ALBUM_RECENTLY_PLAYED = "ALBUM_RECENTLY_PLAYED" const val ALBUM_MOST_PLAYED = "ALBUM_MOST_PLAYED" diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumListPageViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumListPageViewModel.java index 8e88cec8..4d2c7c32 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumListPageViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/AlbumListPageViewModel.java @@ -23,6 +23,7 @@ public class AlbumListPageViewModel extends AndroidViewModel { public String title; public ArtistID3 artist; + public List albums; private MutableLiveData> albumList; @@ -34,6 +35,9 @@ public AlbumListPageViewModel(@NonNull Application application) { } public LiveData> getAlbumList(LifecycleOwner owner) { + if (albums != null) { + return new MutableLiveData<>(albums); + } albumList = new MutableLiveData<>(new ArrayList<>()); switch (title) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistPageViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistPageViewModel.java index f2b7d9e6..affb6cc7 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistPageViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistPageViewModel.java @@ -8,6 +8,7 @@ import androidx.annotation.OptIn; import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; import androidx.media3.common.util.UnstableApi; import com.cappielloantonio.tempo.model.Download; @@ -24,6 +25,7 @@ import com.cappielloantonio.tempo.util.NetworkUtil; import com.cappielloantonio.tempo.util.Preferences; +import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.stream.Collectors; @@ -35,6 +37,11 @@ public class ArtistPageViewModel extends AndroidViewModel { private ArtistID3 artist; + private final MutableLiveData> singles = new MutableLiveData<>(); + private final MutableLiveData> eps = new MutableLiveData<>(); + private final MutableLiveData> mainAlbums = new MutableLiveData<>(); + private final MutableLiveData> appearsOn = new MutableLiveData<>(); + public ArtistPageViewModel(@NonNull Application application) { super(application); @@ -43,6 +50,62 @@ public ArtistPageViewModel(@NonNull Application application) { favoriteRepository = new FavoriteRepository(); } + public void fetchCategorizedAlbums(androidx.lifecycle.LifecycleOwner owner) { + artistRepository.getArtist(artist.getId()).observe(owner, artistWithAlbums -> { + if (artistWithAlbums != null && artistWithAlbums instanceof com.cappielloantonio.tempo.subsonic.models.ArtistWithAlbumsID3) { + com.cappielloantonio.tempo.subsonic.models.ArtistWithAlbumsID3 fullArtist = (com.cappielloantonio.tempo.subsonic.models.ArtistWithAlbumsID3) artistWithAlbums; + + List allAlbums = fullArtist.getAlbums(); + if (allAlbums != null) { + allAlbums.sort(Comparator.comparing(AlbumID3::getYear).reversed()); + + mainAlbums.setValue(allAlbums.stream() + .filter(a -> isType(a, "album")) + .collect(Collectors.toList())); + + singles.setValue(allAlbums.stream() + .filter(a -> isType(a, "single")) + .collect(Collectors.toList())); + + eps.setValue(allAlbums.stream() + .filter(a -> isType(a, "ep")) + .collect(Collectors.toList())); + } + + List appearsOnList = fullArtist.getAppearsOn(); + if (appearsOnList != null) { + appearsOnList.sort(Comparator.comparing(AlbumID3::getYear).reversed()); + appearsOn.setValue(appearsOnList); + } else { + appearsOn.setValue(new java.util.ArrayList<>()); + } + } + }); + } + + private boolean isType(AlbumID3 album, String targetType) { + if (album.getReleaseTypes() != null && !album.getReleaseTypes().isEmpty()) { + return album.getReleaseTypes().contains(targetType); + } + // Fallback to song count if releaseTypes is not available + int songCount = album.getSongCount() != null ? album.getSongCount() : 0; + switch (targetType) { + case "single": + return songCount >= 1 && songCount <= 2; + case "ep": + return songCount >= 3 && songCount <= 7; + case "album": + return songCount >= 8; + default: + return false; + } + } + + public LiveData> getSingles() { return singles; } + public LiveData> getEPs() { return eps; } + public LiveData> getMainAlbums() { return mainAlbums; } + public LiveData> getAppearsOn() { return appearsOn; } + public LiveData> getAlbumList() { return albumRepository.getArtistAlbums(artist.getId()); } diff --git a/app/src/main/res/layout/fragment_artist_page.xml b/app/src/main/res/layout/fragment_artist_page.xml index 6712dd2e..b2ebc4fe 100644 --- a/app/src/main/res/layout/fragment_artist_page.xml +++ b/app/src/main/res/layout/fragment_artist_page.xml @@ -54,7 +54,7 @@ android:clipToPadding="false" android:orientation="vertical" android:paddingTop="18dp" - android:paddingBottom="@dimen/global_padding_bottom"> + android:paddingBottom="120dp"> - + + + + + + + + android:paddingEnd="16dp" /> + + + + + + + + + + + android:paddingEnd="16dp" /> + + + + + + + + + + + + + + + + + + + + + + + + + android:paddingEnd="16dp" /> diff --git a/app/src/main/res/layout/item_album_carousel.xml b/app/src/main/res/layout/item_album_carousel.xml new file mode 100644 index 00000000..3f202989 --- /dev/null +++ b/app/src/main/res/layout/item_album_carousel.xml @@ -0,0 +1,45 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_artist_carousel.xml b/app/src/main/res/layout/item_artist_carousel.xml new file mode 100644 index 00000000..336ede17 --- /dev/null +++ b/app/src/main/res/layout/item_artist_carousel.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 88c11fb7..774a4ac2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -45,9 +45,12 @@ Switch layout More like this Albums + EPs + Singles + Appears On More Biography - Most Streamed Songs + Top Songs See all Ignore Don\'t ask again diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index aa08c151..b598c254 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -68,4 +68,8 @@ ?attr/colorErrorContainer ?attr/colorOnErrorContainer + + \ No newline at end of file From 0ee7e2e66b4728e336917cb3b57e28da791ed2e0 Mon Sep 17 00:00:00 2001 From: beeetfarmer <176325048+beeetfarmer@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:36:12 +0530 Subject: [PATCH 02/48] chore: commit local changes before merging main --- CHANGELOG.md | 19 +- app/build.gradle | 4 +- app/src/main/AndroidManifest.xml | 2 +- .../java/com/cappielloantonio/tempo/App.java | 42 +++ .../provider/AlbumArtContentProvider.java | 3 +- .../repository/AutomotiveRepository.java | 15 +- .../tempo/repository/SharingRepository.java | 4 +- .../tempo/service/BaseMediaService.kt | 266 ++++++++++++++++++ .../tempo/subsonic/utils/CacheUtil.java | 3 +- .../ui/dialog/PlaylistChooserDialog.java | 55 ++-- .../tempo/ui/dialog/TrackInfoDialog.java | 69 ++++- .../fragment/PlayerBottomSheetFragment.java | 58 +++- .../ui/fragment/PlayerControllerFragment.java | 103 +++++-- .../tempo/util/DownloadUtil.java | 23 ++ .../tempo/util/DynamicMediaSourceFactory.kt | 14 +- .../tempo/util/MappingUtil.java | 21 +- .../tempo/util/MusicUtil.java | 25 +- .../tempo/util/TranscodingMediaSource.kt | 8 +- .../main/res/layout-land/activity_main.xml | 11 +- ...nner_fragment_player_controller_layout.xml | 32 +-- .../res/layout/dialog_playlist_chooser.xml | 51 ++++ app/src/main/res/layout/dialog_track_info.xml | 27 ++ .../main/res/menu-land/bottom_nav_menu.xml | 13 +- app/src/main/res/menu/bottom_nav_menu.xml | 1 + app/src/main/res/values-ca/strings.xml | 4 +- app/src/main/res/values-de/strings.xml | 4 +- app/src/main/res/values-es-rES/strings.xml | 12 +- app/src/main/res/values-fr/strings.xml | 4 +- app/src/main/res/values-it/strings.xml | 4 +- app/src/main/res/values-ko/strings.xml | 4 +- app/src/main/res/values-pl/strings.xml | 4 +- app/src/main/res/values-pt/strings.xml | 4 +- app/src/main/res/values-ro/strings.xml | 4 +- app/src/main/res/values-ru/strings.xml | 4 +- app/src/main/res/values-tr/strings.xml | 4 +- app/src/main/res/values-zh/strings.xml | 4 +- app/src/main/res/values/strings.xml | 7 +- .../service/MediaLibraryServiceCallback.kt | 35 ++- .../metadata/android/en-US/changelogs/20.txt | 10 + 39 files changed, 806 insertions(+), 171 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/20.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f13cbf5..f48c4d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,23 @@ # Changelog -## Pending release +## What's Changed +## [4.11.0](https://github.com/eddyizm/tempo/releases/tag/v4.11.0) (2026-02-15) +* fix: added dynamic application id from gradle variant by @eddyizm in https://github.com/eddyizm/tempus/pull/425 +* fix: Use Bluetooth tethering connection by @jaime-grj in https://github.com/eddyizm/tempus/pull/428 +* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/427 +* fix: visual glitches on landscape navbar by @tvillega in https://github.com/eddyizm/tempus/pull/429 +* fix: radio playback "source error" on android auto by @dmachard in https://github.com/eddyizm/tempus/pull/426 +* fix: speed button overlaps with shuffle on landscape by @tvillega in https://github.com/eddyizm/tempus/pull/430 +* fix: local url used in share link instead of server url by @tvillega in https://github.com/eddyizm/tempus/pull/431 +* Feat :prefer downloaded files by @eddyizm in https://github.com/eddyizm/tempus/pull/433 +* fix: radio metadata displayed by @TrackArcher in https://github.com/eddyizm/tempus/pull/352 +* feat: improve playlist chooser dialog UI by @tvillega in https://github.com/eddyizm/tempus/pull/439 + +## New Contributors +* @dmachard made their first contribution in https://github.com/eddyizm/tempus/pull/426 +* @TrackArcher made their first contribution in https://github.com/eddyizm/tempus/pull/352 + +**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.10.1...v4.11.0 ## What's Changed ## [4.10.1](https://github.com/eddyizm/tempo/releases/tag/v4.10.1) (2026-02-08) diff --git a/app/build.gradle b/app/build.gradle index 99691cf3..f8a8b106 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { minSdkVersion 24 targetSdk 35 - versionCode 19 - versionName '4.10.1' + versionCode 20 + versionName '4.11.0' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' javaCompileOptions { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b7a386b7..7a6c915b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -98,7 +98,7 @@ diff --git a/app/src/main/java/com/cappielloantonio/tempo/App.java b/app/src/main/java/com/cappielloantonio/tempo/App.java index 40105eea..b6774670 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/App.java +++ b/app/src/main/java/com/cappielloantonio/tempo/App.java @@ -55,6 +55,48 @@ public static Subsonic getSubsonicClientInstance(boolean override) { } return subsonic; } + + public static Subsonic getSubsonicPublicClientInstance(boolean override) { + + /* + If I do the shortcut that the IDE suggests: + SubsonicPreferences preferences = getSubsonicPreferences1(); + During the chain of calls it will run the following: + String server = Preferences.getInUseServerAddress(); + Which could return Local URL, causing issues like generating public shares with Local URL + + To prevent this I just replicated the entire chain of functions here, + if you need a call to Subsonic using the Server (Public) URL use this function. + */ + + String server = Preferences.getServer(); + String username = Preferences.getUser(); + String password = Preferences.getPassword(); + String token = Preferences.getToken(); + String salt = Preferences.getSalt(); + boolean isLowSecurity = Preferences.isLowScurity(); + + SubsonicPreferences preferences = new SubsonicPreferences(); + preferences.setServerUrl(server); + preferences.setUsername(username); + preferences.setAuthentication(password, token, salt, isLowSecurity); + + if (subsonic == null || override) { + + if (preferences.getAuthentication() != null) { + if (preferences.getAuthentication().getPassword() != null) + Preferences.setPassword(preferences.getAuthentication().getPassword()); + if (preferences.getAuthentication().getToken() != null) + Preferences.setToken(preferences.getAuthentication().getToken()); + if (preferences.getAuthentication().getSalt() != null) + Preferences.setSalt(preferences.getAuthentication().getSalt()); + } + + + } + + return new Subsonic(preferences); + } public static Github getGithubClientInstance() { if (github == null) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/provider/AlbumArtContentProvider.java b/app/src/main/java/com/cappielloantonio/tempo/provider/AlbumArtContentProvider.java index cace2db9..9095e4f6 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/provider/AlbumArtContentProvider.java +++ b/app/src/main/java/com/cappielloantonio/tempo/provider/AlbumArtContentProvider.java @@ -14,6 +14,7 @@ import com.bumptech.glide.Glide; import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.cappielloantonio.tempo.BuildConfig; import com.cappielloantonio.tempo.glide.CustomGlideRequest; import com.cappielloantonio.tempo.util.Preferences; @@ -28,7 +29,7 @@ import java.util.concurrent.TimeUnit; public class AlbumArtContentProvider extends ContentProvider { - public static final String AUTHORITY = "com.cappielloantonio.tempo.provider"; + public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".albumart.provider"; public static final String ALBUM_ART = "albumArt"; private ExecutorService executor; diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/AutomotiveRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/AutomotiveRepository.java index b509360e..8fd49fc7 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/AutomotiveRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/AutomotiveRepository.java @@ -606,20 +606,7 @@ public void onResponse(@NonNull Call call, @NonNull Response mediaItems = new ArrayList<>(); for (InternetRadioStation radioStation : radioStations) { - MediaMetadata mediaMetadata = new MediaMetadata.Builder() - .setTitle(radioStation.getName()) - .setIsBrowsable(false) - .setIsPlayable(true) - .setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION) - .build(); - - MediaItem mediaItem = new MediaItem.Builder() - .setMediaId(radioStation.getId()) - .setMediaMetadata(mediaMetadata) - .setUri(radioStation.getStreamUrl()) - .build(); - - mediaItems.add(mediaItem); + mediaItems.add(MappingUtil.mapInternetRadioStation(radioStation)); } setInternetRadioStationsMetadata(radioStations); diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/SharingRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/SharingRepository.java index 9c531831..9932b418 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/SharingRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/SharingRepository.java @@ -41,7 +41,7 @@ public void onFailure(@NonNull Call call, @NonNull Throwable t) { public MutableLiveData createShare(String id, String description, Long expires) { MutableLiveData share = new MutableLiveData<>(); - App.getSubsonicClientInstance(false) + App.getSubsonicPublicClientInstance(false) .getSharingClient() .createShare(id, description, expires) .enqueue(new Callback() { @@ -64,7 +64,7 @@ public void onFailure(@NonNull Call call, @NonNull Throwable t) { } public void updateShare(String id, String description, Long expires) { - App.getSubsonicClientInstance(false) + App.getSubsonicPublicClientInstance(false) .getSharingClient() .updateShare(id, description, expires) .enqueue(new Callback() { diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt b/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt index b21bf75d..653507ee 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt @@ -24,6 +24,9 @@ import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder import androidx.media3.session.* import androidx.media3.session.MediaSession.ControllerInfo +import androidx.media3.extractor.metadata.icy.IcyInfo +import androidx.media3.extractor.metadata.id3.TextInformationFrame +import androidx.media3.extractor.metadata.vorbis.VorbisComment import com.cappielloantonio.tempo.R import com.cappielloantonio.tempo.repository.QueueRepository import com.cappielloantonio.tempo.ui.activity.MainActivity @@ -32,6 +35,12 @@ import com.cappielloantonio.tempo.widget.WidgetUpdateManager import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit private const val TAG = "BaseMediaService" @@ -70,6 +79,13 @@ open class BaseMediaService : MediaLibraryService() { } } + private val radioHeaderCheckExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() + private var radioHeaderCheckScheduled = false + private var radioHeaderCheckFuture: ScheduledFuture<*>? = null + private val radioHeaderCheckRunnable = Runnable { + checkRadioHttpHeaders() + } + private val binder = LocalBinder() open fun playerInitHook() { @@ -120,6 +136,9 @@ open class BaseMediaService : MediaLibraryService() { updateWidget(player) } + private var lastRadioArtist: String? = null + private var lastRadioTitle: String? = null + fun initializePlayerListener(player: Player) { player.addListener(object : Player.Listener { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { @@ -129,6 +148,16 @@ open class BaseMediaService : MediaLibraryService() { if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { MediaManager.setLastPlayedTimestamp(mediaItem) } + + // Restart header checks for radio streams when media item changes + val mediaType = mediaItem.mediaMetadata.extras?.getString("type") + if (mediaType == Constants.MEDIA_TYPE_RADIO && player.isPlaying) { + stopRadioHeaderChecks() + scheduleRadioHeaderChecks() + } else if (mediaType != Constants.MEDIA_TYPE_RADIO) { + stopRadioHeaderChecks() + } + updateWidget(player) } @@ -170,6 +199,96 @@ open class BaseMediaService : MediaLibraryService() { } } + override fun onMetadata(metadata: Metadata) { + // Handle streaming metadata (ICY, ID3) for radio / streaming content + val currentItem = player.currentMediaItem ?: return + val extras = currentItem.mediaMetadata.extras + if (extras?.getString("type") != Constants.MEDIA_TYPE_RADIO) return + + var artist: String? = null + var title: String? = null + + // Extract metadata from ICY/ID3/Vorbis + for (i in 0 until metadata.length()) { + when (val entry = metadata[i]) { + is IcyInfo -> { + entry.title?.let { icyTitle -> + val parts = icyTitle.split(" - ", limit = 2) + if (parts.size == 2) { + artist = parts[0].trim().ifEmpty { null } + title = parts[1].trim().ifEmpty { null } + } else { + title = icyTitle.trim().ifEmpty { null } + } + } + } + is TextInformationFrame -> { + @Suppress("DEPRECATION") + val value = entry.value + when (entry.id) { + "TPE1" -> if (!value.isNullOrBlank()) artist = value + "TIT2" -> if (!value.isNullOrBlank()) title = value + } + } + is VorbisComment -> { + @Suppress("DEPRECATION") + val value = entry.value + when (entry.key) { + "ARTIST" -> if (!value.isNullOrBlank()) artist = value + "TITLE" -> if (!value.isNullOrBlank()) title = value + } + } + } + } + + if (artist.isNullOrBlank() && title.isNullOrBlank()) return + if (artist == lastRadioArtist && title == lastRadioTitle) return // Deduplicate + + lastRadioArtist = artist + lastRadioTitle = title + + // Stop HTTP header checks since we have embedded metadata + stopRadioHeaderChecks() + + val currentIndex = player.currentMediaItemIndex + if (currentIndex == C.INDEX_UNSET) return + + val metadataBuilder = currentItem.mediaMetadata.buildUpon() + val newExtras = Bundle(extras ?: Bundle()) + + // Store individual values in extras for UI + artist?.let { newExtras.putString("radioArtist", it) } + title?.let { newExtras.putString("radioTitle", it) } + + // Get station name (preserve if already set) + val stationName = extras?.getString("stationName") + ?: currentItem.mediaMetadata.title?.toString() + ?: "" + if (stationName.isNotBlank()) { + newExtras.putString("stationName", stationName) + } + + // Format for notification/player: Title = "Artist - Song", Artist = "Station Name" + val formattedTitle = when { + !artist.isNullOrBlank() && !title.isNullOrBlank() -> "$artist - $title" + !title.isNullOrBlank() -> title + !artist.isNullOrBlank() -> artist + else -> stationName + } + + metadataBuilder.setTitle(formattedTitle) + if (stationName.isNotBlank()) { + metadataBuilder.setArtist(stationName) + } + + (player as? ExoPlayer)?.let { exo -> + exo.replaceMediaItem(currentIndex, currentItem.buildUpon() + .setMediaMetadata(metadataBuilder.setExtras(newExtras).build()) + .build()) + updateWidget(exo) + } + } + override fun onIsPlayingChanged(isPlaying: Boolean) { Log.d(TAG, "onIsPlayingChanged " + player.currentMediaItemIndex) if (!isPlaying) { @@ -182,8 +301,10 @@ open class BaseMediaService : MediaLibraryService() { } if (isPlaying) { scheduleWidgetUpdates() + scheduleRadioHeaderChecks() } else { stopWidgetUpdates() + stopRadioHeaderChecks() } updateWidget(player) } @@ -287,6 +408,8 @@ open class BaseMediaService : MediaLibraryService() { releaseNetworkCallback() equalizerManager.release() stopWidgetUpdates() + stopRadioHeaderChecks() + radioHeaderCheckExecutor.shutdown() releasePlayers() mediaLibrarySession.release() super.onDestroy() @@ -405,6 +528,148 @@ open class BaseMediaService : MediaLibraryService() { widgetUpdateScheduled = false } + private fun scheduleRadioHeaderChecks() { + val player = mediaLibrarySession.player + val currentItem = player.currentMediaItem ?: return + val mediaType = currentItem.mediaMetadata.extras?.getString("type") + if (mediaType != Constants.MEDIA_TYPE_RADIO) return + + if (radioHeaderCheckScheduled) return + + // Check immediately, then periodically + checkRadioHttpHeaders() + radioHeaderCheckFuture = radioHeaderCheckExecutor.scheduleWithFixedDelay( + radioHeaderCheckRunnable, + RADIO_HEADER_CHECK_INTERVAL_SECONDS, + RADIO_HEADER_CHECK_INTERVAL_SECONDS, + TimeUnit.SECONDS + ) + radioHeaderCheckScheduled = true + } + + private fun stopRadioHeaderChecks() { + if (!radioHeaderCheckScheduled) return + radioHeaderCheckFuture?.cancel(false) + radioHeaderCheckFuture = null + radioHeaderCheckScheduled = false + } + + private fun checkRadioHttpHeaders() { + val player = mediaLibrarySession.player + val currentItem = player.currentMediaItem ?: return + val extras = currentItem.mediaMetadata.extras + val mediaType = extras?.getString("type") + if (mediaType != Constants.MEDIA_TYPE_RADIO) return + + // Skip if we already have embedded metadata (ICY/ID3) - HTTP headers are only fallback + val hasEmbeddedMetadata = !currentItem.mediaMetadata.artist.isNullOrBlank() || + !currentItem.mediaMetadata.title.isNullOrBlank() || + (extras != null && !extras.getString("radioArtist").isNullOrBlank()) || + (extras != null && !extras.getString("radioTitle").isNullOrBlank()) + if (hasEmbeddedMetadata) return + + val streamUrl = extras?.getString("uri") ?: currentItem.requestMetadata.mediaUri?.toString() + if (streamUrl.isNullOrBlank()) return + + try { + val url = URL(streamUrl) + val connection = url.openConnection() as? HttpURLConnection ?: return + + // Only try HEAD request (lightweight) - skip GET fallback as it's unreliable + connection.requestMethod = "HEAD" + connection.setRequestProperty("Icy-MetaData", "1") + connection.setRequestProperty("User-Agent", "Tempus/1.0") + connection.connectTimeout = 3000 // Reduced timeout + connection.readTimeout = 3000 + + connection.connect() + + if (connection.responseCode >= 400) { + connection.disconnect() + return + } + + // Check for metadata in HTTP headers + val streamTitle = connection.getHeaderField("icy-name") + ?: connection.getHeaderField("StreamTitle") + ?: connection.getHeaderField("stream-title") + + connection.disconnect() + + if (!streamTitle.isNullOrBlank()) { + processStreamTitle(streamTitle, player) + } + } catch (e: Exception) { + // Silently fail - this is a fallback mechanism, ICY metadata is primary + } + } + + private fun processStreamTitle(streamTitle: String, player: Player) { + // Parse "Artist - Title" format + val parts = streamTitle.split(" - ", limit = 2) + val artist = if (parts.size == 2) parts[0].trim().ifEmpty { null } else null + val title = if (parts.size == 2) parts[1].trim().ifEmpty { null } else streamTitle.trim().ifEmpty { null } + + if (artist.isNullOrBlank() && title.isNullOrBlank()) return + if (artist == lastRadioArtist && title == lastRadioTitle) return // Deduplicate + + lastRadioArtist = artist + lastRadioTitle = title + + // Update on main thread + widgetUpdateHandler.post { + val currentItemNow = player.currentMediaItem ?: return@post + val currentIndex = player.currentMediaItemIndex + if (currentIndex == C.INDEX_UNSET) return@post + + val currentExtras = currentItemNow.mediaMetadata.extras + if (currentExtras?.getString("type") != Constants.MEDIA_TYPE_RADIO) return@post + + // Double-check we still don't have embedded metadata (might have arrived since check) + val hasEmbeddedMetadata = !currentItemNow.mediaMetadata.artist.isNullOrBlank() || + !currentItemNow.mediaMetadata.title.isNullOrBlank() || + (currentExtras != null && !currentExtras.getString("radioArtist").isNullOrBlank()) || + (currentExtras != null && !currentExtras.getString("radioTitle").isNullOrBlank()) + if (hasEmbeddedMetadata) return@post + + val metadataBuilder = currentItemNow.mediaMetadata.buildUpon() + val newExtras = Bundle(currentExtras ?: Bundle()) + + // Store individual values in extras for UI + artist?.let { newExtras.putString("radioArtist", it) } + title?.let { newExtras.putString("radioTitle", it) } + + // Get station name (preserve if already set) + val stationName = currentExtras?.getString("stationName") + ?: currentItemNow.mediaMetadata.title?.toString() + ?: "" + if (stationName.isNotBlank()) { + newExtras.putString("stationName", stationName) + } + + // Format for notification/player: Title = "Artist - Song", Artist = "Station Name" + val formattedTitle = when { + !artist.isNullOrBlank() && !title.isNullOrBlank() -> "$artist - $title" + !title.isNullOrBlank() -> title + !artist.isNullOrBlank() -> artist + else -> stationName + } + + metadataBuilder.setTitle(formattedTitle) + if (stationName.isNotBlank()) { + metadataBuilder.setArtist(stationName) + } + metadataBuilder.setExtras(newExtras) + + (player as? ExoPlayer)?.let { exo -> + exo.replaceMediaItem(currentIndex, currentItemNow.buildUpon() + .setMediaMetadata(metadataBuilder.build()) + .build()) + updateWidget(exo) + } + } + } + private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean { if (audioSessionId == 0 || audioSessionId == -1) return false val attached = equalizerManager.attachToSession(audioSessionId) @@ -595,4 +860,5 @@ open class BaseMediaService : MediaLibraryService() { } private const val WIDGET_UPDATE_INTERVAL_MS = 1000L +private const val RADIO_HEADER_CHECK_INTERVAL_SECONDS = 30L // Reduced frequency - only fallback when ICY fails 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 69dc9dd4..94f136de 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 @@ -62,7 +62,8 @@ private boolean isConnected() { boolean hasAppropriateTransport = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET); + || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH); if (!hasAppropriateTransport) { return false; } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java index af0d2bbe..4ca1f44f 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java @@ -6,7 +6,6 @@ import android.widget.Toast; import androidx.annotation.NonNull; -import androidx.annotation.StringRes; import androidx.fragment.app.DialogFragment; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.LinearLayoutManager; @@ -20,41 +19,30 @@ import com.cappielloantonio.tempo.viewmodel.PlaylistChooserViewModel; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicInteger; - public class PlaylistChooserDialog extends DialogFragment implements ClickCallback { private DialogPlaylistChooserBinding bind; private PlaylistChooserViewModel playlistChooserViewModel; - private PlaylistDialogHorizontalAdapter playlistDialogHorizontalAdapter; - @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { + DialogPlaylistChooserBinding.inflate(getLayoutInflater()); bind = DialogPlaylistChooserBinding.inflate(getLayoutInflater()); playlistChooserViewModel = new ViewModelProvider(requireActivity()).get(PlaylistChooserViewModel.class); - String[] playlistVisibilityChoice = { - getString(R.string.playlist_chooser_dialog_visibility_public), - getString(R.string.playlist_chooser_dialog_visibility_private) - }; + bind.playlistDialogChooserVisibilitySwitch.setOnCheckedChangeListener( + (buttonView, + isChecked) -> playlistChooserViewModel.setIsPlaylistPublic(isChecked) + ); + bind.playlistChooserDialogCreateButton.setOnClickListener(v -> launchPlaylistEditor()); + bind.playlistChooserDialogCancelButton.setOnClickListener(v -> dismiss()); - return new MaterialAlertDialogBuilder(getActivity()) + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext()) .setView(bind.getRoot()) - .setTitle(R.string.playlist_chooser_dialog_title) - .setSingleChoiceItems( - playlistVisibilityChoice, - 0, - (dialog, which) -> { - boolean isPublic = (which == 0); - playlistChooserViewModel.setIsPlaylistPublic(isPublic); - }) - .setNeutralButton(R.string.playlist_chooser_dialog_neutral_button, (dialog, id) -> { }) - .setNegativeButton(R.string.playlist_chooser_dialog_negative_button, (dialog, id) -> dialog.cancel()) - .create(); + .setTitle(R.string.playlist_chooser_dialog_title); + return builder.create(); } @Override @@ -69,25 +57,26 @@ public void onStart() { initPlaylistView(); setSongInfo(); - setButtonAction(); } private void setSongInfo() { playlistChooserViewModel.setSongsToAdd(requireArguments().getParcelableArrayList(Constants.TRACKS_OBJECT)); } - private void setButtonAction() { - androidx.appcompat.app.AlertDialog alertDialog = (androidx.appcompat.app.AlertDialog) Objects.requireNonNull(getDialog()); - alertDialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> { - Bundle bundle = new Bundle(); - bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, playlistChooserViewModel.getSongsToAdd()); + private void launchPlaylistEditor() { + Bundle bundle = new Bundle(); + bundle.putParcelableArrayList( + Constants.TRACKS_OBJECT, + playlistChooserViewModel.getSongsToAdd() + ); - PlaylistEditorDialog dialog = new PlaylistEditorDialog(null); - dialog.setArguments(bundle); - dialog.show(requireActivity().getSupportFragmentManager(), null); + PlaylistEditorDialog editorDialog = new PlaylistEditorDialog(null); + editorDialog.setArguments(bundle); + editorDialog.show( + requireActivity().getSupportFragmentManager(), + null); - Objects.requireNonNull(getDialog()).dismiss(); - }); + dismiss(); } private void initPlaylistView() { 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 e6b91f01..72368555 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 @@ -61,13 +61,47 @@ public void onDestroyView() { private void setTrackInfo() { genreLink = null; yearLink = null; - bind.trakTitleInfoTextView.setText(mediaMetadata.title); - bind.trakArtistInfoTextView.setText( - mediaMetadata.artist != null - ? mediaMetadata.artist - : mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) - ? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder)) - : ""); + + String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null; + boolean isRadio = Objects.equals(type, Constants.MEDIA_TYPE_RADIO); + + if (isRadio) { + // For radio: always read from extras first (radioArtist, radioTitle, stationName) + // MediaMetadata.title/artist are formatted for notification + String stationName = mediaMetadata.extras != null + ? mediaMetadata.extras.getString("stationName", + mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "") + : mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : ""; + + String artist = mediaMetadata.extras != null + ? mediaMetadata.extras.getString("radioArtist", "") + : ""; + + String title = mediaMetadata.extras != null + ? mediaMetadata.extras.getString("radioTitle", "") + : ""; + + // Format: "Artist - Song" or fallback to title or station name + String mainTitle; + if (!android.text.TextUtils.isEmpty(artist) && !android.text.TextUtils.isEmpty(title)) { + mainTitle = artist + " - " + title; + } else if (!android.text.TextUtils.isEmpty(title)) { + mainTitle = title; + } else if (!android.text.TextUtils.isEmpty(artist)) { + mainTitle = artist; + } else { + mainTitle = stationName; + } + + bind.trakTitleInfoTextView.setText(mainTitle); + bind.trakArtistInfoTextView.setText(stationName); + } else { + bind.trakTitleInfoTextView.setText(mediaMetadata.title); + bind.trakArtistInfoTextView.setText( + mediaMetadata.artist != null + ? mediaMetadata.artist + : ""); + } if (mediaMetadata.extras != null) { songLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_SONG, mediaMetadata.extras.getString("id")); @@ -90,6 +124,27 @@ private void setTrackInfo() { 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); + + // Handle radio-specific metadata + if (isRadio) { + String stationName = mediaMetadata.extras.getString("stationName", getString(R.string.label_placeholder)); + String radioArtist = mediaMetadata.extras.getString("radioArtist", ""); + String radioTitle = mediaMetadata.extras.getString("radioTitle", ""); + + // Show station name in station section + bind.stationInfoSector.setVisibility(android.view.View.VISIBLE); + bind.stationValueSector.setText(stationName); + + // Use radio metadata for title/artist if available + if (!android.text.TextUtils.isEmpty(radioTitle)) { + titleValue = radioTitle; + } + if (!android.text.TextUtils.isEmpty(radioArtist)) { + artistValue = radioArtist; + } + } else { + bind.stationInfoSector.setVisibility(android.view.View.GONE); + } if (genreLink == null && genreValue != null && !genreValue.isEmpty() && !getString(R.string.label_placeholder).contentEquals(genreValue)) { genreLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_GENRE, genreValue); 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 e2bca343..52e796ae 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 @@ -3,6 +3,7 @@ import android.content.ComponentName; import android.os.Bundle; import android.os.Handler; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -173,25 +174,54 @@ private void setMetadata(MediaMetadata mediaMetadata) { playerBottomSheetViewModel.setLiveArtist(getViewLifecycleOwner(), mediaMetadata.extras.getString("type"), mediaMetadata.extras.getString("artistId")); playerBottomSheetViewModel.setLiveDescription(mediaMetadata.extras.getString("description", null)); - bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mediaMetadata.extras.getString("title")); - bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText( - mediaMetadata.artist != null - ? mediaMetadata.artist - : Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) - ? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder)) - : ""); + String type = mediaMetadata.extras.getString("type"); + + if (Objects.equals(type, Constants.MEDIA_TYPE_RADIO)) { + // For radio: keep header consistent with full player + String stationName = mediaMetadata.extras.getString( + "stationName", + mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "" + ); + + String artist = mediaMetadata.extras.getString("radioArtist", ""); + String title = mediaMetadata.extras.getString("radioTitle", ""); + + String mainTitle; + if (!TextUtils.isEmpty(artist) && !TextUtils.isEmpty(title)) { + mainTitle = artist + " - " + title; + } else if (!TextUtils.isEmpty(title)) { + mainTitle = title; + } else if (!TextUtils.isEmpty(artist)) { + mainTitle = artist; + } else { + mainTitle = stationName; + } + + bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mainTitle); + bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText(stationName); + + bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setVisibility(!TextUtils.isEmpty(mainTitle) ? View.VISIBLE : View.GONE); + bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setVisibility(!TextUtils.isEmpty(stationName) ? View.VISIBLE : View.GONE); + } else { + // Default (music, podcast, etc.) + bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mediaMetadata.extras.getString("title")); + bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText( + mediaMetadata.artist != null + ? mediaMetadata.artist + : "" + ); + + bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setVisibility(mediaMetadata.extras.getString("title") != null && !Objects.equals(mediaMetadata.extras.getString("title"), "") ? View.VISIBLE : View.GONE); + bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setVisibility( + mediaMetadata.extras.getString("artist") != null && !Objects.equals(mediaMetadata.extras.getString("artist"), "") + ? View.VISIBLE + : View.GONE); + } CustomGlideRequest.Builder .from(requireContext(), mediaMetadata.extras.getString("coverArtId"), CustomGlideRequest.ResourceType.Song) .build() .into(bind.playerHeaderLayout.playerHeaderMediaCoverImage); - - bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setVisibility(mediaMetadata.extras.getString("title") != null && !Objects.equals(mediaMetadata.extras.getString("title"), "") ? View.VISIBLE : View.GONE); - bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setVisibility( - (mediaMetadata.extras.getString("artist") != null && !Objects.equals(mediaMetadata.extras.getString("artist"), "")) - || (Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) && mediaMetadata.extras.getString("uri") != null) - ? View.VISIBLE - : View.GONE); } } 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 c6a0d6fd..3bba6183 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 @@ -7,6 +7,7 @@ import android.os.Bundle; import android.os.IBinder; import android.text.TextUtils; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -214,12 +215,53 @@ public void onRepeatModeChanged(int repeatMode) { } private void setMetadata(MediaMetadata mediaMetadata) { + String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null; + + if (Objects.equals(type, Constants.MEDIA_TYPE_RADIO)) { + // For radio: always read from extras first (radioArtist, radioTitle, stationName) + // MediaMetadata.title/artist are formatted for notification + String stationName = mediaMetadata.extras != null + ? mediaMetadata.extras.getString("stationName", + mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "") + : mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : ""; + + String artist = mediaMetadata.extras != null + ? mediaMetadata.extras.getString("radioArtist", "") + : ""; + + String title = mediaMetadata.extras != null + ? mediaMetadata.extras.getString("radioTitle", "") + : ""; + + // Format: "Artist - Song" or fallback to title or station name + String mainTitle; + if (!TextUtils.isEmpty(artist) && !TextUtils.isEmpty(title)) { + mainTitle = artist + " - " + title; + } else if (!TextUtils.isEmpty(title)) { + mainTitle = title; + } else if (!TextUtils.isEmpty(artist)) { + mainTitle = artist; + } else { + mainTitle = stationName; + } + + playerMediaTitleLabel.setText(mainTitle); + playerArtistNameLabel.setText(stationName); + + playerMediaTitleLabel.setSelected(true); + playerArtistNameLabel.setSelected(true); + + playerMediaTitleLabel.setVisibility(!TextUtils.isEmpty(mainTitle) ? View.VISIBLE : View.GONE); + playerArtistNameLabel.setVisibility(!TextUtils.isEmpty(stationName) ? View.VISIBLE : View.GONE); + + updateAssetLinkChips(mediaMetadata); + return; + } + playerMediaTitleLabel.setText(String.valueOf(mediaMetadata.title)); playerArtistNameLabel.setText( mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) - : mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) - ? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder)) : ""); playerMediaTitleLabel.setSelected(true); @@ -236,43 +278,64 @@ private void setMetadata(MediaMetadata mediaMetadata) { } private void setMediaInfo(MediaMetadata mediaMetadata) { + boolean isLocal = false; + + if (mediaBrowserListenableFuture != null && mediaBrowserListenableFuture.isDone()) { + try { + MediaBrowser browser = mediaBrowserListenableFuture.get(); + if (browser != null && browser.getCurrentMediaItem() != null) { + android.net.Uri currentUri = browser.getCurrentMediaItem().requestMetadata.mediaUri; + if (currentUri != null) { + String scheme = currentUri.getScheme(); + isLocal = "content".equals(scheme) || "file".equals(scheme); + } + } + } catch (Exception e) { + Log.e("DEBUG_PLAYER", "Error getting browser for UI update", e); + } + } + if (mediaMetadata.extras != null) { String extension = mediaMetadata.extras.getString("suffix", getString(R.string.player_unknown_format)); - String bitrate = mediaMetadata.extras.getInt("bitrate", 0) != 0 ? mediaMetadata.extras.getInt("bitrate", 0) + "kbps" : "Original"; - String samplingRate = mediaMetadata.extras.getInt("samplingRate", 0) != 0 ? new DecimalFormat("0.#").format(mediaMetadata.extras.getInt("samplingRate", 0) / 1000.0) + "kHz" : ""; + int rawBitrate = mediaMetadata.extras.getInt("bitrate", 0); + String bitrate = rawBitrate != 0 ? rawBitrate + "kbps" : "Original"; + String samplingRate = mediaMetadata.extras.getInt("samplingRate", 0) != 0 ? + new java.text.DecimalFormat("0.#").format(mediaMetadata.extras.getInt("samplingRate", 0) / 1000.0) + "kHz" : ""; String bitDepth = mediaMetadata.extras.getInt("bitDepth", 0) != 0 ? mediaMetadata.extras.getInt("bitDepth", 0) + "b" : ""; playerMediaExtension.setText(extension); - if (bitrate.equals("Original")) { + if (bitrate.equals("Original") && !isLocal) { playerMediaBitrate.setVisibility(View.GONE); } else { - List mediaQualityItems = new ArrayList<>(); - - if (!bitrate.trim().isEmpty()) mediaQualityItems.add(bitrate); - if (!bitDepth.trim().isEmpty()) mediaQualityItems.add(bitDepth); - if (!samplingRate.trim().isEmpty()) mediaQualityItems.add(samplingRate); - - String mediaQuality = TextUtils.join(" • ", mediaQualityItems); + List items = new ArrayList<>(); + if (!bitrate.trim().isEmpty()) items.add(bitrate); + if (!bitDepth.trim().isEmpty()) items.add(bitDepth); + if (!samplingRate.trim().isEmpty()) items.add(samplingRate); + String mediaQuality = TextUtils.join(" • ", items); + playerMediaBitrate.setVisibility(View.VISIBLE); - playerMediaBitrate.setText(mediaQuality); + playerMediaBitrate.setText(isLocal ? mediaQuality : mediaQuality); } } - boolean isTranscodingExtension = !MusicUtil.getTranscodingFormatPreference().equals("raw"); - boolean isTranscodingBitrate = !MusicUtil.getBitratePreference().equals("0"); + + if (!isLocal) { + boolean isTranscodingExtension = !MusicUtil.getTranscodingFormatPreference().equals("raw"); + boolean isTranscodingBitrate = !MusicUtil.getBitratePreference().equals("0"); + if (isTranscodingExtension || isTranscodingBitrate) { + playerMediaExtension.setText(MusicUtil.getTranscodingFormatPreference() + " (" + getString(R.string.player_transcoding) + ")"); + playerMediaBitrate.setText(!MusicUtil.getBitratePreference().equals("0") ? + MusicUtil.getBitratePreference() + "kbps" : getString(R.string.player_transcoding_requested)); + } - if (isTranscodingExtension || isTranscodingBitrate) { - playerMediaExtension.setText(MusicUtil.getTranscodingFormatPreference() + " (" + getString(R.string.player_transcoding) + ")"); - playerMediaBitrate.setText(!MusicUtil.getBitratePreference().equals("0") ? MusicUtil.getBitratePreference() + "kbps" : getString(R.string.player_transcoding_requested)); } playerTrackInfo.setOnClickListener(view -> { TrackInfoDialog dialog = new TrackInfoDialog(mediaMetadata); dialog.show(activity.getSupportFragmentManager(), null); - }); + }); } - 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; diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java index 6df73eb6..7892455f 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java @@ -29,6 +29,8 @@ import java.net.CookieManager; import java.net.CookiePolicy; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.Executors; @UnstableApi @@ -78,12 +80,33 @@ public static synchronized DataSource.Factory getHttpDataSourceFactory() { return httpDataSourceFactory; } + public static synchronized DataSource.Factory getHttpDataSourceFactoryForRadio() { + CookieManager cookieManager = new CookieManager(); + cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER); + CookieHandler.setDefault(cookieManager); + + // Create a factory with ICY metadata support for radio streams + Map defaultRequestProperties = new HashMap<>(); + defaultRequestProperties.put("Icy-MetaData", "1"); + defaultRequestProperties.put("User-Agent", "Tempus/1.0"); + + return new DefaultHttpDataSource + .Factory() + .setAllowCrossProtocolRedirects(true) + .setDefaultRequestProperties(defaultRequestProperties); + } + public static synchronized DataSource.Factory getUpstreamDataSourceFactory(Context context) { DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory()); dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); return dataSourceFactory; } + public static synchronized DataSource.Factory getUpstreamDataSourceFactoryForRadio(Context context) { + DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactoryForRadio()); + return buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); + } + public static synchronized DataSource.Factory getCacheDataSourceFactory(Context context) { CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory() .setCache(getStreamingCache(context)) diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt b/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt index e152dc0a..f4109550 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt @@ -20,10 +20,15 @@ class DynamicMediaSourceFactory( ) : MediaSource.Factory { override fun createMediaSource(mediaItem: MediaItem): MediaSource { - val mediaType: String? = mediaItem.mediaMetadata.extras?.getString("type", "") + // Detect radio streams in a backwards-compatible way. + // Older Tempus versions tagged radio items via MediaMetadata extras + // (`type == MEDIA_TYPE_RADIO`), while newer upstream changes use an + // "ir-" mediaId prefix. Support BOTH so radio works after rebases. + val mediaType = mediaItem.mediaMetadata.extras?.getString("type", "") + val isRadio = mediaType == Constants.MEDIA_TYPE_RADIO || mediaItem.mediaId.startsWith("ir-") val streamingCacheSize = Preferences.getStreamingCacheSize() - val bypassCache = mediaType == Constants.MEDIA_TYPE_RADIO + val bypassCache = isRadio val useUpstream = when { streamingCacheSize.toInt() == 0 -> true @@ -32,7 +37,10 @@ class DynamicMediaSourceFactory( else -> true } - val dataSourceFactory: DataSource.Factory = if (useUpstream) { + val dataSourceFactory: DataSource.Factory = if (bypassCache) { + // For radio streams, use a DataSourceFactory with ICY metadata support + DownloadUtil.getUpstreamDataSourceFactoryForRadio(context) + } else if (useUpstream) { DownloadUtil.getUpstreamDataSourceFactory(context) } else { DownloadUtil.getCacheDataSourceFactory(context) 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 71007c78..f26ba207 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java @@ -211,6 +211,7 @@ public static MediaItem mapInternetRadioStation(InternetRadioStation internetRad Bundle bundle = new Bundle(); bundle.putString("id", internetRadioStation.getId()); bundle.putString("title", internetRadioStation.getName()); + bundle.putString("stationName", internetRadioStation.getName()); bundle.putString("uri", uri.toString()); bundle.putString("type", Constants.MEDIA_TYPE_RADIO); @@ -219,6 +220,7 @@ public static MediaItem mapInternetRadioStation(InternetRadioStation internetRad .setMediaMetadata( new MediaMetadata.Builder() .setTitle(internetRadioStation.getName()) + .setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION) .setExtras(bundle) .setIsBrowsable(false) .setIsPlayable(true) @@ -288,13 +290,24 @@ public static MediaItem mapMediaItem(PodcastEpisode podcastEpisode) { } private static Uri getUri(Child media) { + // Check if it's in our local SQL Database + DownloadRepository repo = new DownloadRepository(); + Download localDownload = repo.getDownload(media.getId()); + + if (localDownload != null && localDownload.getDownloadUri() != null && !localDownload.getDownloadUri().isEmpty()) { + Log.d(TAG, "Playing local file for: " + media.getTitle()); + return Uri.parse(localDownload.getDownloadUri()); + } + + // Legacy check for external directory, i think this was broken/buggy if (Preferences.getDownloadDirectoryUri() != null) { Uri local = ExternalAudioReader.getUri(media); - return local != null ? local : MusicUtil.getStreamUri(media.getId()); + if (local != null) return local; } - return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(media.getId()) - ? getDownloadUri(media.getId()) - : MusicUtil.getStreamUri(media.getId()); + + // Fallback to streaming + Log.d(TAG, "No local file found. Streaming: " + media.getTitle()); + return MusicUtil.getStreamUri(media.getId()); } private static Uri getUri(PodcastEpisode podcastEpisode) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/MusicUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/MusicUtil.java index c1656899..e04a268a 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/MusicUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/MusicUtil.java @@ -52,6 +52,10 @@ public static Uri getStreamUri(String id, int timeOffset) { if (params.containsKey("c") && params.get("c") != null) uri.append("&c=").append(params.get("c")); + String selectedBitrate = getBitratePreference(); + String selectedFormat = getTranscodingFormatPreference(); + Log.i(TAG, "DEBUG: Requesting Format: " + selectedFormat + " at Bitrate: " + selectedBitrate); + if (!Preferences.isServerPrioritized()) uri.append("&maxBitRate=").append(getBitratePreference()); if (!Preferences.isServerPrioritized()) @@ -73,7 +77,17 @@ public static Uri getStreamUri(String id) { } public static Uri updateStreamUri(Uri uri) { + if (uri == null) return null; + + String scheme = uri.getScheme(); + // If it is local (content:// or file://), return it IMMEDIATELY. + // This prevents the code below from appending &maxBitRate to a local path. + if (scheme != null && (scheme.equals("content") || scheme.equals("file"))) { + return uri; + } + String s = uri.toString(); + Matcher m1 = BITRATE_PATTERN.matcher(s); s = m1.replaceAll(""); Matcher m2 = FORMAT_PATTERN.matcher(s); @@ -157,7 +171,6 @@ public static Uri getTranscodedDownloadUri(String id) { return Uri.parse(uri.toString()); } - public static String getReadableDurationString(Long duration, boolean millis) { long lenght = duration != null ? duration : 0; @@ -303,13 +316,17 @@ public static String getTranscodingFormatPreference() { if (network == null || networkCapabilities == null) return "raw"; + String format; if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { - return Preferences.getAudioTranscodeFormatWifi(); + format = Preferences.getAudioTranscodeFormatWifi(); + Log.d(TAG, "DEBUG: Using WIFI Format: " + format); } else if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { - return Preferences.getAudioTranscodeFormatMobile(); + format = Preferences.getAudioTranscodeFormatMobile(); + Log.d(TAG, "DEBUG: Using MOBILE Format: " + format); } else { - return Preferences.getAudioTranscodeFormatWifi(); + format = Preferences.getAudioTranscodeFormatWifi(); } + return format; } public static String getBitratePreferenceForDownload() { diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/TranscodingMediaSource.kt b/app/src/main/java/com/cappielloantonio/tempo/util/TranscodingMediaSource.kt index 14270ff9..4213a4f7 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/TranscodingMediaSource.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/TranscodingMediaSource.kt @@ -33,12 +33,18 @@ class TranscodingMediaSource( init { val extras = mediaItem.mediaMetadata.extras - if (extras != null && extras.containsKey("duration")) { + val uri = mediaItem.localConfiguration?.uri + val isLocal = uri?.scheme == "content" || uri?.scheme == "file" + + // Only apply the override if it's NOT a local file + if (!isLocal && extras != null && extras.containsKey("duration")) { val seconds = extras.getInt("duration") if (seconds > 0) { durationUs = Util.msToUs(seconds * 1000L) } } + + currentSource = progressiveMediaSourceFactory.createMediaSource(mediaItem) } override fun getMediaItem() = mediaItem diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index 25ee0493..581aa4ce 100644 --- a/app/src/main/res/layout-land/activity_main.xml +++ b/app/src/main/res/layout-land/activity_main.xml @@ -18,18 +18,17 @@ android:orientation="horizontal"> diff --git a/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml b/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml index cb3ae9c6..435e3648 100644 --- a/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml +++ b/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml @@ -1,6 +1,7 @@ @@ -62,6 +63,20 @@ app:srcCompat="@drawable/ic_info_stream" app:tint="?attr/colorOnPrimaryContainer" /> +