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