From d0cffe466bbbbecd3dd191fd7c4562bf69c0a838 Mon Sep 17 00:00:00 2001 From: Slany Date: Wed, 7 Sep 2022 21:20:25 +0200 Subject: [PATCH] Support android auto (#47) --- app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 16 ++ .../kotlin/it/vfsfitvnm/vimusic/Database.kt | 4 + .../it/vfsfitvnm/vimusic/enums/MediaIDType.kt | 16 ++ .../service/PlayerMediaBrowserService.kt | 206 ++++++++++++++++++ .../vimusic/service/PlayerService.kt | 30 +++ .../vfsfitvnm/vimusic/utils/MediaIDHelper.kt | 77 +++++++ app/src/main/res/drawable/disc_white.xml | 12 + app/src/main/res/drawable/heart_white.xml | 9 + app/src/main/res/drawable/playlist_white.xml | 46 ++++ app/src/main/res/xml/automotive_app_desc.xml | 3 + 11 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/MediaIDType.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerMediaBrowserService.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/MediaIDHelper.kt create mode 100644 app/src/main/res/drawable/disc_white.xml create mode 100644 app/src/main/res/drawable/heart_white.xml create mode 100644 app/src/main/res/drawable/playlist_white.xml create mode 100644 app/src/main/res/xml/automotive_app_desc.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f33b76f..f367341 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,7 +9,7 @@ android { defaultConfig { applicationId = "it.vfsfitvnm.vimusic" - minSdk = 21 + minSdk = 23 targetSdk = 32 versionCode = 16 versionName = "0.5.0" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9d1b0a0..38ba601 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + @@ -93,8 +95,22 @@ android:foregroundServiceType="mediaPlayback"> + + + + + + + + + + + diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index 9350130..a6ab2c4 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -127,6 +127,10 @@ interface Database { @Query("SELECT * FROM Song WHERE id = :id") fun song(id: String): Flow + @Transaction + @Query("SELECT * FROM Song WHERE id = :id") + fun songById(id: String): Flow + @Query("SELECT likedAt FROM Song WHERE id = :songId") fun likedAt(songId: String): Flow diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/MediaIDType.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/MediaIDType.kt new file mode 100644 index 0000000..7d5c04f --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/MediaIDType.kt @@ -0,0 +1,16 @@ +package it.vfsfitvnm.vimusic.enums + +enum class MediaIDType { + Playlist, + RandomFavorites, + RandomSongs, + Song; + + val prefix: String + get() = when (this) { + Song -> "VIMUSIC_SONG_ID_" + Playlist -> "VIMUSIC_PLAYLIST_ID_" + RandomSongs -> "VIMUSIC_RANDOM_SONGS" + RandomFavorites -> "VIMUSIC_RANDOM_FAVORITES" + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerMediaBrowserService.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerMediaBrowserService.kt new file mode 100644 index 0000000..ec93f5b --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerMediaBrowserService.kt @@ -0,0 +1,206 @@ +package it.vfsfitvnm.vimusic.service + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.media.MediaDescription +import android.media.browse.MediaBrowser.MediaItem +import android.net.Uri +import android.os.Bundle +import android.os.IBinder +import android.os.Process +import android.service.media.MediaBrowserService +import it.vfsfitvnm.vimusic.BuildConfig +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.enums.PlaylistSortBy +import it.vfsfitvnm.vimusic.enums.SongSortBy +import it.vfsfitvnm.vimusic.enums.SortOrder +import it.vfsfitvnm.vimusic.utils.MediaIDHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +class PlayerMediaBrowserService : MediaBrowserService() { + + var playerServiceBinder: PlayerService.Binder? = null + var isBound = false + + override fun onCreate() { + super.onCreate() + val intent = Intent(this, PlayerService::class.java) + bindService(intent, playerConnection, Context.BIND_AUTO_CREATE) + } + + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): BrowserRoot? { + if (!isCallerAllowed(clientPackageName, clientUid)) { + return null + } + val extras = Bundle() + extras.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_LIST_ITEM_HINT_VALUE) + return BrowserRoot(MEDIA_ROOT_ID, extras) + } + + override fun onLoadChildren( + parentId: String, + result: Result> + ) { + when (parentId) { + MEDIA_ROOT_ID -> result.sendResult(createMenuMediaItem()) + MEDIA_PLAYLISTS_ID -> result.sendResult(createPlaylistsMediaItem()) + MEDIA_FAVORITES_ID -> result.sendResult(createFavoritesMediaItem()) + MEDIA_SONGS_ID -> result.sendResult(createSongsMediaItem()) + } + } + + private fun createFavoritesMediaItem(): MutableList { + val favorites = runBlocking(Dispatchers.IO) { + Database.favorites().first() + }.map { entry -> + MediaItem( + MediaDescription.Builder() + .setMediaId(MediaIDHelper.createMediaIdForSong(entry.id)) + .setTitle(entry.title) + .setSubtitle(entry.artistsText) + .setIconUri( + Uri.parse(entry.thumbnailUrl) + ) + .build(), MediaItem.FLAG_PLAYABLE + ) + }.toCollection(mutableListOf()) + if (favorites.isNotEmpty()) { + favorites.add( + 0, MediaItem( + MediaDescription.Builder() + .setMediaId(MediaIDHelper.createMediaIdForRandomFavorites()) + .setTitle("Play all random") + .setIconUri( + Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/drawable/shuffle") + ) + .build(), MediaItem.FLAG_PLAYABLE + ) + ) + } + return favorites + } + + private fun createSongsMediaItem(): MutableList { + val songs = runBlocking(Dispatchers.IO) { + Database.songs(SongSortBy.DateAdded, SortOrder.Descending).first() + }.map { entry -> + MediaItem( + MediaDescription.Builder() + .setMediaId(MediaIDHelper.createMediaIdForSong(entry.id)) + .setTitle(entry.title) + .setSubtitle(entry.artistsText) + .setIconUri( + Uri.parse(entry.thumbnailUrl) + ) + .build(), MediaItem.FLAG_PLAYABLE + ) + }.toCollection(mutableListOf()) + if (songs.isNotEmpty()) { + songs.add( + 0, MediaItem( + MediaDescription.Builder() + .setMediaId(MediaIDHelper.createMediaIdForRandomSongs()) + .setTitle("Play all random") + .setIconUri( + Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/drawable/shuffle") + ) + .build(), MediaItem.FLAG_PLAYABLE + ) + ) + } + return songs + } + + private fun createPlaylistsMediaItem(): MutableList { + return runBlocking(Dispatchers.IO) { + Database.playlistPreviews(PlaylistSortBy.DateAdded, SortOrder.Descending).first() + }.map { entry -> + MediaItem( + MediaDescription.Builder() + .setMediaId(MediaIDHelper.createMediaIdForPlaylist(entry.playlist.id)) + .setTitle(entry.playlist.name) + .setSubtitle("${entry.songCount} songs") + .setIconUri( + Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/drawable/playlist") + ) + .build(), MediaItem.FLAG_PLAYABLE + ) + }.toCollection(mutableListOf()) + } + + private fun createMenuMediaItem(): MutableList { + return mutableListOf( + MediaItem( + MediaDescription.Builder() + .setMediaId(MEDIA_PLAYLISTS_ID) + .setTitle("Playlists") + .setIconUri( + Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/drawable/playlist_white") + ) + .build(), MediaItem.FLAG_BROWSABLE + ), MediaItem( + MediaDescription.Builder() + .setMediaId(MEDIA_FAVORITES_ID) + .setTitle("Favorites") + .setIconUri( + Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/drawable/heart_white") + ) + .build(), MediaItem.FLAG_BROWSABLE + ), MediaItem( + MediaDescription.Builder() + .setMediaId(MEDIA_SONGS_ID) + .setTitle("Songs") + .setIconUri( + Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/drawable/disc_white") + ) + .build(), MediaItem.FLAG_BROWSABLE + ) + ) + } + + private val playerConnection = object : ServiceConnection { + override fun onServiceConnected( + className: ComponentName, + service: IBinder + ) { + playerServiceBinder = service as PlayerService.Binder + isBound = true + sessionToken = playerServiceBinder?.mediaSession?.sessionToken + } + + override fun onServiceDisconnected(name: ComponentName) { + isBound = false + } + } + + private fun isCallerAllowed( + clientPackageName: String, + clientUid: Int + ): Boolean { + return when { + clientUid == Process.myUid() -> true + clientUid == Process.SYSTEM_UID -> true + ANDROID_AUTO_PACKAGE_NAME == clientPackageName -> true + else -> false + } + } + + companion object { + const val ANDROID_AUTO_PACKAGE_NAME = "com.google.android.projection.gearhead" + const val CONTENT_STYLE_BROWSABLE_HINT = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT" + const val CONTENT_STYLE_LIST_ITEM_HINT_VALUE = 1 + const val MEDIA_ROOT_ID = "VIMUSIC_MEDIA_ROOT_ID" + const val MEDIA_PLAYLISTS_ID = "VIMUSIC_MEDIA_PLAYLISTS_ID" + const val MEDIA_FAVORITES_ID = "VIMUSIC_MEDIA_FAVORITES_ID" + const val MEDIA_SONGS_ID = "VIMUSIC_MEDIA_SONGS_ID" + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt index a5f8528..6d4c37d 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt @@ -13,12 +13,14 @@ import android.content.SharedPreferences import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.Color +import android.media.MediaDescription import android.media.MediaMetadata import android.media.audiofx.AudioEffect import android.media.session.MediaSession import android.media.session.PlaybackState import android.net.Uri import android.os.Build +import android.os.Bundle import android.os.Handler import android.text.format.DateUtils import androidx.compose.runtime.getValue @@ -70,6 +72,7 @@ import it.vfsfitvnm.vimusic.models.Event import it.vfsfitvnm.vimusic.models.QueuedMediaItem import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.utils.InvincibleService +import it.vfsfitvnm.vimusic.utils.MediaIDHelper import it.vfsfitvnm.vimusic.utils.RingBuffer import it.vfsfitvnm.vimusic.utils.TimerJob import it.vfsfitvnm.vimusic.utils.YouTubeRadio @@ -322,6 +325,20 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene } else if (mediaItem.mediaMetadata.artworkUri == bitmapProvider.lastUri) { bitmapProvider.listener?.invoke(bitmapProvider.lastBitmap) } + + // On playlist changed, we refresh the mediaSession queue + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) { + mediaSession.setQueue(player.currentTimeline.mediaItems.mapIndexed { index, it -> + MediaSession.QueueItem( + MediaDescription.Builder() + .setMediaId(it.mediaId) + .setTitle(it.mediaMetadata.title) + .setSubtitle(it.mediaMetadata.artist) + .setIconUri(it.mediaMetadata.artworkUri) + .build(), index.toLong() + ) + }) + } } private fun maybeRecoverPlaybackError() { @@ -469,6 +486,10 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene MediaMetadata.METADATA_KEY_ALBUM, player.currentMediaItem?.mediaMetadata?.albumTitle ) + .putBitmap( + MediaMetadata.METADATA_KEY_ALBUM_ART, + if (isShowingThumbnailInLockscreen) bitmapProvider.bitmap else null + ) .putLong(MediaMetadata.METADATA_KEY_DURATION, player.duration) .build().let(mediaSession::setMetadata) } @@ -534,6 +555,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene } } + override fun notification(): Notification? { if (player.currentMediaItem == null) return null @@ -758,6 +780,9 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene val cache: Cache get() = this@PlayerService.cache + val mediaSession: MediaSession + get() = this@PlayerService.mediaSession + val sleepTimerMillisLeft: StateFlow? get() = timerJob?.millisLeft @@ -837,6 +862,11 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene override fun onSkipToPrevious() = player.forceSeekToPrevious() override fun onSkipToNext() = player.forceSeekToNext() override fun onSeekTo(pos: Long) = player.seekTo(pos) + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + player.forcePlayFromBeginning(MediaIDHelper.extractMusicQueueFromMediaId(mediaId)) + } + + override fun onSkipToQueueItem(id: Long) = player.seekToDefaultPosition(id.toInt()) } private class NotificationActionReceiver(private val player: Player) : BroadcastReceiver() { diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/MediaIDHelper.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/MediaIDHelper.kt new file mode 100644 index 0000000..4084b9c --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/MediaIDHelper.kt @@ -0,0 +1,77 @@ +package it.vfsfitvnm.vimusic.utils + +import androidx.media3.common.MediaItem +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.enums.MediaIDType +import it.vfsfitvnm.vimusic.enums.SongSortBy +import it.vfsfitvnm.vimusic.enums.SortOrder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +class MediaIDHelper { + + companion object { + + fun createMediaIdForSong(id: String): String { + return MediaIDType.Song.prefix.plus(id) + } + + fun createMediaIdForPlaylist(id: Long): String { + return MediaIDType.Playlist.prefix.plus(id) + } + + fun createMediaIdForRandomFavorites(): String { + return MediaIDType.RandomFavorites.prefix + } + + fun createMediaIdForRandomSongs(): String { + return MediaIDType.RandomSongs.prefix + } + + fun extractMusicQueueFromMediaId(mediaID: String?): List { + val result = mutableListOf() + mediaID?.apply { + with(mediaID) { + when { + startsWith(MediaIDType.Song.prefix) -> { + val id = mediaID.removePrefix(MediaIDType.Song.prefix) + val song = runBlocking(Dispatchers.IO) { + Database.songById(id).first() + } + song?.apply { + result.add(song.asMediaItem) + } + } + startsWith(MediaIDType.Playlist.prefix) -> { + val id = mediaID.removePrefix(MediaIDType.Playlist.prefix).toLong() + val playlist = runBlocking(Dispatchers.IO) { + Database.playlistWithSongs(id).first() + } + playlist?.apply { + if (playlist.songs.isNotEmpty()) { + playlist.songs.map { it.asMediaItem }.forEach(result::add) + } + } + } + startsWith(MediaIDType.RandomFavorites.prefix) -> { + val favorites = runBlocking(Dispatchers.IO) { + Database.favorites().first() + } + favorites.map { it.asMediaItem }.forEach(result::add) + result.shuffle() + } + startsWith(MediaIDType.RandomSongs.prefix) -> { + val favorites = runBlocking(Dispatchers.IO) { + Database.songs(SongSortBy.DateAdded, SortOrder.Descending).first() + } + favorites.map { it.asMediaItem }.forEach(result::add) + result.shuffle() + } + } + } + } + return result + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/disc_white.xml b/app/src/main/res/drawable/disc_white.xml new file mode 100644 index 0000000..fd3f2a8 --- /dev/null +++ b/app/src/main/res/drawable/disc_white.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/heart_white.xml b/app/src/main/res/drawable/heart_white.xml new file mode 100644 index 0000000..2a71a1e --- /dev/null +++ b/app/src/main/res/drawable/heart_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/playlist_white.xml b/app/src/main/res/drawable/playlist_white.xml new file mode 100644 index 0000000..ac99221 --- /dev/null +++ b/app/src/main/res/drawable/playlist_white.xml @@ -0,0 +1,46 @@ + + + + + + + + + diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000..59ee4e3 --- /dev/null +++ b/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file