diff --git a/USAGE.md b/USAGE.md index e9711875..b045b9b1 100644 --- a/USAGE.md +++ b/USAGE.md @@ -78,6 +78,9 @@ On the main player control screen, tapping on the artwork will reveal a small co 1. Downloads the track (there is a notification if the android screen but not a pop toast currently ) 2. Adds track to playlist - pops up playlist dialog. 3. Adds tracks to the queue via instant mix function + * TBD: what is the _instant mix function_? + * Uses [getSimilarSongs](https://opensubsonic.netlify.app/docs/endpoints/getsimilarsongs/) of OpenSubsonic API. + Which tracks to be mixed depends on the server implementation. For example, Navidrome gets 15 similar artists from LastFM, then 20 top songs from each. 4. Saves play queue (if the feature is enabled in the settings) * if the setting is not enabled, it toggles a view of the lyrics if available (slides to the right) @@ -163,4 +166,4 @@ For additional help: --- -*Note: This app requires a pre-existing Subsonic-compatible server with music content.* \ No newline at end of file +*Note: This app requires a pre-existing Subsonic-compatible server with music content.* diff --git a/app/build.gradle b/app/build.gradle index 2cc90af4..53758756 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -110,12 +110,12 @@ dependencies { implementation 'com.github.bumptech.glide:annotations:4.16.0' // Media3 - implementation 'androidx.media3:media3-session:1.5.1' - implementation 'androidx.media3:media3-common:1.5.1' - implementation 'androidx.media3:media3-exoplayer:1.5.1' - implementation 'androidx.media3:media3-ui:1.5.1' - implementation 'androidx.media3:media3-exoplayer-hls:1.5.1' - tempusImplementation 'androidx.media3:media3-cast:1.5.1' + implementation 'androidx.media3:media3-session:1.8.0' + implementation 'androidx.media3:media3-common:1.8.0' + implementation 'androidx.media3:media3-exoplayer:1.8.0' + implementation 'androidx.media3:media3-ui:1.8.0' + implementation 'androidx.media3:media3-exoplayer-hls:1.8.0' + tempusImplementation 'androidx.media3:media3-cast:1.8.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' diff --git a/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt index c669a7d3..bb237c62 100644 --- a/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -5,18 +5,20 @@ import android.app.PendingIntent.FLAG_IMMUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.app.TaskStackBuilder import android.content.Intent +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities import android.os.Binder import android.os.Bundle import android.os.IBinder import android.os.Handler import android.os.Looper +import android.util.Log import androidx.media3.common.* import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.MediaSource -import androidx.media3.exoplayer.source.TrackGroupArray -import androidx.media3.exoplayer.trackselection.TrackSelectionArray import androidx.media3.session.* import androidx.media3.session.MediaSession.ControllerInfo import com.cappielloantonio.tempo.R @@ -43,6 +45,7 @@ class MediaService : MediaLibraryService() { private lateinit var mediaLibrarySession: MediaLibrarySession private lateinit var shuffleCommands: List private lateinit var repeatCommands: List + private lateinit var networkCallback: CustomNetworkCallback lateinit var equalizerManager: EqualizerManager private var customLayout = ImmutableList.of() @@ -81,6 +84,38 @@ class MediaService : MediaLibraryService() { const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER" } + fun updateMediaItems() { + Log.d("MediaService", "update items"); + val n = player.mediaItemCount + val k = player.currentMediaItemIndex + val current = player.currentPosition + val items = (0 .. n-1).map{i -> MappingUtil.mapMediaItem(player.getMediaItemAt(i))} + player.clearMediaItems() + player.setMediaItems(items, k, current) + } + + inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() { + var wasWifi = false + + init { + val manager = getSystemService(ConnectivityManager::class.java) + val network = manager.activeNetwork + val capabilities = manager.getNetworkCapabilities(network) + if (capabilities != null) + wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } + + override fun onCapabilitiesChanged(network : Network, networkCapabilities : NetworkCapabilities) { + val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + if (isWifi != wasWifi) { + wasWifi = isWifi + widgetUpdateHandler.post(Runnable { + updateMediaItems() + }) + } + } + } + override fun onCreate() { super.onCreate() @@ -90,6 +125,7 @@ class MediaService : MediaLibraryService() { restorePlayerFromQueue() initializePlayerListener() initializeEqualizerManager() + initializeNetworkListener() setPlayer(player) } @@ -99,6 +135,7 @@ class MediaService : MediaLibraryService() { } override fun onDestroy() { + releaseNetworkCallback() equalizerManager.release() stopWidgetUpdates() releasePlayer() @@ -275,6 +312,12 @@ class MediaService : MediaLibraryService() { } } + private fun initializeNetworkListener() { + networkCallback = CustomNetworkCallback() + getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(networkCallback) + updateMediaItems() + } + private fun restorePlayerFromQueue() { if (player.mediaItemCount > 0) return @@ -398,6 +441,10 @@ class MediaService : MediaLibraryService() { mediaLibrarySession.release() } + private fun releaseNetworkCallback() { + getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback) + } + @SuppressLint("PrivateResource") private fun getShuffleCommandButton(sessionCommand: SessionCommand): CommandButton { val isOn = sessionCommand.customAction == CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCatalogueAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCatalogueAdapter.java index e2d0563e..ba663c37 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCatalogueAdapter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCatalogueAdapter.java @@ -105,16 +105,6 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter groupSong(List songs) { switch (view) { case Constants.DOWNLOAD_TYPE_TRACK: diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PodcastChannelCatalogueAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PodcastChannelCatalogueAdapter.java index 3eb6bc02..f3784175 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PodcastChannelCatalogueAdapter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/PodcastChannelCatalogueAdapter.java @@ -95,16 +95,6 @@ public class PodcastChannelCatalogueAdapter extends RecyclerView.Adapter artistAdapter.setItems(artistList)); + artistCatalogueViewModel.getArtistList().observe(getViewLifecycleOwner(), artistList -> { + artistAdapter.setItems(artistList); + artistAdapter.sort(Preferences.getArtistSortOrder()); + }); bind.artistCatalogueRecyclerView.setOnTouchListener((v, event) -> { hideKeyboard(v); @@ -192,6 +196,9 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback { } else if (menuItem.getItemId() == R.id.menu_artist_sort_random) { artistAdapter.sort(Constants.ARTIST_ORDER_BY_RANDOM); return true; + } else if (menuItem.getItemId() == R.id.menu_artist_sort_album_count) { + artistAdapter.sort(Constants.ARTIST_ORDER_BY_ALBUM_COUNT); + return true; } return false; diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java index be2b3eb0..12ca2434 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java @@ -117,14 +117,12 @@ public class DownloadFragment extends Fragment implements ClickCallback { if (songs.isEmpty()) { if (bind != null) { bind.emptyDownloadLayout.setVisibility(View.VISIBLE); - bind.fragmentDownloadNestedScrollView.setVisibility(View.GONE); bind.downloadDownloadedSector.setVisibility(View.GONE); bind.downloadedGroupByImageView.setVisibility(View.GONE); } } else { if (bind != null) { bind.emptyDownloadLayout.setVisibility(View.GONE); - bind.fragmentDownloadNestedScrollView.setVisibility(View.VISIBLE); bind.downloadDownloadedSector.setVisibility(View.VISIBLE); bind.downloadedGroupByImageView.setVisibility(View.VISIBLE); 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 bf2a1a72..c6a4e3a4 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt @@ -40,6 +40,7 @@ object Constants { const val ARTIST_STARRED = "ARTIST_STARRED" const val ARTIST_ORDER_BY_NAME = "ARTIST_ORDER_BY_NAME" const val ARTIST_ORDER_BY_RANDOM = "ARTIST_ORDER_BY_RANDOM" + const val ARTIST_ORDER_BY_ALBUM_COUNT = "ARTIST_ORDER_BY_ALBUM_COUNT" const val ARTIST_ORDER_BY_MOST_RECENTLY_STARRED = "ARTIST_ORDER_BY_MOST_RECENTLY_STARRED" const val ARTIST_ORDER_BY_LEAST_RECENTLY_STARRED = "ARTIST_ORDER_BY_LEAST_RECENTLY_STARRED" 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 558aa218..1222804b 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java @@ -115,6 +115,22 @@ public class MappingUtil { .build(); } + public static MediaItem mapMediaItem(MediaItem old) { + Uri uri = old.requestMetadata.mediaUri == null ? null : MusicUtil.updateStreamUri(old.requestMetadata.mediaUri); + return new MediaItem.Builder() + .setMediaId(old.mediaId) + .setMediaMetadata(old.mediaMetadata) + .setRequestMetadata( + new MediaItem.RequestMetadata.Builder() + .setMediaUri(uri) + .setExtras(old.requestMetadata.extras) + .build() + ) + .setMimeType(MimeTypes.BASE_TYPE_AUDIO) + .setUri(uri) + .build(); + } + public static List mapDownloads(List items) { ArrayList downloads = new ArrayList<>(); 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 696b5b9d..cd2c7a36 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/MusicUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/MusicUtil.java @@ -21,11 +21,16 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; public class MusicUtil { private static final String TAG = "MusicUtil"; + private static final Pattern BITRATE_PATTERN = Pattern.compile("&maxBitRate=\\d+"); + private static final Pattern FORMAT_PATTERN = Pattern.compile("&format=\\w+"); + public static Uri getStreamUri(String id) { Map params = App.getSubsonicClientInstance(false).getParams(); @@ -61,6 +66,24 @@ public class MusicUtil { return Uri.parse(uri.toString()); } + public static Uri updateStreamUri(Uri uri) { + String s = uri.toString(); + Matcher m1 = BITRATE_PATTERN.matcher(s); + s = m1.replaceAll(""); + Matcher m2 = FORMAT_PATTERN.matcher(s); + s = m2.replaceAll(""); + s = s.replace("&estimateContentLength=true", ""); + + if (!Preferences.isServerPrioritized()) + s += "&maxBitRate=" + getBitratePreference(); + if (!Preferences.isServerPrioritized()) + s += "&format=" + getTranscodingFormatPreference(); + if (Preferences.askForEstimateContentLength()) + s += "&estimateContentLength=true"; + + return Uri.parse(s); + } + public static Uri getDownloadUri(String id) { StringBuilder uri = new StringBuilder(); 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 4815fb83..42ffa524 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt @@ -79,6 +79,7 @@ object Preferences { private const val ALBUM_DETAIL = "album_detail" private const val ALBUM_SORT_ORDER = "album_sort_order" private const val DEFAULT_ALBUM_SORT_ORDER = Constants.ALBUM_ORDER_BY_NAME + private const val ARTIST_SORT_BY_ALBUM_COUNT= "artist_sort_by_album_count" @JvmStatic fun getServer(): String? { @@ -656,4 +657,14 @@ object Preferences { fun setAlbumSortOrder(sortOrder: String) { App.getInstance().preferences.edit().putString(ALBUM_SORT_ORDER, sortOrder).apply() } + + @JvmStatic + fun getArtistSortOrder(): String { + val sort_by_album_count = App.getInstance().preferences.getBoolean(ARTIST_SORT_BY_ALBUM_COUNT, false) + Log.d("Preferences", "getSortOrder") + if (sort_by_album_count) + return Constants.ARTIST_ORDER_BY_ALBUM_COUNT + else + return Constants.ARTIST_ORDER_BY_NAME + } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_album_page.xml b/app/src/main/res/layout/fragment_album_page.xml index 411d5c1c..1cf81bc3 100644 --- a/app/src/main/res/layout/fragment_album_page.xml +++ b/app/src/main/res/layout/fragment_album_page.xml @@ -14,22 +14,21 @@ app:layout_collapseMode="pin" app:navigationIcon="@drawable/ic_arrow_back" /> - - - + + android:layout_height="wrap_content"> + android:paddingTop="8dp" + app:layout_scrollFlags="scroll|exitUntilCollapsed"> + - - - - - - - - - - - - - - + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_download.xml b/app/src/main/res/layout/fragment_download.xml index 68d3f84b..47fd92e0 100644 --- a/app/src/main/res/layout/fragment_download.xml +++ b/app/src/main/res/layout/fragment_download.xml @@ -1,9 +1,10 @@ - + android:layout_height="match_parent" + android:orientation="vertical"> - + tools:visibility="visible"> - + + + android:text="@string/download_shuffle_all_subtitle" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/downloaded_text_view_refreshable" /> - + - + - + + - - - - - - - - - + + diff --git a/app/src/main/res/layout/fragment_playlist_page.xml b/app/src/main/res/layout/fragment_playlist_page.xml index d409601a..87d7e858 100644 --- a/app/src/main/res/layout/fragment_playlist_page.xml +++ b/app/src/main/res/layout/fragment_playlist_page.xml @@ -27,7 +27,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?attr/colorSurface" - app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"> + app:layout_scrollFlags="scroll|exitUntilCollapsed"> + \ 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 c5b35a88..2dafec49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -200,6 +200,7 @@ Artist Name Random + Album Count Recently added Recently played Most played @@ -527,4 +528,6 @@ Show album detail If enabled, show the album details like genre, song count etc. on the album page + Sort artists by album count + If enabled, sort the artists by album count. Sort by name if disabled. diff --git a/app/src/main/res/xml/global_preferences.xml b/app/src/main/res/xml/global_preferences.xml index 4610e255..a8caeb8b 100644 --- a/app/src/main/res/xml/global_preferences.xml +++ b/app/src/main/res/xml/global_preferences.xml @@ -116,6 +116,12 @@ android:summary="@string/settings_album_detail_summary" android:key="album_detail" /> + + diff --git a/app/src/tempus/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempus/java/com/cappielloantonio/tempo/service/MediaService.kt index 2ff81ac4..36ea5b26 100644 --- a/app/src/tempus/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempus/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -4,10 +4,14 @@ import android.app.PendingIntent.FLAG_IMMUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.app.TaskStackBuilder import android.content.Intent +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities import android.os.Binder import android.os.IBinder import android.os.Handler import android.os.Looper +import android.util.Log import androidx.core.content.ContextCompat import androidx.media3.cast.CastPlayer import androidx.media3.cast.SessionAvailabilityListener @@ -43,6 +47,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { private lateinit var castPlayer: CastPlayer private lateinit var mediaLibrarySession: MediaLibrarySession private lateinit var librarySessionCallback: MediaLibrarySessionCallback + private lateinit var networkCallback: CustomNetworkCallback lateinit var equalizerManager: EqualizerManager inner class LocalBinder : Binder() { @@ -69,6 +74,38 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { } } + fun updateMediaItems() { + Log.d("MediaService", "update items"); + val n = player.mediaItemCount + val k = player.currentMediaItemIndex + val current = player.currentPosition + val items = (0 .. n-1).map{i -> MappingUtil.mapMediaItem(player.getMediaItemAt(i))} + player.clearMediaItems() + player.setMediaItems(items, k, current) + } + + inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() { + var wasWifi = false + + init { + val manager = getSystemService(ConnectivityManager::class.java) + val network = manager.activeNetwork + val capabilities = manager.getNetworkCapabilities(network) + if (capabilities != null) + wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } + + override fun onCapabilitiesChanged(network : Network, networkCapabilities : NetworkCapabilities) { + val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + if (isWifi != wasWifi) { + wasWifi = isWifi + widgetUpdateHandler.post(Runnable { + updateMediaItems() + }) + } + } + } + override fun onCreate() { super.onCreate() @@ -79,6 +116,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { initializePlayerListener() initializeCastPlayer() initializeEqualizerManager() + initializeNetworkListener() setPlayer( null, @@ -99,6 +137,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { } override fun onDestroy() { + releaseNetworkCallback() equalizerManager.release() stopWidgetUpdates() releasePlayer() @@ -178,6 +217,12 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { .build() } + private fun initializeNetworkListener() { + networkCallback = CustomNetworkCallback() + getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(networkCallback) + updateMediaItems() + } + private fun restorePlayerFromQueue() { if (player.mediaItemCount > 0) return @@ -374,6 +419,10 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { automotiveRepository.deleteMetadata() } + private fun releaseNetworkCallback() { + getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback) + } + private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) override fun onCastSessionAvailable() {