Support android auto (#47)

This commit is contained in:
Slany 2022-09-07 21:20:25 +02:00 committed by vfsfitvnm
parent fae96d1114
commit d0cffe466b
11 changed files with 420 additions and 1 deletions

View file

@ -9,7 +9,7 @@ android {
defaultConfig {
applicationId = "it.vfsfitvnm.vimusic"
minSdk = 21
minSdk = 23
targetSdk = 32
versionCode = 16
versionName = "0.5.0"

View file

@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-sdk android:minSdkVersion="23" android:targetSdkVersion="32" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@ -93,8 +95,22 @@
android:foregroundServiceType="mediaPlayback">
</service>
<service android:name=".service.PlayerMediaBrowserService"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
<receiver
android:name=".service.PlayerService$NotificationDismissReceiver"
android:exported="false" />
<meta-data android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@drawable/app_icon" />
</application>
</manifest>

View file

@ -127,6 +127,10 @@ interface Database {
@Query("SELECT * FROM Song WHERE id = :id")
fun song(id: String): Flow<Song?>
@Transaction
@Query("SELECT * FROM Song WHERE id = :id")
fun songById(id: String): Flow<DetailedSong?>
@Query("SELECT likedAt FROM Song WHERE id = :songId")
fun likedAt(songId: String): Flow<Long?>

View file

@ -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"
}
}

View file

@ -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<MutableList<MediaItem>>
) {
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<MediaItem> {
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<MediaItem> {
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<MediaItem> {
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<MediaItem> {
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"
}
}

View file

@ -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<Long?>?
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() {

View file

@ -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<MediaItem> {
val result = mutableListOf<MediaItem>()
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
}
}
}

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M256,176a80,80 0,1 0,80 80A80.09,80.09 0,0 0,256 176ZM256,288a32,32 0,1 1,32 -32A32,32 0,0 1,256 288Z"/>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M414.39,97.61A224,224 0,1 0,97.61 414.39,224 224,0 1,0 414.39,97.61ZM256,368A112,112 0,1 1,368 256,112.12 112.12,0 0,1 256,368Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M256,448a32,32 0,0 1,-18 -5.57c-78.59,-53.35 -112.62,-89.93 -131.39,-112.8 -40,-48.75 -59.15,-98.8 -58.61,-153C48.63,114.52 98.46,64 159.08,64c44.08,0 74.61,24.83 92.39,45.51a6,6 0,0 0,9.06 0C278.31,88.81 308.84,64 352.92,64 413.54,64 463.37,114.52 464,176.64c0.54,54.21 -18.63,104.26 -58.61,153 -18.77,22.87 -52.8,59.45 -131.39,112.8A32,32 0,0 1,256 448Z"/>
</vector>

View file

@ -0,0 +1,46 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M160.98,112L448.98,112"
android:strokeLineJoin="round"
android:strokeWidth="48"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"/>
<path
android:pathData="M80.98,112m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"/>
<path
android:pathData="M80.98,224m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"/>
<path
android:pathData="M80.98,336m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"/>
<path
android:pathData="M160.98,312L218.65,312A24,24 0,0 1,242.65 336L242.65,336A24,24 0,0 1,218.65 360L160.98,360A24,24 0,0 1,136.98 336L136.98,336A24,24 0,0 1,160.98 312z"
android:strokeWidth="8.97186"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M160.98,200L300.29,200A24,24 0,0 1,324.29 224L324.29,224A24,24 0,0 1,300.29 248L160.98,248A24,24 0,0 1,136.98 224L136.98,224A24,24 0,0 1,160.98 200z"
android:strokeWidth="11.9451"
android:fillColor="#FFFFFF"/>
<path
android:fillColor="#FFFFFFFF"
android:pathData="m341.62,486a36.22,36.22 0,0 1,-21.24 -6.92,37.17 37.17,0 0,1 -15.4,-30.1 32.98,32.98 0,0 1,22.4 -31.32l33.38,-11.23a10.66,10.66 0,0 0,7.22 -10.17V231.37a21.07,21.07 0,0 1,15.81 -20.44l71.13,-18.47a14.44,14.44 0,0 1,18.06 13.97v37.9a21.06,21.06 0,0 1,-15.88 20.47l-60.15,15.18a10.66,10.66 0,0 0,-7.97 10.4v158.87a31.64,31.64 0,0 1,-21.51 30.06l-14.09,4.74a36.75,36.75 0,0 1,-11.76 1.94z"
android:strokeWidth="0.656249"/>
</vector>

View file

@ -0,0 +1,3 @@
<automotiveApp>
<uses name="media"/>
</automotiveApp>