From 349c961f1acf156f7edc841cf6aac0fdb5e17d09 Mon Sep 17 00:00:00 2001 From: CappielloAntonio Date: Thu, 22 Aug 2024 16:11:56 +0200 Subject: [PATCH] repo: add play variant --- app/build.gradle | 6 + .../tempo/service/MediaBrowserTree.kt | 497 ++++++++++++++++++ .../service/MediaLibraryServiceCallback.kt | 162 ++++++ .../tempo/service/MediaService.kt | 210 ++++++++ .../tempo/ui/fragment/ToolbarFragment.java | 67 +++ .../cappielloantonio/tempo/util/Flavors.java | 14 + 6 files changed, 956 insertions(+) create mode 100644 app/src/play/java/com/cappielloantonio/tempo/service/MediaBrowserTree.kt create mode 100644 app/src/play/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt create mode 100644 app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt create mode 100644 app/src/play/java/com/cappielloantonio/tempo/ui/fragment/ToolbarFragment.java create mode 100644 app/src/play/java/com/cappielloantonio/tempo/util/Flavors.java diff --git a/app/build.gradle b/app/build.gradle index 67be0fa0..ffccce2f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,6 +37,11 @@ android { dimension = "default" applicationId "com.cappielloantonio.notquitemy.tempo" } + + play { + dimension = "default" + applicationId "com.cappielloantonio.play.tempo" + } } buildTypes { @@ -90,6 +95,7 @@ dependencies { implementation 'androidx.media3:media3-ui:1.4.0' implementation 'androidx.media3:media3-exoplayer-hls:1.4.0' tempoImplementation 'androidx.media3:media3-cast:1.4.0' + playImplementation 'androidx.media3:media3-cast:1.4.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' annotationProcessor 'androidx.room:room-compiler:2.6.1' diff --git a/app/src/play/java/com/cappielloantonio/tempo/service/MediaBrowserTree.kt b/app/src/play/java/com/cappielloantonio/tempo/service/MediaBrowserTree.kt new file mode 100644 index 00000000..f88f9b6b --- /dev/null +++ b/app/src/play/java/com/cappielloantonio/tempo/service/MediaBrowserTree.kt @@ -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 = 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 = ArrayList() + + fun addChild(childID: String) { + this.children.add(treeNodes[childID]!!.item) + } + + fun getChildren(): ListenableFuture>> { + val listenableFuture = SettableFuture.create>>() + 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 = 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>> { + 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): List { + val updatedMediaItems = ArrayList() + + 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>> { + return automotiveRepository.search( + query, + ALBUM_ID, + ARTIST_ID + ) + } +} diff --git a/app/src/play/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt b/app/src/play/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt new file mode 100644 index 00000000..747a45b2 --- /dev/null +++ b/app/src/play/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt @@ -0,0 +1,162 @@ +package com.cappielloantonio.tempo.service + +import android.content.Context +import android.os.Bundle +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.CommandButton +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.cappielloantonio.tempo.R +import com.cappielloantonio.tempo.repository.AutomotiveRepository +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture + +open class MediaLibrarySessionCallback( + context: Context, + automotiveRepository: AutomotiveRepository +) : + MediaLibraryService.MediaLibrarySession.Callback { + + init { + MediaBrowserTree.initialize(automotiveRepository) + } + + private val customLayoutCommandButtons: List = listOf( + 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(), + + 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() + ) + + @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 { + if (session.isMediaNotificationController(controller) || session.isAutomotiveController( + controller + ) || session.isAutoCompanionController(controller) + ) { + val customLayout = + customLayoutCommandButtons[if (session.player.shuffleModeEnabled) 1 else 0] + + return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(mediaNotificationSessionCommands) + .setCustomLayout(ImmutableList.of(customLayout)).build() + } + + return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build() + } + + @OptIn(UnstableApi::class) + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON == customCommand.customAction) { + session.player.shuffleModeEnabled = true + session.setCustomLayout( + session.mediaNotificationControllerInfo!!, + ImmutableList.of(customLayoutCommandButtons[1]) + ) + + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } else if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF == customCommand.customAction) { + session.player.shuffleModeEnabled = false + session.setCustomLayout( + session.mediaNotificationControllerInfo!!, + ImmutableList.of(customLayoutCommandButtons[0]) + ) + + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED)) + } + + override fun onGetLibraryRoot( + session: MediaLibraryService.MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture> { + 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>> { + return MediaBrowserTree.getChildren(parentId) + } + + override fun onAddMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: List + ): ListenableFuture> { + return super.onAddMediaItems( + mediaSession, + controller, + MediaBrowserTree.getItems(mediaItems) + ) + } + + override fun onSearch( + session: MediaLibraryService.MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture> { + 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>> { + return MediaBrowserTree.search(query) + } + + companion object { + private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = + "android.media3.session.demo.SHUFFLE_ON" + private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF = + "android.media3.session.demo.SHUFFLE_OFF" + } +} \ No newline at end of file diff --git a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt new file mode 100644 index 00000000..b993aaba --- /dev/null +++ b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -0,0 +1,210 @@ +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 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.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession.ControllerInfo +import com.cappielloantonio.tempo.repository.AutomotiveRepository +import com.cappielloantonio.tempo.ui.activity.MainActivity +import com.cappielloantonio.tempo.util.Constants +import com.cappielloantonio.tempo.util.DownloadUtil +import com.cappielloantonio.tempo.util.Preferences +import com.cappielloantonio.tempo.util.ReplayGainUtil +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 + + override fun onCreate() { + super.onCreate() + + initializeRepository() + initializePlayer() + initializeCastPlayer() + initializeMediaLibrarySession() + initializePlayerListener() + + 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() { + releasePlayer() + super.onDestroy() + } + + private fun initializeRepository() { + automotiveRepository = AutomotiveRepository() + } + + private fun initializePlayer() { + player = ExoPlayer.Builder(this) + .setRenderersFactory(getRenderersFactory()) + .setMediaSourceFactory(getMediaSourceFactory()) + .setAudioAttributes(AudioAttributes.DEFAULT, true) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_NETWORK) + .setLoadControl(initializeLoadControl()) + .build() + } + + private fun initializeCastPlayer() { + if (GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS + ) { + castPlayer = CastPlayer(CastContext.getSharedInstance(this)) + castPlayer.setSessionAvailabilityListener(this) + } + } + + private fun initializeMediaLibrarySession() { + val sessionActivityPendingIntent = + TaskStackBuilder.create(this).run { + addNextIntent(Intent(this@MediaService, MainActivity::class.java)) + getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT) + } + + mediaLibrarySession = + MediaLibrarySession.Builder(this, player, createLibrarySessionCallback()) + .setSessionActivity(sessionActivityPendingIntent) + .build() + } + + private fun createLibrarySessionCallback(): MediaLibrarySession.Callback { + 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) + } + } + + override fun onTracksChanged(tracks: Tracks) { + ReplayGainUtil.setReplayGain(player, tracks) + MediaManager.scrobble(player.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) + } + } + + 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) + } + } + + 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) + } + } + } + }) + } + + 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 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() + clearListener() + } + + private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) + + private fun getMediaSourceFactory() = + DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this)) + + override fun onCastSessionAvailable() { + setPlayer(player, castPlayer) + } + + override fun onCastSessionUnavailable() { + setPlayer(castPlayer, player) + } +} \ No newline at end of file diff --git a/app/src/play/java/com/cappielloantonio/tempo/ui/fragment/ToolbarFragment.java b/app/src/play/java/com/cappielloantonio/tempo/ui/fragment/ToolbarFragment.java new file mode 100644 index 00000000..d3f36bb7 --- /dev/null +++ b/app/src/play/java/com/cappielloantonio/tempo/ui/fragment/ToolbarFragment.java @@ -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; + } +} \ No newline at end of file diff --git a/app/src/play/java/com/cappielloantonio/tempo/util/Flavors.java b/app/src/play/java/com/cappielloantonio/tempo/util/Flavors.java new file mode 100644 index 00000000..4bed2921 --- /dev/null +++ b/app/src/play/java/com/cappielloantonio/tempo/util/Flavors.java @@ -0,0 +1,14 @@ +package com.cappielloantonio.tempo.util; + +import android.content.Context; + +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 { + public static void initializeCastContext(Context context) { + if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS) + CastContext.getSharedInstance(context); + } +}