feat: first pass swapping new icon, build config and rename folder structure

This commit is contained in:
eddyizm
2025-10-21 09:23:25 -07:00
parent be0480538e
commit 7a83a03a90
54 changed files with 125 additions and 10 deletions

View File

@@ -0,0 +1,497 @@
package com.cappielloantonio.tempo.service
import android.net.Uri
import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.SubtitleConfiguration
import androidx.media3.common.MediaMetadata
import androidx.media3.session.LibraryResult
import com.cappielloantonio.tempo.repository.AutomotiveRepository
import com.cappielloantonio.tempo.util.Preferences.getServerId
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
object MediaBrowserTree {
private lateinit var automotiveRepository: AutomotiveRepository
private var treeNodes: MutableMap<String, MediaItemNode> = mutableMapOf()
private var isInitialized = false
// Root
private const val ROOT_ID = "[rootID]"
// First level
private const val HOME_ID = "[homeID]"
private const val LIBRARY_ID = "[libraryID]"
private const val OTHER_ID = "[otherID]"
// Second level HOME_ID
private const val MOST_PLAYED_ID = "[mostPlayedID]"
private const val LAST_PLAYED_ID = "[lastPlayedID]"
private const val RECENTLY_ADDED_ID = "[recentlyAddedID]"
private const val RECENT_SONGS_ID = "[recentSongsID]"
private const val MADE_FOR_YOU_ID = "[madeForYouID]"
private const val STARRED_TRACKS_ID = "[starredTracksID]"
private const val STARRED_ALBUMS_ID = "[starredAlbumsID]"
private const val STARRED_ARTISTS_ID = "[starredArtistsID]"
private const val RANDOM_ID = "[randomID]"
// Second level LIBRARY_ID
private const val FOLDER_ID = "[folderID]"
private const val INDEX_ID = "[indexID]"
private const val DIRECTORY_ID = "[directoryID]"
private const val PLAYLIST_ID = "[playlistID]"
// Second level OTHER_ID
private const val PODCAST_ID = "[podcastID]"
private const val RADIO_ID = "[radioID]"
private const val ALBUM_ID = "[albumID]"
private const val ARTIST_ID = "[artistID]"
private class MediaItemNode(val item: MediaItem) {
private val children: MutableList<MediaItem> = ArrayList()
fun addChild(childID: String) {
this.children.add(treeNodes[childID]!!.item)
}
fun getChildren(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val listenableFuture = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
val libraryResult = LibraryResult.ofItemList(children, null)
listenableFuture.set(libraryResult)
return listenableFuture
}
}
private fun buildMediaItem(
title: String,
mediaId: String,
isPlayable: Boolean,
isBrowsable: Boolean,
mediaType: @MediaMetadata.MediaType Int,
subtitleConfigurations: List<SubtitleConfiguration> = mutableListOf(),
album: String? = null,
artist: String? = null,
genre: String? = null,
sourceUri: Uri? = null,
imageUri: Uri? = null
): MediaItem {
val metadata =
MediaMetadata.Builder()
.setAlbumTitle(album)
.setTitle(title)
.setArtist(artist)
.setGenre(genre)
.setIsBrowsable(isBrowsable)
.setIsPlayable(isPlayable)
.setArtworkUri(imageUri)
.setMediaType(mediaType)
.build()
return MediaItem.Builder()
.setMediaId(mediaId)
.setSubtitleConfigurations(subtitleConfigurations)
.setMediaMetadata(metadata)
.setUri(sourceUri)
.build()
}
fun initialize(automotiveRepository: AutomotiveRepository) {
this.automotiveRepository = automotiveRepository
if (isInitialized) return
isInitialized = true
// Root level
treeNodes[ROOT_ID] =
MediaItemNode(
buildMediaItem(
title = "Root Folder",
mediaId = ROOT_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
// First level
treeNodes[HOME_ID] =
MediaItemNode(
buildMediaItem(
title = "Home",
mediaId = HOME_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[LIBRARY_ID] =
MediaItemNode(
buildMediaItem(
title = "Library",
mediaId = LIBRARY_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[OTHER_ID] =
MediaItemNode(
buildMediaItem(
title = "Other",
mediaId = OTHER_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[ROOT_ID]!!.addChild(HOME_ID)
treeNodes[ROOT_ID]!!.addChild(LIBRARY_ID)
treeNodes[ROOT_ID]!!.addChild(OTHER_ID)
// Second level HOME_ID
treeNodes[MOST_PLAYED_ID] =
MediaItemNode(
buildMediaItem(
title = "Most played",
mediaId = MOST_PLAYED_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
)
)
treeNodes[LAST_PLAYED_ID] =
MediaItemNode(
buildMediaItem(
title = "Last played",
mediaId = LAST_PLAYED_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
)
)
treeNodes[RECENTLY_ADDED_ID] =
MediaItemNode(
buildMediaItem(
title = "Recently added",
mediaId = RECENTLY_ADDED_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
)
)
treeNodes[RECENT_SONGS_ID] =
MediaItemNode(
buildMediaItem(
title = "Recent songs",
mediaId = RECENT_SONGS_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[MADE_FOR_YOU_ID] =
MediaItemNode(
buildMediaItem(
title = "Made for you",
mediaId = MADE_FOR_YOU_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS
)
)
treeNodes[STARRED_TRACKS_ID] =
MediaItemNode(
buildMediaItem(
title = "Starred tracks",
mediaId = STARRED_TRACKS_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[STARRED_ALBUMS_ID] =
MediaItemNode(
buildMediaItem(
title = "Starred albums",
mediaId = STARRED_ALBUMS_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
)
)
treeNodes[STARRED_ARTISTS_ID] =
MediaItemNode(
buildMediaItem(
title = "Starred artists",
mediaId = STARRED_ARTISTS_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS
)
)
treeNodes[RANDOM_ID] =
MediaItemNode(
buildMediaItem(
title = "Random",
mediaId = RANDOM_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[HOME_ID]!!.addChild(MOST_PLAYED_ID)
treeNodes[HOME_ID]!!.addChild(LAST_PLAYED_ID)
treeNodes[HOME_ID]!!.addChild(RECENTLY_ADDED_ID)
treeNodes[HOME_ID]!!.addChild(RECENT_SONGS_ID)
treeNodes[HOME_ID]!!.addChild(MADE_FOR_YOU_ID)
treeNodes[HOME_ID]!!.addChild(STARRED_TRACKS_ID)
treeNodes[HOME_ID]!!.addChild(STARRED_ALBUMS_ID)
treeNodes[HOME_ID]!!.addChild(STARRED_ARTISTS_ID)
treeNodes[HOME_ID]!!.addChild(RANDOM_ID)
// Second level LIBRARY_ID
treeNodes[FOLDER_ID] =
MediaItemNode(
buildMediaItem(
title = "Folders",
mediaId = FOLDER_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[PLAYLIST_ID] =
MediaItemNode(
buildMediaItem(
title = "Playlists",
mediaId = PLAYLIST_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS
)
)
treeNodes[LIBRARY_ID]!!.addChild(FOLDER_ID)
treeNodes[LIBRARY_ID]!!.addChild(PLAYLIST_ID)
// Second level OTHER_ID
treeNodes[PODCAST_ID] =
MediaItemNode(
buildMediaItem(
title = "Podcasts",
mediaId = PODCAST_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_PODCASTS
)
)
treeNodes[RADIO_ID] =
MediaItemNode(
buildMediaItem(
title = "Radio stations",
mediaId = RADIO_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_RADIO_STATIONS
)
)
treeNodes[OTHER_ID]!!.addChild(PODCAST_ID)
treeNodes[OTHER_ID]!!.addChild(RADIO_ID)
}
fun getRootItem(): MediaItem {
return treeNodes[ROOT_ID]!!.item
}
fun getChildren(
id: String
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
return when (id) {
ROOT_ID -> treeNodes[ROOT_ID]?.getChildren()!!
HOME_ID -> treeNodes[HOME_ID]?.getChildren()!!
LIBRARY_ID -> treeNodes[LIBRARY_ID]?.getChildren()!!
OTHER_ID -> treeNodes[OTHER_ID]?.getChildren()!!
MOST_PLAYED_ID -> automotiveRepository.getAlbums(id, "frequent", 100)
LAST_PLAYED_ID -> automotiveRepository.getAlbums(id, "recent", 100)
RECENTLY_ADDED_ID -> automotiveRepository.getAlbums(id, "newest", 100)
RECENT_SONGS_ID -> automotiveRepository.getRecentlyPlayedSongs(getServerId(),100)
MADE_FOR_YOU_ID -> automotiveRepository.getStarredArtists(id)
STARRED_TRACKS_ID -> automotiveRepository.starredSongs
STARRED_ALBUMS_ID -> automotiveRepository.getStarredAlbums(id)
STARRED_ARTISTS_ID -> automotiveRepository.getStarredArtists(id)
RANDOM_ID -> automotiveRepository.getRandomSongs(100)
FOLDER_ID -> automotiveRepository.getMusicFolders(id)
PLAYLIST_ID -> automotiveRepository.getPlaylists(id)
PODCAST_ID -> automotiveRepository.getNewestPodcastEpisodes(100)
RADIO_ID -> automotiveRepository.internetRadioStations
else -> {
if (id.startsWith(MOST_PLAYED_ID)) {
return automotiveRepository.getAlbumTracks(
id.removePrefix(
MOST_PLAYED_ID
)
)
}
if (id.startsWith(LAST_PLAYED_ID)) {
return automotiveRepository.getAlbumTracks(
id.removePrefix(
LAST_PLAYED_ID
)
)
}
if (id.startsWith(RECENTLY_ADDED_ID)) {
return automotiveRepository.getAlbumTracks(
id.removePrefix(
RECENTLY_ADDED_ID
)
)
}
if (id.startsWith(MADE_FOR_YOU_ID)) {
return automotiveRepository.getMadeForYou(
id.removePrefix(
MADE_FOR_YOU_ID
),
20
)
}
if (id.startsWith(STARRED_ALBUMS_ID)) {
return automotiveRepository.getAlbumTracks(
id.removePrefix(
STARRED_ALBUMS_ID
)
)
}
if (id.startsWith(STARRED_ARTISTS_ID)) {
return automotiveRepository.getArtistAlbum(
STARRED_ALBUMS_ID,
id.removePrefix(
STARRED_ARTISTS_ID
)
)
}
if (id.startsWith(FOLDER_ID)) {
return automotiveRepository.getIndexes(
INDEX_ID,
id.removePrefix(
FOLDER_ID
)
)
}
if (id.startsWith(INDEX_ID)) {
return automotiveRepository.getDirectories(
DIRECTORY_ID,
id.removePrefix(
INDEX_ID
)
)
}
if (id.startsWith(DIRECTORY_ID)) {
return automotiveRepository.getDirectories(
DIRECTORY_ID,
id.removePrefix(
DIRECTORY_ID
)
)
}
if (id.startsWith(PLAYLIST_ID)) {
return automotiveRepository.getPlaylistSongs(
id.removePrefix(
PLAYLIST_ID
)
)
}
if (id.startsWith(ALBUM_ID)) {
return automotiveRepository.getAlbumTracks(
id.removePrefix(
ALBUM_ID
)
)
}
if (id.startsWith(ARTIST_ID)) {
return automotiveRepository.getArtistAlbum(
ALBUM_ID,
id.removePrefix(
ARTIST_ID
)
)
}
return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
}
}
}
// https://github.com/androidx/media/issues/156
fun getItems(mediaItems: List<MediaItem>): List<MediaItem> {
val updatedMediaItems = ArrayList<MediaItem>()
mediaItems.forEach {
if (it.localConfiguration?.uri != null) {
updatedMediaItems.add(it)
} else {
val sessionMediaItem = automotiveRepository.getSessionMediaItem(it.mediaId)
if (sessionMediaItem != null) {
var toAdd = automotiveRepository.getMetadatas(sessionMediaItem.timestamp!!)
val index = toAdd.indexOfFirst { mediaItem -> mediaItem.mediaId == it.mediaId }
toAdd = toAdd.subList(index, toAdd.size)
updatedMediaItems.addAll(toAdd)
}
}
}
return updatedMediaItems
}
fun search(query: String): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
return automotiveRepository.search(
query,
ALBUM_ID,
ARTIST_ID
)
}
}

View File

@@ -0,0 +1,396 @@
package com.cappielloantonio.tempo.service
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.annotation.OptIn
import androidx.concurrent.futures.CallbackToFutureAdapter
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.Rating
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaConstants
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionError
import androidx.media3.session.SessionResult
import com.cappielloantonio.tempo.App
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.repository.AutomotiveRepository
import com.cappielloantonio.tempo.subsonic.base.ApiResponse
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_LOADING
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
import com.google.common.collect.ImmutableList
import com.cappielloantonio.tempo.util.Preferences
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
open class MediaLibrarySessionCallback(
context: Context,
automotiveRepository: AutomotiveRepository
) :
MediaLibraryService.MediaLibrarySession.Callback {
init {
MediaBrowserTree.initialize(automotiveRepository)
}
private val customCommandToggleShuffleModeOn = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_shuffle_on_description))
.setSessionCommand(
SessionCommand(
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY
)
).setIconResId(R.drawable.exo_icon_shuffle_off).build()
private val customCommandToggleShuffleModeOff = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_shuffle_off_description))
.setSessionCommand(
SessionCommand(
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY
)
).setIconResId(R.drawable.exo_icon_shuffle_on).build()
private val customCommandToggleRepeatModeOff = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_repeat_off_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY))
.setIconResId(R.drawable.exo_icon_repeat_off)
.build()
private val customCommandToggleRepeatModeOne = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_repeat_one_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY))
.setIconResId(R.drawable.exo_icon_repeat_one)
.build()
private val customCommandToggleRepeatModeAll = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_repeat_all_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY))
.setIconResId(R.drawable.exo_icon_repeat_all)
.build()
private val customCommandToggleHeartOn = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_heart_on_description))
.setSessionCommand(
SessionCommand(
CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY
)
)
.setIconResId(R.drawable.ic_favorite)
.build()
private val customCommandToggleHeartOff = CommandButton.Builder()
.setDisplayName(context.getString(R.string.exo_controls_heart_off_description))
.setSessionCommand(
SessionCommand(CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY)
)
.setIconResId(R.drawable.ic_favorites_outlined)
.build()
// Fake Command while waiting for like update command
private val customCommandToggleHeartLoading = CommandButton.Builder()
.setDisplayName(context.getString(R.string.cast_expanded_controller_loading))
.setSessionCommand(
SessionCommand(CUSTOM_COMMAND_TOGGLE_HEART_LOADING, Bundle.EMPTY)
)
.setIconResId(R.drawable.ic_bookmark_sync)
.build()
private val customLayoutCommandButtons = listOf(
customCommandToggleShuffleModeOn,
customCommandToggleShuffleModeOff,
customCommandToggleRepeatModeOff,
customCommandToggleRepeatModeOne,
customCommandToggleRepeatModeAll,
customCommandToggleHeartOn,
customCommandToggleHeartOff,
customCommandToggleHeartLoading,
)
@OptIn(UnstableApi::class)
val mediaNotificationSessionCommands =
MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
.also { builder ->
customLayoutCommandButtons.forEach { commandButton ->
commandButton.sessionCommand?.let { builder.add(it) }
}
}.build()
@OptIn(UnstableApi::class)
override fun onConnect(
session: MediaSession, controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
session.player.addListener(object : Player.Listener {
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
updateMediaNotificationCustomLayout(session)
}
override fun onRepeatModeChanged(repeatMode: Int) {
updateMediaNotificationCustomLayout(session)
}
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
updateMediaNotificationCustomLayout(session)
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateMediaNotificationCustomLayout(session)
}
})
// FIXME: I'm not sure this if is required anymore
if (session.isMediaNotificationController(controller) || session.isAutomotiveController(
controller
) || session.isAutoCompanionController(controller)
) {
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(mediaNotificationSessionCommands)
.setCustomLayout(buildCustomLayout(session.player))
.build()
}
return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build()
}
// Update the mediaNotification after some changes
@OptIn(UnstableApi::class)
private fun updateMediaNotificationCustomLayout(
session: MediaSession,
isRatingPending: Boolean = false
) {
session.setCustomLayout(
session.mediaNotificationControllerInfo!!,
buildCustomLayout(session.player, isRatingPending)
)
}
private fun buildCustomLayout(player: Player, isRatingPending: Boolean = false): ImmutableList<CommandButton> {
val customLayout = mutableListOf<CommandButton>()
val showShuffle = Preferences.showShuffleInsteadOfHeart()
if (!showShuffle) {
if (player.currentMediaItem != null && !isRatingPending) {
if ((player.mediaMetadata.userRating as HeartRating?)?.isHeart == true) {
customLayout.add(customCommandToggleHeartOn)
} else {
customLayout.add(customCommandToggleHeartOff)
}
}
} else {
customLayout.add(
if (player.shuffleModeEnabled) customCommandToggleShuffleModeOff else customCommandToggleShuffleModeOn
)
}
// Add repeat button
val repeatButton = when (player.repeatMode) {
Player.REPEAT_MODE_ONE -> customCommandToggleRepeatModeOne
Player.REPEAT_MODE_ALL -> customCommandToggleRepeatModeAll
else -> customCommandToggleRepeatModeOff
}
customLayout.add(repeatButton)
return ImmutableList.copyOf(customLayout)
}
// Setting rating without a mediaId will set the currently listened mediaId
override fun onSetRating(
session: MediaSession,
controller: MediaSession.ControllerInfo,
rating: Rating
): ListenableFuture<SessionResult> {
return onSetRating(session, controller, session.player.currentMediaItem!!.mediaId, rating)
}
override fun onSetRating(
session: MediaSession,
controller: MediaSession.ControllerInfo,
mediaId: String,
rating: Rating
): ListenableFuture<SessionResult> {
val isStaring = (rating as HeartRating).isHeart
val networkCall = if (isStaring)
App.getSubsonicClientInstance(false)
.mediaAnnotationClient
.star(mediaId, null, null)
else
App.getSubsonicClientInstance(false)
.mediaAnnotationClient
.unstar(mediaId, null, null)
return CallbackToFutureAdapter.getFuture { completer ->
networkCall.enqueue(object : Callback<ApiResponse?> {
@OptIn(UnstableApi::class)
override fun onResponse(
call: Call<ApiResponse?>,
response: Response<ApiResponse?>
) {
if (response.isSuccessful) {
// Search if the media item in the player should be updated
for (i in 0 until session.player.mediaItemCount) {
val mediaItem = session.player.getMediaItemAt(i)
if (mediaItem.mediaId == mediaId) {
val newMetadata = mediaItem.mediaMetadata.buildUpon()
.setUserRating(HeartRating(isStaring)).build()
session.player.replaceMediaItem(
i,
mediaItem.buildUpon().setMediaMetadata(newMetadata).build()
)
}
}
updateMediaNotificationCustomLayout(session)
completer.set(SessionResult(SessionResult.RESULT_SUCCESS))
} else {
updateMediaNotificationCustomLayout(session)
completer.set(
SessionResult(
SessionError(
response.code(),
response.message()
)
)
)
}
}
@OptIn(UnstableApi::class)
override fun onFailure(call: Call<ApiResponse?>, t: Throwable) {
updateMediaNotificationCustomLayout(session)
completer.set(
SessionResult(
SessionError(
SessionError.ERROR_UNKNOWN,
"An error as occurred"
)
)
)
}
})
}
}
@OptIn(UnstableApi::class)
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
when (customCommand.customAction) {
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> {
session.player.shuffleModeEnabled = true
updateMediaNotificationCustomLayout(session)
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> {
session.player.shuffleModeEnabled = false
updateMediaNotificationCustomLayout(session)
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> {
val nextMode = when (session.player.repeatMode) {
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE
else -> Player.REPEAT_MODE_OFF
}
session.player.repeatMode = nextMode
updateMediaNotificationCustomLayout(session)
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
CUSTOM_COMMAND_TOGGLE_HEART_ON,
CUSTOM_COMMAND_TOGGLE_HEART_OFF -> {
val currentRating = session.player.mediaMetadata.userRating as? HeartRating
val isCurrentlyLiked = currentRating?.isHeart ?: false
val newLikedState = !isCurrentlyLiked
updateMediaNotificationCustomLayout(
session,
isRatingPending = true // Show loading state
)
return onSetRating(session, controller, HeartRating(newLikedState))
}
else -> return Futures.immediateFuture(
SessionResult(
SessionError(
SessionError.ERROR_NOT_SUPPORTED,
customCommand.customAction
)
)
)
}
}
override fun onGetLibraryRoot(
session: MediaLibraryService.MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<MediaItem>> {
return Futures.immediateFuture(LibraryResult.ofItem(MediaBrowserTree.getRootItem(), params))
}
override fun onGetChildren(
session: MediaLibraryService.MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
page: Int,
pageSize: Int,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
return MediaBrowserTree.getChildren(parentId)
}
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: List<MediaItem>
): ListenableFuture<List<MediaItem>> {
return super.onAddMediaItems(
mediaSession,
controller,
MediaBrowserTree.getItems(mediaItems)
)
}
override fun onSearch(
session: MediaLibraryService.MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<Void>> {
session.notifySearchResultChanged(browser, query, 60, params)
return Futures.immediateFuture(LibraryResult.ofVoid())
}
override fun onGetSearchResult(
session: MediaLibraryService.MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
page: Int,
pageSize: Int,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
return MediaBrowserTree.search(query)
}
}

View File

@@ -0,0 +1,406 @@
package com.cappielloantonio.tempo.service
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.TaskStackBuilder
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import android.os.Handler
import android.os.Looper
import androidx.core.content.ContextCompat
import androidx.media3.cast.CastPlayer
import androidx.media3.cast.SessionAvailabilityListener
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession.ControllerInfo
import com.cappielloantonio.tempo.repository.AutomotiveRepository
import com.cappielloantonio.tempo.repository.QueueRepository
import com.cappielloantonio.tempo.ui.activity.MainActivity
import com.cappielloantonio.tempo.util.AssetLinkUtil
import com.cappielloantonio.tempo.util.Constants
import com.cappielloantonio.tempo.util.DownloadUtil
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
import com.cappielloantonio.tempo.util.MappingUtil
import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil
import com.cappielloantonio.tempo.widget.WidgetUpdateManager
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
@UnstableApi
class MediaService : MediaLibraryService(), SessionAvailabilityListener {
private lateinit var automotiveRepository: AutomotiveRepository
private lateinit var player: ExoPlayer
private lateinit var castPlayer: CastPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var librarySessionCallback: MediaLibrarySessionCallback
lateinit var equalizerManager: EqualizerManager
inner class LocalBinder : Binder() {
fun getEqualizerManager(): EqualizerManager {
return this@MediaService.equalizerManager
}
}
private val binder = LocalBinder()
companion object {
const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
}
private val widgetUpdateHandler = Handler(Looper.getMainLooper())
private var widgetUpdateScheduled = false
private val widgetUpdateRunnable = object : Runnable {
override fun run() {
if (!player.isPlaying) {
widgetUpdateScheduled = false
return
}
updateWidget()
widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
}
}
override fun onCreate() {
super.onCreate()
initializeRepository()
initializePlayer()
initializeMediaLibrarySession()
restorePlayerFromQueue()
initializePlayerListener()
initializeCastPlayer()
initializeEqualizerManager()
setPlayer(
null,
if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player
)
}
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
return mediaLibrarySession
}
override fun onTaskRemoved(rootIntent: Intent?) {
val player = mediaLibrarySession.player
if (!player.playWhenReady || player.mediaItemCount == 0) {
stopSelf()
}
}
override fun onDestroy() {
equalizerManager.release()
stopWidgetUpdates()
releasePlayer()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? {
// Check if the intent is for our custom equalizer binder
if (intent?.action == ACTION_BIND_EQUALIZER) {
return binder
}
// Otherwise, handle it as a normal MediaLibraryService connection
return super.onBind(intent)
}
private fun initializeRepository() {
automotiveRepository = AutomotiveRepository()
}
private fun initializeEqualizerManager() {
equalizerManager = EqualizerManager()
val audioSessionId = player.audioSessionId
if (equalizerManager.attachToSession(audioSessionId)) {
val enabled = Preferences.isEqualizerEnabled()
equalizerManager.setEnabled(enabled)
val bands = equalizerManager.getNumberOfBands()
val savedLevels = Preferences.getEqualizerBandLevels(bands)
for (i in 0 until bands) {
equalizerManager.setBandLevel(i.toShort(), savedLevels[i])
}
}
}
private fun initializePlayer() {
player = ExoPlayer.Builder(this)
.setRenderersFactory(getRenderersFactory())
.setMediaSourceFactory(DynamicMediaSourceFactory(this))
.setAudioAttributes(AudioAttributes.DEFAULT, true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
.setLoadControl(initializeLoadControl())
.build()
player.shuffleModeEnabled = Preferences.isShuffleModeEnabled()
player.repeatMode = Preferences.getRepeatMode()
}
@Suppress("DEPRECATION")
private fun initializeCastPlayer() {
if (GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
) {
CastContext.getSharedInstance(this, ContextCompat.getMainExecutor(this))
.addOnSuccessListener { castContext ->
castPlayer = CastPlayer(castContext)
castPlayer.setSessionAvailabilityListener(this@MediaService)
if (castPlayer.isCastSessionAvailable && this::mediaLibrarySession.isInitialized) {
setPlayer(player, castPlayer)
}
}
}
}
private fun initializeMediaLibrarySession() {
val sessionActivityPendingIntent =
TaskStackBuilder.create(this).run {
addNextIntent(Intent(this@MediaService, MainActivity::class.java))
getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
}
librarySessionCallback = createLibrarySessionCallback()
mediaLibrarySession =
MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(sessionActivityPendingIntent)
.build()
}
private fun restorePlayerFromQueue() {
if (player.mediaItemCount > 0) return
val queueRepository = QueueRepository()
val storedQueue = queueRepository.media
if (storedQueue.isNullOrEmpty()) return
val mediaItems = MappingUtil.mapMediaItems(storedQueue)
if (mediaItems.isEmpty()) return
val lastIndex = try {
queueRepository.lastPlayedMediaIndex
} catch (_: Exception) {
0
}.coerceIn(0, mediaItems.size - 1)
val lastPosition = try {
queueRepository.lastPlayedMediaTimestamp
} catch (_: Exception) {
0L
}.let { if (it < 0L) 0L else it }
player.setMediaItems(mediaItems, lastIndex, lastPosition)
player.prepare()
updateWidget()
}
private fun createLibrarySessionCallback(): MediaLibrarySessionCallback {
return MediaLibrarySessionCallback(this, automotiveRepository)
}
private fun initializePlayerListener() {
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (mediaItem == null) return
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem)
}
updateWidget()
}
override fun onTracksChanged(tracks: Tracks) {
ReplayGainUtil.setReplayGain(player, tracks)
val currentMediaItem = player.currentMediaItem
if (currentMediaItem != null && currentMediaItem.mediaMetadata.extras != null) {
MediaManager.scrobble(currentMediaItem, false)
}
if (player.currentMediaItemIndex + 1 == player.mediaItemCount)
MediaManager.continuousPlay(player.currentMediaItem)
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (!isPlaying) {
MediaManager.setPlayingPausedTimestamp(
player.currentMediaItem,
player.currentPosition
)
} else {
MediaManager.scrobble(player.currentMediaItem, false)
}
if (isPlaying) {
scheduleWidgetUpdates()
} else {
stopWidgetUpdates()
}
updateWidget()
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
if (!player.hasNextMediaItem() &&
playbackState == Player.STATE_ENDED &&
player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
) {
MediaManager.scrobble(player.currentMediaItem, true)
MediaManager.saveChronology(player.currentMediaItem)
}
updateWidget()
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) {
if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
MediaManager.scrobble(oldPosition.mediaItem, true)
MediaManager.saveChronology(oldPosition.mediaItem)
}
if (newPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
MediaManager.setLastPlayedTimestamp(newPosition.mediaItem)
}
}
}
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
}
override fun onRepeatModeChanged(repeatMode: Int) {
Preferences.setRepeatMode(repeatMode)
}
})
if (player.isPlaying) {
scheduleWidgetUpdates()
}
}
private fun updateWidget() {
val mi = player.currentMediaItem
val title = mi?.mediaMetadata?.title?.toString()
?: mi?.mediaMetadata?.extras?.getString("title")
val artist = mi?.mediaMetadata?.artist?.toString()
?: mi?.mediaMetadata?.extras?.getString("artist")
val album = mi?.mediaMetadata?.albumTitle?.toString()
?: mi?.mediaMetadata?.extras?.getString("album")
val extras = mi?.mediaMetadata?.extras
val coverId = extras?.getString("coverArtId")
val songLink = extras?.getString("assetLinkSong")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id"))
val albumLink = extras?.getString("assetLinkAlbum")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId"))
val artistLink = extras?.getString("assetLinkArtist")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId"))
val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
WidgetUpdateManager.updateFromState(
this,
title ?: "",
artist ?: "",
album ?: "",
coverId,
player.isPlaying,
player.shuffleModeEnabled,
player.repeatMode,
position,
duration,
songLink,
albumLink,
artistLink
)
}
private fun scheduleWidgetUpdates() {
if (widgetUpdateScheduled) return
widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS)
widgetUpdateScheduled = true
}
private fun stopWidgetUpdates() {
if (!widgetUpdateScheduled) return
widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable)
widgetUpdateScheduled = false
}
private fun initializeLoadControl(): DefaultLoadControl {
return DefaultLoadControl.Builder()
.setBufferDurationsMs(
(DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
(DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
.build()
}
private fun getQueueFromPlayer(player: Player): List<MediaItem> {
val queue = mutableListOf<MediaItem>()
for (i in 0 until player.mediaItemCount) {
queue.add(player.getMediaItemAt(i))
}
return queue
}
private fun setPlayer(oldPlayer: Player?, newPlayer: Player) {
if (oldPlayer === newPlayer) return
oldPlayer?.stop()
mediaLibrarySession.player = newPlayer
}
private fun releasePlayer() {
if (this::castPlayer.isInitialized) castPlayer.setSessionAvailabilityListener(null)
if (this::castPlayer.isInitialized) castPlayer.release()
player.release()
mediaLibrarySession.release()
automotiveRepository.deleteMetadata()
}
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
override fun onCastSessionAvailable() {
val currentQueue = getQueueFromPlayer(player)
val currentIndex = player.currentMediaItemIndex
val currentPosition = player.currentPosition
val isPlaying = player.playWhenReady
setPlayer(player, castPlayer)
castPlayer.setMediaItems(currentQueue, currentIndex, currentPosition)
castPlayer.playWhenReady = isPlaying
castPlayer.prepare()
}
override fun onCastSessionUnavailable() {
val currentQueue = getQueueFromPlayer(castPlayer)
val currentIndex = castPlayer.currentMediaItemIndex
val currentPosition = castPlayer.currentPosition
val isPlaying = castPlayer.playWhenReady
setPlayer(castPlayer, player)
player.setMediaItems(currentQueue, currentIndex, currentPosition)
player.playWhenReady = isPlaying
player.prepare()
}
}
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L

View File

@@ -0,0 +1,67 @@
package com.cappielloantonio.tempo.ui.fragment;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.FragmentToolbarBinding;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.google.android.gms.cast.framework.CastButtonFactory;
@UnstableApi
public class ToolbarFragment extends Fragment {
private static final String TAG = "ToolbarFragment";
private FragmentToolbarBinding bind;
private MainActivity activity;
public ToolbarFragment() {
// Required empty public constructor
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
inflater.inflate(R.menu.main_page_menu, menu);
CastButtonFactory.setUpMediaRouteButton(requireContext(), menu, R.id.media_route_menu_item);
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
activity = (MainActivity) getActivity();
bind = FragmentToolbarBinding.inflate(inflater, container, false);
View view = bind.getRoot();
return view;
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.action_search) {
activity.navController.navigate(R.id.searchFragment);
return true;
} else if (item.getItemId() == R.id.action_settings) {
activity.navController.navigate(R.id.settingsFragment);
return true;
}
return false;
}
}

View File

@@ -0,0 +1,17 @@
package com.cappielloantonio.tempo.util;
import android.content.Context;
import androidx.core.content.ContextCompat;
import com.google.android.gms.cast.framework.CastContext;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
public class Flavors {
@SuppressWarnings("deprecation")
public static void initializeCastContext(Context context) {
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS)
CastContext.getSharedInstance(context, ContextCompat.getMainExecutor(context));
}
}