diff --git a/app/build.gradle b/app/build.gradle index 71530899..8c98e067 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,9 +10,8 @@ android { minSdkVersion 24 targetSdk 35 - versionCode 31 - versionName '3.14.8' - + versionCode 32 + versionName '3.15.0' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' javaCompileOptions { @@ -23,8 +22,21 @@ android { ] } } + } + splits { + abi { + enable true + reset() + //noinspection ChromeOsAbiSupport + include 'armeabi-v7a', 'arm64-v8a' + universalApk false + } + } + + + flavorDimensions += "default" productFlavors { @@ -50,6 +62,12 @@ android { minifyEnabled true debuggable false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + universalApk true + } + + debug { + applicationIdSuffix ".debug" + debuggable true } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/EqualizerManager.kt b/app/src/main/java/com/cappielloantonio/tempo/service/EqualizerManager.kt new file mode 100644 index 00000000..9d8489e3 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/service/EqualizerManager.kt @@ -0,0 +1,47 @@ +package com.cappielloantonio.tempo.service + +import android.media.audiofx.Equalizer + +class EqualizerManager { + + private var equalizer: Equalizer? = null + + fun attachToSession(audioSessionId: Int): Boolean { + release() + if (audioSessionId != 0 && audioSessionId != -1) { + try { + equalizer = Equalizer(0, audioSessionId).apply { + enabled = true + } + return true + } catch (e: Exception) { + // Some devices may not support Equalizer or audio session may be invalid + equalizer = null + } + } + return false + } + + fun setBandLevel(band: Short, level: Short) { + equalizer?.setBandLevel(band, level) + } + + fun getNumberOfBands(): Short = equalizer?.numberOfBands ?: 0 + + fun getBandLevelRange(): ShortArray? = equalizer?.bandLevelRange + + fun getCenterFreq(band: Short): Int? = + equalizer?.getCenterFreq(band)?.div(1000) + + fun getBandLevel(band: Short): Short? = + equalizer?.getBandLevel(band) + + fun setEnabled(enabled: Boolean) { + equalizer?.enabled = enabled + } + + fun release() { + equalizer?.release() + equalizer = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java index ea1dcaba..6c99c2b7 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java +++ b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java @@ -1,11 +1,17 @@ package com.cappielloantonio.tempo.service; import android.content.ComponentName; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.OptIn; +import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; import androidx.media3.common.MediaItem; +import androidx.media3.common.Player; +import androidx.media3.common.Timeline; import androidx.media3.common.util.UnstableApi; import androidx.media3.session.MediaBrowser; import androidx.media3.session.SessionToken; @@ -21,14 +27,79 @@ import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation; import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode; import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.Preferences; +import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import java.lang.ref.WeakReference; import java.util.List; import java.util.concurrent.ExecutionException; public class MediaManager { private static final String TAG = "MediaManager"; + private static WeakReference attachedBrowserRef = new WeakReference<>(null); + + public static void registerPlaybackObserver( + ListenableFuture browserFuture, + PlaybackViewModel playbackViewModel + ) { + if (browserFuture == null) return; + + Futures.addCallback(browserFuture, new FutureCallback() { + @Override + public void onSuccess(MediaBrowser browser) { + MediaBrowser current = attachedBrowserRef.get(); + if (current != browser) { + browser.addListener(new Player.Listener() { + @Override + public void onEvents(@NonNull Player player, @NonNull Player.Events events) { + if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION) + || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED) + || events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + + String mediaId = player.getCurrentMediaItem() != null + ? player.getCurrentMediaItem().mediaId + : null; + + boolean playing = player.getPlaybackState() == Player.STATE_READY + && player.getPlayWhenReady(); + + playbackViewModel.update(mediaId, playing); + } + } + }); + + String mediaId = browser.getCurrentMediaItem() != null + ? browser.getCurrentMediaItem().mediaId + : null; + boolean playing = browser.getPlaybackState() == Player.STATE_READY && browser.getPlayWhenReady(); + playbackViewModel.update(mediaId, playing); + + attachedBrowserRef = new WeakReference<>(browser); + } else { + String mediaId = browser.getCurrentMediaItem() != null + ? browser.getCurrentMediaItem().mediaId + : null; + boolean playing = browser.getPlaybackState() == Player.STATE_READY && browser.getPlayWhenReady(); + playbackViewModel.update(mediaId, playing); + } + } + + @Override + public void onFailure(@NonNull Throwable t) { + Log.e(TAG, "Failed to get MediaBrowser instance", t); + } + }, MoreExecutors.directExecutor()); + } + + public static void onBrowserReleased(@Nullable MediaBrowser released) { + MediaBrowser attached = attachedBrowserRef.get(); + if (attached == released) { + attachedBrowserRef.clear(); + } + } public static void reset(ListenableFuture mediaBrowserListenableFuture) { if (mediaBrowserListenableFuture != null) { @@ -107,11 +178,24 @@ public class MediaManager { mediaBrowserListenableFuture.addListener(() -> { try { if (mediaBrowserListenableFuture.isDone()) { - mediaBrowserListenableFuture.get().clearMediaItems(); - mediaBrowserListenableFuture.get().setMediaItems(MappingUtil.mapMediaItems(media)); - mediaBrowserListenableFuture.get().prepare(); - mediaBrowserListenableFuture.get().seekTo(startIndex, 0); - mediaBrowserListenableFuture.get().play(); + MediaBrowser browser = mediaBrowserListenableFuture.get(); + browser.clearMediaItems(); + browser.setMediaItems(MappingUtil.mapMediaItems(media)); + browser.prepare(); + + Player.Listener timelineListener = new Player.Listener() { + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + int itemCount = browser.getMediaItemCount(); + if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) { + browser.seekTo(startIndex, 0); + browser.play(); + browser.removeListener(this); + } + } + }; + browser.addListener(timelineListener); + enqueueDatabase(media, true, 0); } } catch (ExecutionException | InterruptedException e) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PlayerSongQueueAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PlayerSongQueueAdapter.java index 5747eab1..4db3a572 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PlayerSongQueueAdapter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PlayerSongQueueAdapter.java @@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.adapter; import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -23,17 +24,24 @@ import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; public class PlayerSongQueueAdapter extends RecyclerView.Adapter { + private static final String TAG = "PlayerSongQueueAdapter"; private final ClickCallback click; private ListenableFuture mediaBrowserListenableFuture; private List songs; + private String currentPlayingId; + private boolean isPlaying; + private List currentPlayingPositions = Collections.emptyList(); + public PlayerSongQueueAdapter(ClickCallback click) { this.click = click; this.songs = Collections.emptyList(); @@ -104,6 +112,46 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter { + mediaBrowserListenableFuture.addListener(() -> { + try { + MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get(); + int pos = holder.getBindingAdapterPosition(); + Child s = songs.get(pos); + if (currentPlayingId != null && currentPlayingId.equals(s.getId())) { + if (isPlaying) { + mediaBrowser.pause(); + } else { + mediaBrowser.play(); + } + } else { + mediaBrowser.seekTo(pos, 0); + mediaBrowser.play(); + } + } catch (Exception e) { + Log.w(TAG, "Error obtaining MediaBrowser", e); + } + }, MoreExecutors.directExecutor()); + + }); + bindPlaybackState(holder, song); + } + + private void bindPlaybackState(@NonNull PlayerSongQueueAdapter.ViewHolder holder, @NonNull Child song) { + boolean isCurrent = currentPlayingId != null && currentPlayingId.equals(song.getId()); + + if (isCurrent) { + holder.item.playPauseIcon.setVisibility(View.VISIBLE); + if (isPlaying) { + holder.item.playPauseIcon.setImageResource(R.drawable.ic_pause); + } else { + holder.item.playPauseIcon.setImageResource(R.drawable.ic_play); + } + holder.item.coverArtOverlay.setVisibility(View.VISIBLE); + } else { + holder.item.playPauseIcon.setVisibility(View.INVISIBLE); + holder.item.coverArtOverlay.setVisibility(View.INVISIBLE); + } } public List getItems() { @@ -132,6 +180,46 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter oldPositions = currentPlayingPositions; + + this.currentPlayingId = mediaId; + this.isPlaying = playing; + + if (Objects.equals(oldId, mediaId) && oldPlaying == playing) { + List newPositionsCheck = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList(); + if (oldPositions.equals(newPositionsCheck)) { + return; + } + } + + currentPlayingPositions = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList(); + + for (int pos : oldPositions) { + if (pos >= 0 && pos < songs.size()) { + notifyItemChanged(pos, "payload_playback"); + } + } + for (int pos : currentPlayingPositions) { + if (!oldPositions.contains(pos) && pos >= 0 && pos < songs.size()) { + notifyItemChanged(pos, "payload_playback"); + } + } + } + + private List findPositionsById(String id) { + if (id == null) return Collections.emptyList(); + List positions = new ArrayList<>(); + for (int i = 0; i < songs.size(); i++) { + if (id.equals(songs.get(i).getId())) { + positions.add(i); + } + } + return positions; + } + public Child getItem(int id) { return songs.get(id); } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java index a1630bca..cb10ab4e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java @@ -1,6 +1,8 @@ package com.cappielloantonio.tempo.ui.adapter; +import android.app.Activity; import android.os.Bundle; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -10,6 +12,7 @@ import android.widget.Filterable; import androidx.annotation.NonNull; import androidx.appcompat.content.res.AppCompatResources; import androidx.media3.common.util.UnstableApi; +import androidx.media3.session.MediaBrowser; import androidx.recyclerview.widget.RecyclerView; import com.cappielloantonio.tempo.R; @@ -23,6 +26,7 @@ import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; +import com.google.common.util.concurrent.ListenableFuture; import java.util.ArrayList; import java.util.Collections; @@ -30,6 +34,7 @@ import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.ExecutionException; @UnstableApi public class SongHorizontalAdapter extends RecyclerView.Adapter implements Filterable { @@ -42,6 +47,11 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter songs; private String currentFilter; + private String currentPlayingId; + private boolean isPlaying; + private List currentPlayingPositions = Collections.emptyList(); + private ListenableFuture mediaBrowserListenableFuture; + private final Filter filtering = new Filter() { @Override protected FilterResults performFiltering(CharSequence constraint) { @@ -70,6 +80,12 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter) results.values; notifyDataSetChanged(); + + for (int pos : currentPlayingPositions) { + if (pos >= 0 && pos < songs.size()) { + notifyItemChanged(pos, "payload_playback"); + } + } } }; @@ -81,6 +97,7 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter payloads) { + if (!payloads.isEmpty() && payloads.contains("payload_playback")) { + bindPlaybackState(holder, songs.get(position)); + } else { + super.onBindViewHolder(holder, position, payloads); + } + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { Child song = songs.get(position); holder.item.searchResultSongTitleTextView.setText(song.getTitle()); @@ -165,6 +191,33 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter oldPositions = currentPlayingPositions; + + this.currentPlayingId = mediaId; + this.isPlaying = playing; + + if (Objects.equals(oldId, mediaId) && oldPlaying == playing) { + List newPositionsCheck = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList(); + if (oldPositions.equals(newPositionsCheck)) { + return; + } + } + + currentPlayingPositions = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList(); + + for (int pos : oldPositions) { + if (pos >= 0 && pos < songs.size()) { + notifyItemChanged(pos, "payload_playback"); + } + } + for (int pos : currentPlayingPositions) { + if (!oldPositions.contains(pos) && pos >= 0 && pos < songs.size()) { + notifyItemChanged(pos, "payload_playback"); + } + } + } + + private List findPositionsById(String id) { + if (id == null) return Collections.emptyList(); + List positions = new ArrayList<>(); + for (int i = 0; i < songs.size(); i++) { + if (id.equals(songs.get(i).getId())) { + positions.add(i); + } + } + return positions; + } + @Override public Filter getFilter() { return filtering; @@ -215,11 +308,29 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter(MusicUtil.limitPlayableMedia(songs, getBindingAdapterPosition()))); bundle.putInt(Constants.ITEM_POSITION, MusicUtil.getPlayableMediaPosition(songs, getBindingAdapterPosition())); - click.onMediaClick(bundle); + if (tappedSong.getId().equals(currentPlayingId)) { + Log.i("SongHorizontalAdapter", "Tapping on currently playing song, toggling playback"); + try{ + MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get(); + Log.i("SongHorizontalAdapter", "MediaBrowser retrieved, isPlaying: " + isPlaying); + if (isPlaying) { + mediaBrowser.pause(); + } else { + mediaBrowser.play(); + } + } catch (ExecutionException | InterruptedException e) { + Log.e("SongHorizontalAdapter", "Error getting MediaBrowser", e); + } + } else { + click.onMediaClick(bundle); + } } private boolean onLongClick() { @@ -247,4 +358,8 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter mediaBrowserListenableFuture) { + this.mediaBrowserListenableFuture = mediaBrowserListenableFuture; + } } 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 03e71e10..03ea9100 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 @@ -40,6 +40,7 @@ import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.viewmodel.AlbumPageViewModel; +import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.google.common.util.concurrent.ListenableFuture; import java.util.ArrayList; @@ -52,6 +53,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback { private FragmentAlbumPageBinding bind; private MainActivity activity; private AlbumPageViewModel albumPageViewModel; + private PlaybackViewModel playbackViewModel; private SongHorizontalAdapter songHorizontalAdapter; private ListenableFuture mediaBrowserListenableFuture; @@ -74,6 +76,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback { bind = FragmentAlbumPageBinding.inflate(inflater, container, false); View view = bind.getRoot(); albumPageViewModel = new ViewModelProvider(requireActivity()).get(AlbumPageViewModel.class); + playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class); init(); initAppBar(); @@ -91,6 +94,14 @@ public class AlbumPageFragment extends Fragment implements ClickCallback { super.onStart(); initializeMediaBrowser(); + + MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel); + observePlayback(); + } + + public void onResume() { + super.onResume(); + if (songHorizontalAdapter != null) setMediaBrowserListenableFuture(); } @Override @@ -271,8 +282,13 @@ public class AlbumPageFragment extends Fragment implements ClickCallback { songHorizontalAdapter = new SongHorizontalAdapter(this, false, false, album); bind.songRecyclerView.setAdapter(songHorizontalAdapter); + setMediaBrowserListenableFuture(); + reapplyPlayback(); - albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> songHorizontalAdapter.setItems(songs)); + albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> { + songHorizontalAdapter.setItems(songs); + reapplyPlayback(); + }); } }); } @@ -295,4 +311,31 @@ public class AlbumPageFragment extends Fragment implements ClickCallback { public void onMediaLongClick(Bundle bundle) { Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle); } + + private void observePlayback() { + playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> { + if (songHorizontalAdapter != null) { + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + }); + playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> { + if (songHorizontalAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + }); + } + + private void reapplyPlayback() { + if (songHorizontalAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + } + + private void setMediaBrowserListenableFuture() { + songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java index 9dccc83e..48453b8c 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 @@ -29,19 +29,16 @@ import com.cappielloantonio.tempo.service.MediaManager; import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.ui.activity.MainActivity; -import com.cappielloantonio.tempo.ui.adapter.AlbumArtistPageOrSimilarAdapter; import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter; import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter; -import com.cappielloantonio.tempo.ui.adapter.ArtistSimilarAdapter; import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.MusicUtil; -import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.ArtistPageViewModel; +import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.google.common.util.concurrent.ListenableFuture; import java.util.ArrayList; -import java.util.Collections; import java.util.List; @UnstableApi @@ -49,6 +46,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback { private FragmentArtistPageBinding bind; private MainActivity activity; private ArtistPageViewModel artistPageViewModel; + private PlaybackViewModel playbackViewModel; private SongHorizontalAdapter songHorizontalAdapter; private AlbumCatalogueAdapter albumCatalogueAdapter; @@ -63,6 +61,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback { bind = FragmentArtistPageBinding.inflate(inflater, container, false); View view = bind.getRoot(); artistPageViewModel = new ViewModelProvider(requireActivity()).get(ArtistPageViewModel.class); + playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class); init(); initAppBar(); @@ -80,6 +79,13 @@ public class ArtistPageFragment extends Fragment implements ClickCallback { super.onStart(); initializeMediaBrowser(); + MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel); + observePlayback(); + } + + public void onResume() { + super.onResume(); + if (songHorizontalAdapter != null) setMediaBrowserListenableFuture(); } @Override @@ -174,6 +180,8 @@ public class ArtistPageFragment extends Fragment implements ClickCallback { songHorizontalAdapter = new SongHorizontalAdapter(this, true, true, null); bind.mostStreamedSongRecyclerView.setAdapter(songHorizontalAdapter); + setMediaBrowserListenableFuture(); + reapplyPlayback(); artistPageViewModel.getArtistTopSongList().observe(getViewLifecycleOwner(), songs -> { if (songs == null) { if (bind != null) bind.artistPageTopSongsSector.setVisibility(View.GONE); @@ -183,6 +191,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback { if (bind != null) bind.artistPageShuffleButton.setEnabled(!songs.isEmpty()); songHorizontalAdapter.setItems(songs); + reapplyPlayback(); } }); } @@ -273,4 +282,31 @@ public class ArtistPageFragment extends Fragment implements ClickCallback { public void onArtistLongClick(Bundle bundle) { Navigation.findNavController(requireView()).navigate(R.id.artistBottomSheetDialog, bundle); } + + private void observePlayback() { + playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> { + if (songHorizontalAdapter != null) { + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + }); + playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> { + if (songHorizontalAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + }); + } + + private void reapplyPlayback() { + if (songHorizontalAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + } + + private void setMediaBrowserListenableFuture() { + songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt new file mode 100644 index 00000000..a5115f14 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt @@ -0,0 +1,237 @@ +package com.cappielloantonio.tempo.ui.fragment + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.IBinder +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.annotation.OptIn +import androidx.fragment.app.Fragment +import androidx.media3.common.util.UnstableApi +import com.cappielloantonio.tempo.R +import com.cappielloantonio.tempo.service.EqualizerManager +import com.cappielloantonio.tempo.service.MediaService +import com.cappielloantonio.tempo.util.Preferences + +class EqualizerFragment : Fragment() { + + private var equalizerManager: EqualizerManager? = null + private lateinit var eqBandsContainer: LinearLayout + private lateinit var eqSwitch: Switch + private lateinit var resetButton: Button + private lateinit var safeSpace: Space + private val bandSeekBars = mutableListOf() + + private val connection = object : ServiceConnection { + @OptIn(UnstableApi::class) + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = service as MediaService.LocalBinder + equalizerManager = binder.getEqualizerManager() + initUI() + restoreEqualizerPreferences() + } + + override fun onServiceDisconnected(arg0: ComponentName) { + equalizerManager = null + } + } + + @OptIn(UnstableApi::class) + override fun onStart() { + super.onStart() + Intent(requireContext(), MediaService::class.java).also { intent -> + intent.action = MediaService.ACTION_BIND_EQUALIZER + requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + } + + override fun onStop() { + super.onStop() + requireActivity().unbindService(connection) + equalizerManager = null + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val root = inflater.inflate(R.layout.fragment_equalizer, container, false) + eqSwitch = root.findViewById(R.id.equalizer_switch) + eqSwitch.isChecked = Preferences.isEqualizerEnabled() + eqSwitch.jumpDrawablesToCurrentState() + return root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + eqBandsContainer = view.findViewById(R.id.eq_bands_container) + resetButton = view.findViewById(R.id.equalizer_reset_button) + safeSpace = view.findViewById(R.id.equalizer_bottom_space) + } + + private fun initUI() { + val manager = equalizerManager + val notSupportedView = view?.findViewById(R.id.equalizer_not_supported_container) + val switchRow = view?.findViewById(R.id.equalizer_switch_row) + + if (manager == null || manager.getNumberOfBands().toInt() == 0) { + switchRow?.visibility = View.GONE + resetButton.visibility = View.GONE + eqBandsContainer.visibility = View.GONE + safeSpace.visibility = View.GONE + notSupportedView?.visibility = View.VISIBLE + return + } + + notSupportedView?.visibility = View.GONE + switchRow?.visibility = View.VISIBLE + resetButton.visibility = View.VISIBLE + eqBandsContainer.visibility = View.VISIBLE + safeSpace.visibility = View.VISIBLE + + eqSwitch.setOnCheckedChangeListener(null) + updateUiEnabledState(eqSwitch.isChecked) + eqSwitch.setOnCheckedChangeListener { _, isChecked -> + manager.setEnabled(isChecked) + Preferences.setEqualizerEnabled(isChecked) + updateUiEnabledState(isChecked) + } + + createBandSliders() + + resetButton.setOnClickListener { + resetEqualizer() + saveBandLevelsToPreferences() + } + } + + private fun updateUiEnabledState(isEnabled: Boolean) { + resetButton.isEnabled = isEnabled + bandSeekBars.forEach { it.isEnabled = isEnabled } + } + + private fun formatDb(value: Int): String = if (value > 0) "+$value dB" else "$value dB" + + private fun createBandSliders() { + val manager = equalizerManager ?: return + eqBandsContainer.removeAllViews() + bandSeekBars.clear() + val bands = manager.getNumberOfBands() + val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500) + val minLevelDb = bandLevelRange[0] / 100 + val maxLevelDb = bandLevelRange[1] / 100 + + val savedLevels = Preferences.getEqualizerBandLevels(bands) + for (i in 0 until bands) { + val band = i.toShort() + val freq = manager.getCenterFreq(band) ?: 0 + + val row = LinearLayout(requireContext()).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + val topBottomMarginDp = 16 + topMargin = topBottomMarginDp.dpToPx(context) + bottomMargin = topBottomMarginDp.dpToPx(context) + } + setPadding(0, 8, 0, 8) + } + + val freqLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply { + text = if (freq >= 1000) { + if (freq % 1000 == 0) { + "${freq / 1000} kHz" + } else { + String.format("%.1f kHz", freq / 1000f) + } + } else { + "$freq Hz" + } + gravity = Gravity.START + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f) + } + row.addView(freqLabel) + + val initialLevelDb = (savedLevels.getOrNull(i) ?: (manager.getBandLevel(band) ?: 0)) / 100 + val dbLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply { + text = formatDb(initialLevelDb) + setPadding(12, 0, 0, 0) + gravity = Gravity.END + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f) + } + + val seekBar = SeekBar(requireContext()).apply { + max = maxLevelDb - minLevelDb + progress = initialLevelDb - minLevelDb + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 6f) + setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + val thisLevelDb = progress + minLevelDb + if (fromUser) { + manager.setBandLevel(band, (thisLevelDb * 100).toShort()) + saveBandLevelsToPreferences() + } + dbLabel.text = formatDb(thisLevelDb) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) {} + override fun onStopTrackingTouch(seekBar: SeekBar) {} + }) + } + bandSeekBars.add(seekBar) + row.addView(seekBar) + row.addView(dbLabel) + eqBandsContainer.addView(row) + } + } + + private fun resetEqualizer() { + val manager = equalizerManager ?: return + val bands = manager.getNumberOfBands() + val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500) + val minLevelDb = bandLevelRange[0] / 100 + val midLevelDb = 0 + + for (i in 0 until bands) { + manager.setBandLevel(i.toShort(), (0).toShort()) + bandSeekBars.getOrNull(i)?.progress = midLevelDb - minLevelDb + } + Preferences.setEqualizerBandLevels(ShortArray(bands.toInt())) + } + + private fun saveBandLevelsToPreferences() { + val manager = equalizerManager ?: return + val bands = manager.getNumberOfBands() + val levels = ShortArray(bands.toInt()) { i -> manager.getBandLevel(i.toShort()) ?: 0 } + Preferences.setEqualizerBandLevels(levels) + } + + private fun restoreEqualizerPreferences() { + val manager = equalizerManager ?: return + eqSwitch.isChecked = Preferences.isEqualizerEnabled() + updateUiEnabledState(eqSwitch.isChecked) + + val bands = manager.getNumberOfBands() + val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500) + val minLevelDb = bandLevelRange[0] / 100 + + val savedLevels = Preferences.getEqualizerBandLevels(bands) + for (i in 0 until bands) { + val savedDb = savedLevels[i] / 100 + manager.setBandLevel(i.toShort(), (savedDb * 100).toShort()) + bandSeekBars.getOrNull(i)?.progress = savedDb - minLevelDb + } + } + +} + +private fun Int.dpToPx(context: Context): Int = + (this * context.resources.displayMetrics.density).toInt() \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java index 4d30ce30..4c47f0c9 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java @@ -60,6 +60,7 @@ import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.UIUtil; import com.cappielloantonio.tempo.viewmodel.HomeViewModel; +import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.google.android.material.snackbar.Snackbar; import com.google.common.util.concurrent.ListenableFuture; @@ -74,6 +75,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { private FragmentHomeTabMusicBinding bind; private MainActivity activity; private HomeViewModel homeViewModel; + private PlaybackViewModel playbackViewModel; private DiscoverSongAdapter discoverSongAdapter; private SimilarTrackAdapter similarMusicAdapter; @@ -101,6 +103,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { bind = FragmentHomeTabMusicBinding.inflate(inflater, container, false); View view = bind.getRoot(); homeViewModel = new ViewModelProvider(requireActivity()).get(HomeViewModel.class); + playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class); init(); @@ -138,12 +141,18 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { super.onStart(); initializeMediaBrowser(); + + MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel); + observeStarredSongsPlayback(); + observeTopSongsPlayback(); } @Override public void onResume() { super.onResume(); refreshSharesView(); + if (topSongAdapter != null) setTopSongsMediaBrowserListenableFuture(); + if (starredSongAdapter != null) setStarredSongsMediaBrowserListenableFuture(); } @Override @@ -477,6 +486,8 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { topSongAdapter = new SongHorizontalAdapter(this, true, false, null); bind.topSongsRecyclerView.setAdapter(topSongAdapter); + setTopSongsMediaBrowserListenableFuture(); + reapplyTopSongsPlayback(); homeViewModel.getChronologySample(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), chronologies -> { if (chronologies == null || chronologies.isEmpty()) { if (bind != null) bind.homeGridTracksSector.setVisibility(View.GONE); @@ -492,6 +503,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { .collect(Collectors.toList()); topSongAdapter.setItems(topSongs); + reapplyTopSongsPlayback(); } }); @@ -515,6 +527,8 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { starredSongAdapter = new SongHorizontalAdapter(this, true, false, null); bind.starredTracksRecyclerView.setAdapter(starredSongAdapter); + setStarredSongsMediaBrowserListenableFuture(); + reapplyStarredSongsPlayback(); homeViewModel.getStarredTracks(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), songs -> { if (songs == null) { if (bind != null) bind.starredTracksSector.setVisibility(View.GONE); @@ -525,6 +539,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { bind.starredTracksRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), UIUtil.getSpanCount(songs.size(), 5), GridLayoutManager.HORIZONTAL, false)); starredSongAdapter.setItems(songs); + reapplyStarredSongsPlayback(); } }); @@ -954,6 +969,8 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION)); activity.setBottomSheetInPeek(true); } + topSongAdapter.notifyDataSetChanged(); + starredSongAdapter.notifyDataSetChanged(); } @Override @@ -1043,4 +1060,58 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { public void onShareLongClick(Bundle bundle) { Navigation.findNavController(requireView()).navigate(R.id.shareBottomSheetDialog, bundle); } + + private void observeStarredSongsPlayback() { + playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> { + if (starredSongAdapter != null) { + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + starredSongAdapter.setPlaybackState(id, playing != null && playing); + } + }); + playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> { + if (starredSongAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + starredSongAdapter.setPlaybackState(id, playing != null && playing); + } + }); + } + + private void observeTopSongsPlayback() { + playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> { + if (topSongAdapter != null) { + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + topSongAdapter.setPlaybackState(id, playing != null && playing); + } + }); + playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> { + if (topSongAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + topSongAdapter.setPlaybackState(id, playing != null && playing); + } + }); + } + + private void reapplyStarredSongsPlayback() { + if (starredSongAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + starredSongAdapter.setPlaybackState(id, playing != null && playing); + } + } + + private void reapplyTopSongsPlayback() { + if (topSongAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + topSongAdapter.setPlaybackState(id, playing != null && playing); + } + } + + private void setTopSongsMediaBrowserListenableFuture() { + topSongAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); + } + + private void setStarredSongsMediaBrowserListenableFuture() { + starredSongAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); + } } 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 99f3c4ca..e63436c7 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 @@ -1,7 +1,11 @@ package com.cappielloantonio.tempo.ui.fragment; import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; import android.os.Bundle; +import android.os.IBinder; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; @@ -24,11 +28,14 @@ import androidx.media3.common.util.RepeatModeUtil; import androidx.media3.common.util.UnstableApi; import androidx.media3.session.MediaBrowser; import androidx.media3.session.SessionToken; +import androidx.navigation.NavController; +import androidx.navigation.NavOptions; import androidx.navigation.fragment.NavHostFragment; import androidx.viewpager2.widget.ViewPager2; import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerControllerBinding; +import com.cappielloantonio.tempo.service.EqualizerManager; import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.dialog.RatingDialog; @@ -68,11 +75,15 @@ public class PlayerControllerFragment extends Fragment { private ImageButton playerOpenQueueButton; private ImageButton playerTrackInfo; private LinearLayout ratingContainer; + private ImageButton equalizerButton; private MainActivity activity; private PlayerBottomSheetViewModel playerBottomSheetViewModel; private ListenableFuture mediaBrowserListenableFuture; + private MediaService.LocalBinder mediaServiceBinder; + private boolean isServiceBound = false; + @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { activity = (MainActivity) getActivity(); @@ -89,6 +100,7 @@ public class PlayerControllerFragment extends Fragment { initMediaListenable(); initMediaLabelButton(); initArtistLabelButton(); + initEqualizerButton(); return view; } @@ -126,6 +138,7 @@ public class PlayerControllerFragment extends Fragment { playerTrackInfo = bind.getRoot().findViewById(R.id.player_info_track); 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); checkAndSetRatingContainerVisibility(); } @@ -426,6 +439,18 @@ public class PlayerControllerFragment extends Fragment { }); } + private void initEqualizerButton() { + equalizerButton.setOnClickListener(v -> { + NavController navController = NavHostFragment.findNavController(this); + NavOptions navOptions = new NavOptions.Builder() + .setLaunchSingleTop(true) + .setPopUpTo(R.id.equalizerFragment, true) + .build(); + navController.navigate(R.id.equalizerFragment, null, navOptions); + if (activity != null) activity.collapseBottomSheetDelayed(); + }); + } + public void goToControllerPage() { playerMediaCoverViewPager.setCurrentItem(0, false); } @@ -461,4 +486,66 @@ public class PlayerControllerFragment extends Fragment { mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_100)); // TODO Resettare lo skip del silenzio } + + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + mediaServiceBinder = (MediaService.LocalBinder) service; + isServiceBound = true; + checkEqualizerBands(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mediaServiceBinder = null; + isServiceBound = false; + } + }; + + private void bindMediaService() { + Intent intent = new Intent(requireActivity(), MediaService.class); + intent.setAction(MediaService.ACTION_BIND_EQUALIZER); + requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE); + isServiceBound = true; + } + + private void checkEqualizerBands() { + if (mediaServiceBinder != null) { + EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager(); + short numBands = eqManager.getNumberOfBands(); + + if (equalizerButton != null) { + if (numBands == 0) { + equalizerButton.setVisibility(View.GONE); + + ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams(); + params.startToEnd = ConstraintLayout.LayoutParams.UNSET; + params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID; + playerOpenQueueButton.setLayoutParams(params); + } else { + equalizerButton.setVisibility(View.VISIBLE); + + ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams(); + params.startToStart = ConstraintLayout.LayoutParams.UNSET; + params.startToEnd = R.id.player_open_equalizer_button; + playerOpenQueueButton.setLayoutParams(params); + } + } + } + } + + @Override + public void onResume() { + super.onResume(); + bindMediaService(); + } + + @Override + public void onPause() { + super.onPause(); + if (isServiceBound) { + requireActivity().unbindService(serviceConnection); + isServiceBound = false; + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerQueueFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerQueueFragment.java index f53a2247..06536cd6 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerQueueFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerQueueFragment.java @@ -23,6 +23,7 @@ import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.ui.adapter.PlayerSongQueueAdapter; import com.cappielloantonio.tempo.util.Constants; +import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -38,6 +39,7 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { private InnerFragmentPlayerQueueBinding bind; private PlayerBottomSheetViewModel playerBottomSheetViewModel; + private PlaybackViewModel playbackViewModel; private ListenableFuture mediaBrowserListenableFuture; private PlayerSongQueueAdapter playerSongQueueAdapter; @@ -48,6 +50,7 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { View view = bind.getRoot(); playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class); + playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class); initQueueRecyclerView(); @@ -59,6 +62,9 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { super.onStart(); initializeBrowser(); bindMediaController(); + + MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel); + observePlayback(); } @Override @@ -110,9 +116,12 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { playerSongQueueAdapter = new PlayerSongQueueAdapter(this); bind.playerQueueRecyclerView.setAdapter(playerSongQueueAdapter); + reapplyPlayback(); + playerBottomSheetViewModel.getQueueSong().observe(getViewLifecycleOwner(), queue -> { if (queue != null) { playerSongQueueAdapter.setItems(queue.stream().map(item -> (Child) item).collect(Collectors.toList())); + reapplyPlayback(); } }); @@ -216,4 +225,27 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback { public void onMediaClick(Bundle bundle) { MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION)); } + + private void observePlayback() { + playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> { + if (playerSongQueueAdapter != null) { + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + playerSongQueueAdapter.setPlaybackState(id, playing != null && playing); + } + }); + playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> { + if (playerSongQueueAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + playerSongQueueAdapter.setPlaybackState(id, playing != null && playing); + } + }); + } + + private void reapplyPlayback() { + if (playerSongQueueAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + playerSongQueueAdapter.setPlaybackState(id, playing != null && playing); + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java index 55b46ff2..7fefe7db 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java @@ -37,6 +37,7 @@ import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MusicUtil; +import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.cappielloantonio.tempo.viewmodel.PlaylistPageViewModel; import com.google.common.util.concurrent.ListenableFuture; @@ -49,6 +50,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback { private FragmentPlaylistPageBinding bind; private MainActivity activity; private PlaylistPageViewModel playlistPageViewModel; + private PlaybackViewModel playbackViewModel; private SongHorizontalAdapter songHorizontalAdapter; @@ -94,6 +96,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback { bind = FragmentPlaylistPageBinding.inflate(inflater, container, false); View view = bind.getRoot(); playlistPageViewModel = new ViewModelProvider(requireActivity()).get(PlaylistPageViewModel.class); + playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class); init(); initAppBar(); @@ -109,6 +112,15 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback { super.onStart(); initializeMediaBrowser(); + + MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel); + observePlayback(); + } + + @Override + public void onResume() { + super.onResume(); + if (songHorizontalAdapter != null) setMediaBrowserListenableFuture(); } @Override @@ -248,8 +260,13 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback { songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null); bind.songRecyclerView.setAdapter(songHorizontalAdapter); + setMediaBrowserListenableFuture(); + reapplyPlayback(); - playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> songHorizontalAdapter.setItems(songs)); + playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> { + songHorizontalAdapter.setItems(songs); + reapplyPlayback(); + }); } private void initializeMediaBrowser() { @@ -270,4 +287,31 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback { public void onMediaLongClick(Bundle bundle) { Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle); } + + private void observePlayback() { + playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> { + if (songHorizontalAdapter != null) { + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + }); + playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> { + if (songHorizontalAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + }); + } + + private void reapplyPlayback() { + if (songHorizontalAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + } + + private void setMediaBrowserListenableFuture() { + songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); + } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java index 8792a98a..c4b5f3b1 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java @@ -4,14 +4,11 @@ import android.content.ComponentName; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.view.inputmethod.EditorInfo; import android.widget.ImageView; import android.widget.TextView; -import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -34,6 +31,7 @@ import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter; import com.cappielloantonio.tempo.ui.adapter.ArtistAdapter; import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter; import com.cappielloantonio.tempo.util.Constants; +import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.cappielloantonio.tempo.viewmodel.SearchViewModel; import com.google.common.util.concurrent.ListenableFuture; @@ -46,6 +44,7 @@ public class SearchFragment extends Fragment implements ClickCallback { private FragmentSearchBinding bind; private MainActivity activity; private SearchViewModel searchViewModel; + private PlaybackViewModel playbackViewModel; private ArtistAdapter artistAdapter; private AlbumAdapter albumAdapter; @@ -61,6 +60,7 @@ public class SearchFragment extends Fragment implements ClickCallback { bind = FragmentSearchBinding.inflate(inflater, container, false); View view = bind.getRoot(); searchViewModel = new ViewModelProvider(requireActivity()).get(SearchViewModel.class); + playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class); initSearchResultView(); initSearchView(); @@ -73,6 +73,15 @@ public class SearchFragment extends Fragment implements ClickCallback { public void onStart() { super.onStart(); initializeMediaBrowser(); + + MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel); + observePlayback(); + } + + @Override + public void onResume() { + super.onResume(); + if (songHorizontalAdapter != null) setMediaBrowserListenableFuture(); } @Override @@ -113,6 +122,9 @@ public class SearchFragment extends Fragment implements ClickCallback { bind.searchResultTracksRecyclerView.setHasFixedSize(true); songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null); + setMediaBrowserListenableFuture(); + reapplyPlayback(); + bind.searchResultTracksRecyclerView.setAdapter(songHorizontalAdapter); } @@ -260,6 +272,7 @@ public class SearchFragment extends Fragment implements ClickCallback { @Override public void onMediaClick(Bundle bundle) { MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION)); + songHorizontalAdapter.notifyDataSetChanged(); activity.setBottomSheetInPeek(true); } @@ -287,4 +300,31 @@ public class SearchFragment extends Fragment implements ClickCallback { public void onArtistLongClick(Bundle bundle) { Navigation.findNavController(requireView()).navigate(R.id.artistBottomSheetDialog, bundle); } + + private void observePlayback() { + playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> { + if (songHorizontalAdapter != null) { + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + }); + playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> { + if (songHorizontalAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + }); + } + + private void reapplyPlayback() { + if (songHorizontalAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + } + + private void setMediaBrowserListenableFuture() { + songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); + } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java index e8ddf2b2..aa33631c 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java @@ -1,9 +1,13 @@ package com.cappielloantonio.tempo.ui.fragment; +import android.content.ComponentName; +import android.content.Context; import android.content.Intent; +import android.content.ServiceConnection; import android.media.audiofx.AudioEffect; import android.os.Bundle; import android.os.Handler; +import android.os.IBinder; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -18,6 +22,9 @@ import androidx.appcompat.app.AppCompatDelegate; import androidx.core.os.LocaleListCompat; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.util.UnstableApi; +import androidx.navigation.NavController; +import androidx.navigation.NavOptions; +import androidx.navigation.fragment.NavHostFragment; import androidx.preference.ListPreference; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; @@ -28,6 +35,8 @@ import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.helper.ThemeHelper; import com.cappielloantonio.tempo.interfaces.DialogClickCallback; import com.cappielloantonio.tempo.interfaces.ScanCallback; +import com.cappielloantonio.tempo.service.EqualizerManager; +import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.dialog.DeleteDownloadStorageDialog; import com.cappielloantonio.tempo.ui.dialog.DownloadStorageDialog; @@ -51,6 +60,9 @@ public class SettingsFragment extends PreferenceFragmentCompat { private ActivityResultLauncher someActivityResultLauncher; + private MediaService.LocalBinder mediaServiceBinder; + private boolean isServiceBound = false; + @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -86,7 +98,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { public void onResume() { super.onResume(); - checkEqualizer(); + checkSystemEqualizer(); checkCacheStorage(); checkStorage(); @@ -102,6 +114,9 @@ public class SettingsFragment extends PreferenceFragmentCompat { actionChangeDownloadStorage(); actionDeleteDownloadStorage(); actionKeepScreenOn(); + + bindMediaService(); + actionAppEqualizer(); } @Override @@ -124,8 +139,8 @@ public class SettingsFragment extends PreferenceFragmentCompat { } } - private void checkEqualizer() { - Preference equalizer = findPreference("equalizer"); + private void checkSystemEqualizer() { + Preference equalizer = findPreference("system_equalizer"); if (equalizer == null) return; @@ -353,4 +368,63 @@ public class SettingsFragment extends PreferenceFragmentCompat { return true; }); } + + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + mediaServiceBinder = (MediaService.LocalBinder) service; + isServiceBound = true; + checkEqualizerBands(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mediaServiceBinder = null; + isServiceBound = false; + } + }; + + private void bindMediaService() { + Intent intent = new Intent(requireActivity(), MediaService.class); + intent.setAction(MediaService.ACTION_BIND_EQUALIZER); + requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE); + isServiceBound = true; + } + + private void checkEqualizerBands() { + if (mediaServiceBinder != null) { + EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager(); + short numBands = eqManager.getNumberOfBands(); + Preference appEqualizer = findPreference("app_equalizer"); + if (appEqualizer != null) { + appEqualizer.setVisible(numBands > 0); + } + } + } + + private void actionAppEqualizer() { + Preference appEqualizer = findPreference("app_equalizer"); + if (appEqualizer != null) { + appEqualizer.setOnPreferenceClickListener(preference -> { + NavController navController = NavHostFragment.findNavController(this); + NavOptions navOptions = new NavOptions.Builder() + .setLaunchSingleTop(true) + .setPopUpTo(R.id.equalizerFragment, true) + .build(); + activity.setBottomNavigationBarVisibility(true); + activity.setBottomSheetVisibility(true); + navController.navigate(R.id.equalizerFragment, null, navOptions); + return true; + }); + } + } + + @Override + public void onPause() { + super.onPause(); + if (isServiceBound) { + requireActivity().unbindService(serviceConnection); + isServiceBound = false; + } + } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SongListPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SongListPageFragment.java index fcaeec84..a524e7c7 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SongListPageFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SongListPageFragment.java @@ -36,6 +36,7 @@ import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter; import com.cappielloantonio.tempo.util.Constants; +import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.cappielloantonio.tempo.viewmodel.SongListPageViewModel; import com.google.common.util.concurrent.ListenableFuture; @@ -49,6 +50,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback { private FragmentSongListPageBinding bind; private MainActivity activity; private SongListPageViewModel songListPageViewModel; + private PlaybackViewModel playbackViewModel; private SongHorizontalAdapter songHorizontalAdapter; @@ -69,6 +71,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback { bind = FragmentSongListPageBinding.inflate(inflater, container, false); View view = bind.getRoot(); songListPageViewModel = new ViewModelProvider(requireActivity()).get(SongListPageViewModel.class); + playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class); init(); initAppBar(); @@ -82,6 +85,15 @@ public class SongListPageFragment extends Fragment implements ClickCallback { public void onStart() { super.onStart(); initializeMediaBrowser(); + + MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel); + observePlayback(); + } + + @Override + public void onResume() { + super.onResume(); + setMediaBrowserListenableFuture(); } @Override @@ -191,9 +203,12 @@ public class SongListPageFragment extends Fragment implements ClickCallback { songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null); bind.songListRecyclerView.setAdapter(songHorizontalAdapter); + setMediaBrowserListenableFuture(); + reapplyPlayback(); songListPageViewModel.getSongList().observe(getViewLifecycleOwner(), songs -> { isLoading = false; songHorizontalAdapter.setItems(songs); + reapplyPlayback(); setSongListPageSubtitle(songs); }); @@ -325,4 +340,31 @@ public class SongListPageFragment extends Fragment implements ClickCallback { public void onMediaLongClick(Bundle bundle) { Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle); } + + private void observePlayback() { + playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> { + if (songHorizontalAdapter != null) { + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + }); + playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> { + if (songHorizontalAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + }); + } + + private void reapplyPlayback() { + if (songHorizontalAdapter != null) { + String id = playbackViewModel.getCurrentSongId().getValue(); + Boolean playing = playbackViewModel.getIsPlaying().getValue(); + songHorizontalAdapter.setPlaybackState(id, playing != null && playing); + } + } + + private void setMediaBrowserListenableFuture() { + songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); + } } \ No newline at end of file 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 a8cafc4a..238b4136 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java @@ -78,32 +78,26 @@ public final class DownloadUtil { return httpDataSourceFactory; } - public static synchronized DataSource.Factory getDataSourceFactory(Context context) { - if (dataSourceFactory == null) { - context = context.getApplicationContext(); + public static synchronized DataSource.Factory getUpstreamDataSourceFactory(Context context) { + DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory()); + dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); + return dataSourceFactory; + } - DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory()); - - if (Preferences.getStreamingCacheSize() > 0) { - CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory() - .setCache(getStreamingCache(context)) - .setUpstreamDataSourceFactory(upstreamFactory); - - ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory( - new StreamingCacheDataSource.Factory(streamCacheFactory), - dataSpec -> { - DataSpec.Builder builder = dataSpec.buildUpon(); - builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN); - return builder.build(); - } - ); - - dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context)); - } else { - dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); - } - } + public static synchronized DataSource.Factory getCacheDataSourceFactory(Context context) { + CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory() + .setCache(getStreamingCache(context)) + .setUpstreamDataSourceFactory(getUpstreamDataSourceFactory(context)); + ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory( + new StreamingCacheDataSource.Factory(streamCacheFactory), + dataSpec -> { + DataSpec.Builder builder = dataSpec.buildUpon(); + builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN); + return builder.build(); + } + ); + dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context)); return dataSourceFactory; } diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt b/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt new file mode 100644 index 00000000..31dc172a --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt @@ -0,0 +1,69 @@ +package com.cappielloantonio.tempo.util + +import android.content.Context +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider +import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy +import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.ExtractorsFactory + +@UnstableApi +class DynamicMediaSourceFactory( + private val context: Context +) : MediaSource.Factory { + + override fun createMediaSource(mediaItem: MediaItem): MediaSource { + val mediaType: String? = mediaItem.mediaMetadata.extras?.getString("type", "") + + val streamingCacheSize = Preferences.getStreamingCacheSize() + val bypassCache = mediaType == Constants.MEDIA_TYPE_RADIO + + val useUpstream = when { + streamingCacheSize.toInt() == 0 -> true + streamingCacheSize > 0 && bypassCache -> true + streamingCacheSize > 0 && !bypassCache -> false + else -> true + } + + val dataSourceFactory: DataSource.Factory = if (useUpstream) { + DownloadUtil.getUpstreamDataSourceFactory(context) + } else { + DownloadUtil.getCacheDataSourceFactory(context) + } + + return when { + mediaItem.localConfiguration?.mimeType == MimeTypes.APPLICATION_M3U8 || + mediaItem.localConfiguration?.uri?.lastPathSegment?.endsWith(".m3u8", ignoreCase = true) == true -> { + HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) + } + + else -> { + val extractorsFactory: ExtractorsFactory = DefaultExtractorsFactory() + ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + .createMediaSource(mediaItem) + } + } + } + + override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory { + TODO("Not yet implemented") + } + + override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory { + TODO("Not yet implemented") + } + + override fun getSupportedTypes(): IntArray { + return intArrayOf( + C.CONTENT_TYPE_HLS, + C.CONTENT_TYPE_OTHER + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt index 8c77ab13..92cb30cd 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt @@ -69,7 +69,8 @@ object Preferences { private const val NEXT_UPDATE_CHECK = "next_update_check" private const val CONTINUOUS_PLAY = "continuous_play" private const val LAST_INSTANT_MIX = "last_instant_mix" - + private const val EQUALIZER_ENABLED = "equalizer_enabled" + private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels" @JvmStatic fun getServer(): String? { @@ -538,4 +539,31 @@ object Preferences { LAST_INSTANT_MIX, 0 ) + 5000 < System.currentTimeMillis() } + + @JvmStatic + fun setEqualizerEnabled(enabled: Boolean) { + App.getInstance().preferences.edit().putBoolean(EQUALIZER_ENABLED, enabled).apply() + } + + @JvmStatic + fun isEqualizerEnabled(): Boolean { + return App.getInstance().preferences.getBoolean(EQUALIZER_ENABLED, false) + } + + @JvmStatic + fun setEqualizerBandLevels(bandLevels: ShortArray) { + val asString = bandLevels.joinToString(",") + App.getInstance().preferences.edit().putString(EQUALIZER_BAND_LEVELS, asString).apply() + } + + @JvmStatic + fun getEqualizerBandLevels(bandCount: Short): ShortArray { + val str = App.getInstance().preferences.getString(EQUALIZER_BAND_LEVELS, null) + if (str.isNullOrBlank()) { + return ShortArray(bandCount.toInt()) + } + val parts = str.split(",") + if (parts.size < bandCount) return ShortArray(bandCount.toInt()) + return ShortArray(bandCount.toInt()) { i -> parts[i].toShortOrNull() ?: 0 } + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaybackViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaybackViewModel.java new file mode 100644 index 00000000..b1808d9f --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaybackViewModel.java @@ -0,0 +1,35 @@ +package com.cappielloantonio.tempo.viewmodel; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import java.util.Objects; + +public class PlaybackViewModel extends ViewModel { + + private final MutableLiveData currentSongId = new MutableLiveData<>(null); + private final MutableLiveData isPlaying = new MutableLiveData<>(false); + + public LiveData getCurrentSongId() { + return currentSongId; + } + + public LiveData getIsPlaying() { + return isPlaying; + } + + public void update(String songId, boolean playing) { + if (!Objects.equals(currentSongId.getValue(), songId)) { + currentSongId.postValue(songId); + } + if (!Objects.equals(isPlaying.getValue(), playing)) { + isPlaying.postValue(playing); + } + } + + public void clear() { + currentSongId.postValue(null); + isPlaying.postValue(false); + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_eq.xml b/app/src/main/res/drawable/ic_eq.xml new file mode 100644 index 00000000..5f3a8b46 --- /dev/null +++ b/app/src/main/res/drawable/ic_eq.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ui_eq_not_supported.xml b/app/src/main/res/drawable/ui_eq_not_supported.xml new file mode 100644 index 00000000..fc8a364b --- /dev/null +++ b/app/src/main/res/drawable/ui_eq_not_supported.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 7ad0250e..cb3ae9c6 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 @@ -382,11 +382,23 @@ android:layout_height="wrap_content" android:padding="16dp" android:background="?attr/selectableItemBackgroundBorderless" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintStart_toEndOf="@+id/player_open_equalizer_button" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:srcCompat="@drawable/ic_queue" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_equalizer.xml b/app/src/main/res/layout/fragment_equalizer.xml new file mode 100644 index 00000000..dcf2191f --- /dev/null +++ b/app/src/main/res/layout/fragment_equalizer.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + +