Support android auto (#47)
This commit is contained in:
@@ -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?>
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user