From 168fb02f33d8de5f505b5228cd20f941b4092336 Mon Sep 17 00:00:00 2001 From: Blake Dunn Date: Sat, 8 Apr 2023 14:31:18 -0500 Subject: [PATCH] initial media 3 work --- .gitignore | 2 +- app/build.gradle.kts | 8 +- app/src/main/AndroidManifest.xml | 4 +- .../amplify/ui/album/AlbumViewModel.kt | 30 +- .../amplify/ui/artist/ArtistViewModel.kt | 16 +- .../artist/songs/ArtistAllSongsViewModel.kt | 5 +- .../amplify/ui/main/MainActivity.kt | 15 +- .../amplify/ui/main/MainActivityViewModel.kt | 15 +- .../amplify/ui/nowplaying/NowPlayingScreen.kt | 42 +-- .../ui/nowplaying/NowPlayingViewModel.kt | 56 +-- .../amplify/ui/playlist/PlaylistViewModel.kt | 30 +- .../amplify/ui/search/SearchFragment.kt | 2 +- .../amplify/ui/search/SearchViewModel.kt | 5 +- .../SongMenuBottomSheetViewModel.kt | 20 +- .../amplify/ui/songs/SongsViewModel.kt | 4 +- .../devdunnapps/amplify/utils/Extensions.kt | 15 +- .../devdunnapps/amplify/utils/MusicService.kt | 347 +++++------------- .../amplify/utils/MusicServiceConnection.kt | 223 +++++++---- .../amplify/utils/NotificationManager.kt | 21 +- gradle/libs.versions.toml | 6 +- 20 files changed, 355 insertions(+), 511 deletions(-) diff --git a/.gitignore b/.gitignore index 794eb0d..06d0adc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ /local.properties /.idea .DS_Store -/build +build /app/release /captures .externalNativeBuild diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d1574e6..020f6b9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -75,7 +75,9 @@ dependencies { implementation(libs.androidx.dataStore) implementation(libs.androidx.core.ktx) implementation(libs.androidx.preference.ktx) - implementation(libs.androidx.media) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.mediaSession) + implementation(libs.androidx.media3.ui) implementation(libs.androidx.navigation.fragment) implementation(libs.androidx.navigation.compose) @@ -88,10 +90,6 @@ dependencies { implementation(libs.material) - implementation(libs.exoplayer.core) - implementation(libs.exoplayer.ui) - implementation(libs.exoplayer.mediaSession) - implementation(libs.hilt.android) kapt(libs.hilt.compiler) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e10cfd8..ab72630 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,9 +46,11 @@ - + + diff --git a/app/src/main/java/com/devdunnapps/amplify/ui/album/AlbumViewModel.kt b/app/src/main/java/com/devdunnapps/amplify/ui/album/AlbumViewModel.kt index 9d88812..882e86b 100644 --- a/app/src/main/java/com/devdunnapps/amplify/ui/album/AlbumViewModel.kt +++ b/app/src/main/java/com/devdunnapps/amplify/ui/album/AlbumViewModel.kt @@ -1,7 +1,5 @@ package com.devdunnapps.amplify.ui.album -import android.os.Bundle -import android.support.v4.media.session.PlaybackStateCompat import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -18,7 +16,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import java.io.Serializable import javax.inject.Inject @HiltViewModel @@ -64,28 +61,21 @@ class AlbumViewModel @Inject constructor( } fun playSong(song: Song) { - val bundle = Bundle() - bundle.putSerializable("song", song) - musicServiceConnection.transportControls.sendCustomAction("play_song", bundle) + musicServiceConnection.playSong(song) } fun playAlbum(whenToPlay: WhenToPlay = WhenToPlay.NOW, shuffle: Boolean = false) { - val action = when (whenToPlay) { - WhenToPlay.NOW -> "play_songs_now" - WhenToPlay.NEXT -> "play_songs_next" - WhenToPlay.QUEUE -> "add_songs_to_queue" - } - - val shuffleMode = if (shuffle) PlaybackStateCompat.SHUFFLE_MODE_ALL else PlaybackStateCompat.SHUFFLE_MODE_NONE - musicServiceConnection.transportControls.setShuffleMode(shuffleMode) + val songs = (_album.value as? Resource.Success)?.data?.songs ?: return - musicServiceConnection.transportControls.sendCustomAction(action, collectAlbumBundle()) - } + if (shuffle) + musicServiceConnection.enableShuffleMode() + else + musicServiceConnection.disableShuffleMode() - private fun collectAlbumBundle(): Bundle { - return Bundle().apply { - val albumContent = _album.value as? Resource.Success ?: return@apply - putSerializable("songs", albumContent.data.songs as Serializable) + when (whenToPlay) { + WhenToPlay.NOW -> musicServiceConnection.playSongs(songs) + WhenToPlay.NEXT -> musicServiceConnection.playSongsNext(songs) + WhenToPlay.QUEUE -> musicServiceConnection.addSongsToQueue(songs) } } } diff --git a/app/src/main/java/com/devdunnapps/amplify/ui/artist/ArtistViewModel.kt b/app/src/main/java/com/devdunnapps/amplify/ui/artist/ArtistViewModel.kt index 27d92fb..2c0f98d 100644 --- a/app/src/main/java/com/devdunnapps/amplify/ui/artist/ArtistViewModel.kt +++ b/app/src/main/java/com/devdunnapps/amplify/ui/artist/ArtistViewModel.kt @@ -1,7 +1,6 @@ package com.devdunnapps.amplify.ui.artist import android.os.Bundle -import android.support.v4.media.session.PlaybackStateCompat import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -19,7 +18,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import java.io.Serializable import javax.inject.Inject @HiltViewModel @@ -85,18 +83,12 @@ class ArtistViewModel @Inject constructor( fun playSong(song: Song) { val bundle = Bundle() bundle.putSerializable("song", song) - musicServiceConnection.transportControls.sendCustomAction("play_song", bundle) + musicServiceConnection.playSong(song) } fun shuffleArtist() { - musicServiceConnection.transportControls.setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_ALL) - musicServiceConnection.transportControls.sendCustomAction("play_songs_now", collectAlbumBundle()) - } - - private fun collectAlbumBundle(): Bundle? { - val currentValue = _artistSongs.value as? Resource.Success ?: return null - return Bundle().apply { - putSerializable("songs", currentValue.data as Serializable) - } + val songs = (_artistSongs.value as? Resource.Success)?.data ?: return + musicServiceConnection.enableShuffleMode() + musicServiceConnection.playSongs(songs) } } diff --git a/app/src/main/java/com/devdunnapps/amplify/ui/artist/songs/ArtistAllSongsViewModel.kt b/app/src/main/java/com/devdunnapps/amplify/ui/artist/songs/ArtistAllSongsViewModel.kt index 89b3d3a..d09add0 100644 --- a/app/src/main/java/com/devdunnapps/amplify/ui/artist/songs/ArtistAllSongsViewModel.kt +++ b/app/src/main/java/com/devdunnapps/amplify/ui/artist/songs/ArtistAllSongsViewModel.kt @@ -1,6 +1,5 @@ package com.devdunnapps.amplify.ui.artist.songs -import android.os.Bundle import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -42,8 +41,6 @@ class ArtistAllSongsViewModel @Inject constructor( } fun playSong(song: Song) { - val bundle = Bundle() - bundle.putSerializable("song", song) - musicServiceConnection.transportControls.sendCustomAction("play_song", bundle) + musicServiceConnection.playSong(song) } } diff --git a/app/src/main/java/com/devdunnapps/amplify/ui/main/MainActivity.kt b/app/src/main/java/com/devdunnapps/amplify/ui/main/MainActivity.kt index 5c60705..e36ab62 100644 --- a/app/src/main/java/com/devdunnapps/amplify/ui/main/MainActivity.kt +++ b/app/src/main/java/com/devdunnapps/amplify/ui/main/MainActivity.kt @@ -1,9 +1,8 @@ package com.devdunnapps.amplify.ui.main import android.content.Intent +import android.media.session.PlaybackState import android.os.Bundle -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.PlaybackStateCompat import android.util.TypedValue import android.view.View import androidx.activity.viewModels @@ -87,15 +86,13 @@ class MainActivity : AppCompatActivity() { binding.nowPlayingBoxCollapsed.setContent { Mdc3Theme { - val playbackState = viewModel.playbackState.collectAsState().value.state val currentlyPlayingMetadata = viewModel.mediaMetadata.collectAsState().value - if (currentlyPlayingMetadata != NOTHING_PLAYING) { NowPlayingCollapsed( - albumArtUrl = currentlyPlayingMetadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI), - title = currentlyPlayingMetadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE), - subtitle = currentlyPlayingMetadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST), - isPlaying = playbackState == PlaybackStateCompat.STATE_PLAYING, + albumArtUrl = currentlyPlayingMetadata.artworkUri.toString(), + title = currentlyPlayingMetadata.title.toString(), + subtitle = currentlyPlayingMetadata.artist.toString(), + isPlaying = viewModel.isPlaying.collectAsState().value, onPlayPauseClick = viewModel::togglePlaybackState, onSkipClick = viewModel::skipToNext ) @@ -118,7 +115,7 @@ class MainActivity : AppCompatActivity() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.playbackState.collect { - if (it.state == PlaybackStateCompat.STATE_PLAYING) { + if (it == PlaybackState.STATE_PLAYING) { val marginInDp = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 64f, resources.displayMetrics).toInt() (binding.navContentFrame.layoutParams as CoordinatorLayout.LayoutParams).setMargins(0, 0, 0, marginInDp) diff --git a/app/src/main/java/com/devdunnapps/amplify/ui/main/MainActivityViewModel.kt b/app/src/main/java/com/devdunnapps/amplify/ui/main/MainActivityViewModel.kt index 52d38a6..0a84301 100644 --- a/app/src/main/java/com/devdunnapps/amplify/ui/main/MainActivityViewModel.kt +++ b/app/src/main/java/com/devdunnapps/amplify/ui/main/MainActivityViewModel.kt @@ -1,6 +1,5 @@ package com.devdunnapps.amplify.ui.main -import android.support.v4.media.session.PlaybackStateCompat import androidx.lifecycle.ViewModel import com.devdunnapps.amplify.utils.MusicServiceConnection import dagger.hilt.android.lifecycle.HiltViewModel @@ -10,19 +9,19 @@ import javax.inject.Inject class MainActivityViewModel @Inject constructor( private val musicServiceConnection: MusicServiceConnection ): ViewModel() { - - var playbackState = musicServiceConnection.playbackState - var mediaMetadata = musicServiceConnection.nowPlaying + val isPlaying = musicServiceConnection.isPlaying + val playbackState = musicServiceConnection.playbackState + val mediaMetadata = musicServiceConnection.nowPlaying fun togglePlaybackState() { - if (playbackState.value.state == PlaybackStateCompat.STATE_PLAYING) { - musicServiceConnection.transportControls.pause() + if (musicServiceConnection.isPlaying.value) { + musicServiceConnection.pause() } else { - musicServiceConnection.transportControls.play() + musicServiceConnection.play() } } fun skipToNext() { - musicServiceConnection.transportControls.skipToNext() + musicServiceConnection.skipToNext() } } diff --git a/app/src/main/java/com/devdunnapps/amplify/ui/nowplaying/NowPlayingScreen.kt b/app/src/main/java/com/devdunnapps/amplify/ui/nowplaying/NowPlayingScreen.kt index 6f30296..8408679 100644 --- a/app/src/main/java/com/devdunnapps/amplify/ui/nowplaying/NowPlayingScreen.kt +++ b/app/src/main/java/com/devdunnapps/amplify/ui/nowplaying/NowPlayingScreen.kt @@ -1,7 +1,5 @@ package com.devdunnapps.amplify.ui.nowplaying -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.PlaybackStateCompat import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -52,6 +50,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.common.Player import coil.compose.AsyncImage import coil.request.ImageRequest import com.devdunnapps.amplify.ui.utils.DynamicThemePrimaryColorsFromImage @@ -73,20 +72,20 @@ fun NowPlayingScreen( if (metadata == NOTHING_PLAYING) return - val songDurationMillis = metadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION) + val songDurationMillis = viewModel.duration.collectAsState().value val mediaPosition = viewModel.mediaPosition.collectAsState().value - val playMode = viewModel.playbackState.collectAsState().value + val isPlaying = viewModel.isPlaying.collectAsState().value val shuffleModel = viewModel.shuffleMode.collectAsState().value val repeatMode = viewModel.repeatMode.collectAsState().value NowPlayingHeader( - artworkUrl = metadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI), - title = metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE), - subtitle = metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST), + artworkUrl = metadata.artworkUri.toString(), + title = metadata.title.toString(), + subtitle = metadata.artist.toString(), onSeekToPosition = { viewModel.seekTo((it * 1000).toLong()) }, mediaPosition = mediaPosition, songDurationMillis = songDurationMillis, - playMode = playMode, + isPlaying = isPlaying, shuffleMode = shuffleModel, repeatMode = repeatMode, onToggleShuffleClick = viewModel::toggleShuffleState, @@ -96,7 +95,7 @@ fun NowPlayingScreen( onSkipNext = viewModel::skipToNext, onCollapseNowPlaying = onCollapseNowPlaying, onMenuClick = { - onNowPlayingMenuClick(viewModel.metadata.value.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID)) +// onNowPlayingMenuClick(viewModel.metadata.value.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID)) } ) } @@ -109,8 +108,8 @@ private fun NowPlayingHeader( onSeekToPosition: (Float) -> Unit, mediaPosition: Long, songDurationMillis: Long, - playMode: PlaybackStateCompat, - shuffleMode: Int, + isPlaying: Boolean, + shuffleMode: Boolean, repeatMode: Int, onToggleShuffleClick: () -> Unit, onToggleRepeatClick: () -> Unit, @@ -188,7 +187,7 @@ private fun NowPlayingHeader( ) MediaButtonsRow( - playMode = playMode, + isPlaying = isPlaying, shuffleMode = shuffleMode, repeatMode = repeatMode, onToggleShuffleClick = onToggleShuffleClick, @@ -235,7 +234,6 @@ private fun NowPlayingProgressBar( modifier: Modifier = Modifier ) { Column(modifier = modifier) { - var sliderPosition by remember(mediaPosition) { mutableStateOf((mediaPosition / 1000).toFloat()) } Slider( value = sliderPosition, @@ -278,8 +276,8 @@ private fun NowPlayingProgressBar( @Composable private fun MediaButtonsRow( - playMode: PlaybackStateCompat, - shuffleMode: Int, + isPlaying: Boolean, + shuffleMode: Boolean, repeatMode: Int, onToggleShuffleClick: () -> Unit, onToggleRepeatClick: () -> Unit, @@ -294,13 +292,9 @@ private fun MediaButtonsRow( verticalAlignment = Alignment.CenterVertically, modifier = modifier ) { - val shuffleModeIcon = when (shuffleMode) { - PlaybackStateCompat.SHUFFLE_MODE_ALL -> Icons.Filled.ShuffleOn - else -> Icons.Filled.Shuffle - } IconButton(onClick = onToggleShuffleClick) { Icon( - imageVector = shuffleModeIcon, + imageVector = if (shuffleMode) Icons.Filled.ShuffleOn else Icons.Filled.Shuffle, contentDescription = null, tint = MaterialTheme.colorScheme.onBackground ) @@ -315,15 +309,13 @@ private fun MediaButtonsRow( ) } - val playPauseIcon = - if (playMode.state == PlaybackStateCompat.STATE_PAUSED) Icons.Filled.PlayArrow else Icons.Filled.Pause FloatingActionButton( shape = CircleShape, onClick = onTogglePlayPause, modifier = Modifier.size(64.dp) ) { Icon( - imageVector = playPauseIcon, + imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, contentDescription = null ) } @@ -338,8 +330,8 @@ private fun MediaButtonsRow( } val repeatModeIcon = when (repeatMode) { - PlaybackStateCompat.REPEAT_MODE_ONE -> Icons.Filled.RepeatOne - PlaybackStateCompat.REPEAT_MODE_ALL -> Icons.Filled.RepeatOn + Player.REPEAT_MODE_ONE -> Icons.Filled.RepeatOne + Player.REPEAT_MODE_ALL -> Icons.Filled.RepeatOn else -> Icons.Filled.Repeat } IconButton(onClick = onToggleRepeatClick) { diff --git a/app/src/main/java/com/devdunnapps/amplify/ui/nowplaying/NowPlayingViewModel.kt b/app/src/main/java/com/devdunnapps/amplify/ui/nowplaying/NowPlayingViewModel.kt index e066812..7f31902 100644 --- a/app/src/main/java/com/devdunnapps/amplify/ui/nowplaying/NowPlayingViewModel.kt +++ b/app/src/main/java/com/devdunnapps/amplify/ui/nowplaying/NowPlayingViewModel.kt @@ -2,12 +2,9 @@ package com.devdunnapps.amplify.ui.nowplaying import android.os.Handler import android.os.Looper -import android.support.v4.media.session.PlaybackStateCompat -import androidx.compose.runtime.MutableState -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.media3.common.Player import com.devdunnapps.amplify.utils.MusicServiceConnection -import com.devdunnapps.amplify.utils.currentPlayBackPosition import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import javax.inject.Inject @@ -16,12 +13,11 @@ import javax.inject.Inject class NowPlayingViewModel @Inject constructor( private val musicServiceConnection: MusicServiceConnection ): ViewModel() { - - val playbackState = musicServiceConnection.playbackState + val isPlaying = musicServiceConnection.isPlaying val metadata = musicServiceConnection.nowPlaying - - val shuffleMode: MutableStateFlow = MutableStateFlow(PlaybackStateCompat.SHUFFLE_MODE_NONE) - val repeatMode: MutableStateFlow = MutableStateFlow(PlaybackStateCompat.REPEAT_MODE_NONE) + val duration = musicServiceConnection.duration + val shuffleMode = musicServiceConnection.isShuffleEnabled + val repeatMode = musicServiceConnection.repeatMode val mediaPosition: MutableStateFlow = MutableStateFlow(0) private val handler = Handler(Looper.getMainLooper()) @@ -32,54 +28,42 @@ class NowPlayingViewModel @Inject constructor( } fun togglePlayingState() { - if (playbackState.value.state == PlaybackStateCompat.STATE_PLAYING) { - musicServiceConnection.transportControls.pause() + if (musicServiceConnection.isPlaying.value) { + musicServiceConnection.pause() } else { - musicServiceConnection.transportControls.play() + musicServiceConnection.play() } } fun toggleShuffleState() { - if (musicServiceConnection.shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_NONE) { - musicServiceConnection.transportControls.setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_ALL) - shuffleMode.value = PlaybackStateCompat.SHUFFLE_MODE_ALL - } else { - musicServiceConnection.transportControls.setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_NONE) - shuffleMode.value = PlaybackStateCompat.SHUFFLE_MODE_NONE - } + if (musicServiceConnection.isShuffleEnabled.value) + musicServiceConnection.enableShuffleMode() + else + musicServiceConnection.disableShuffleMode() } fun toggleRepeatState() { - when (musicServiceConnection.repeatMode) { - PlaybackStateCompat.REPEAT_MODE_NONE -> { - musicServiceConnection.transportControls.setRepeatMode(PlaybackStateCompat.REPEAT_MODE_ONE) - repeatMode.value = PlaybackStateCompat.REPEAT_MODE_ONE - } - PlaybackStateCompat.REPEAT_MODE_ONE -> { - musicServiceConnection.transportControls.setRepeatMode(PlaybackStateCompat.REPEAT_MODE_ALL) - repeatMode.value = PlaybackStateCompat.REPEAT_MODE_ALL - } - PlaybackStateCompat.REPEAT_MODE_ALL -> { - musicServiceConnection.transportControls.setRepeatMode(PlaybackStateCompat.REPEAT_MODE_NONE) - repeatMode.value = PlaybackStateCompat.REPEAT_MODE_NONE - } + when (musicServiceConnection.repeatMode.value) { + Player.REPEAT_MODE_OFF -> musicServiceConnection.setRepeatMode(Player.REPEAT_MODE_ONE) + Player.REPEAT_MODE_ONE -> musicServiceConnection.setRepeatMode(Player.REPEAT_MODE_ALL) + Player.REPEAT_MODE_ALL -> musicServiceConnection.setRepeatMode(Player.REPEAT_MODE_OFF) } } fun skipToPrevious() { - musicServiceConnection.transportControls.skipToPrevious() + musicServiceConnection.skipToPrevious() } fun skipToNext() { - musicServiceConnection.transportControls.skipToNext() + musicServiceConnection.skipToNext() } fun seekTo(position: Long) { - musicServiceConnection.transportControls.seekTo(position) + musicServiceConnection.seekTo(position) } private fun checkPlaybackPosition(): Boolean = handler.postDelayed({ - val currPosition = playbackState.value.currentPlayBackPosition + val currPosition = musicServiceConnection.currentPosition if (mediaPosition.value != currPosition) mediaPosition.value = currPosition if (updatePosition) diff --git a/app/src/main/java/com/devdunnapps/amplify/ui/playlist/PlaylistViewModel.kt b/app/src/main/java/com/devdunnapps/amplify/ui/playlist/PlaylistViewModel.kt index 72f3107..5fef9df 100644 --- a/app/src/main/java/com/devdunnapps/amplify/ui/playlist/PlaylistViewModel.kt +++ b/app/src/main/java/com/devdunnapps/amplify/ui/playlist/PlaylistViewModel.kt @@ -1,7 +1,5 @@ package com.devdunnapps.amplify.ui.playlist -import android.os.Bundle -import android.support.v4.media.session.PlaybackStateCompat import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -19,7 +17,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import java.io.Serializable import javax.inject.Inject @HiltViewModel @@ -63,28 +60,21 @@ class PlaylistViewModel @Inject constructor( } fun playSong(song: Song) { - val bundle = Bundle() - bundle.putSerializable("song", song) - musicServiceConnection.transportControls.sendCustomAction("play_song", bundle) + musicServiceConnection.playSong(song) } fun playPlaylist(whenToPlay: WhenToPlay = WhenToPlay.NOW, shuffle: Boolean = false) { - val action = when (whenToPlay) { - WhenToPlay.NOW -> "play_songs_now" - WhenToPlay.NEXT -> "add_songs_to_queue" - WhenToPlay.QUEUE -> "play_songs_next" - } - - val shuffleMode = if (shuffle) PlaybackStateCompat.SHUFFLE_MODE_ALL else PlaybackStateCompat.SHUFFLE_MODE_NONE - musicServiceConnection.transportControls.setShuffleMode(shuffleMode) + val songs = (_uiState.value as? Resource.Success)?.data?.songs ?: return - musicServiceConnection.transportControls.sendCustomAction(action, collectPlaylistBundle()) - } + if (shuffle) + musicServiceConnection.enableShuffleMode() + else + musicServiceConnection.disableShuffleMode() - private fun collectPlaylistBundle(): Bundle { - return Bundle().apply { - val songs = (_uiState.value as? Resource.Success)?.data?.songs ?: return@apply - putSerializable("songs", songs as Serializable) + when (whenToPlay) { + WhenToPlay.NOW -> musicServiceConnection.playSongs(songs) + WhenToPlay.NEXT -> musicServiceConnection.playSongsNext(songs) + WhenToPlay.QUEUE -> musicServiceConnection.addSongsToQueue(songs) } } diff --git a/app/src/main/java/com/devdunnapps/amplify/ui/search/SearchFragment.kt b/app/src/main/java/com/devdunnapps/amplify/ui/search/SearchFragment.kt index 4b4a072..5785ab5 100644 --- a/app/src/main/java/com/devdunnapps/amplify/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/devdunnapps/amplify/ui/search/SearchFragment.kt @@ -105,7 +105,7 @@ private fun SearchScreen( ) } ) { - Box(modifier = Modifier.padding(it)) { + Box(modifier = Modifier.padding(top = it.calculateTopPadding())) { when (searchResults) { is Resource.Loading -> LoadingScreen() is Resource.Error -> ErrorScreen() diff --git a/app/src/main/java/com/devdunnapps/amplify/ui/search/SearchViewModel.kt b/app/src/main/java/com/devdunnapps/amplify/ui/search/SearchViewModel.kt index 5fde83f..aad137d 100644 --- a/app/src/main/java/com/devdunnapps/amplify/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/devdunnapps/amplify/ui/search/SearchViewModel.kt @@ -1,6 +1,5 @@ package com.devdunnapps.amplify.ui.search -import android.os.Bundle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.devdunnapps.amplify.domain.models.SearchResults @@ -54,8 +53,6 @@ class SearchViewModel @Inject constructor( } fun playSong(song: Song) { - val bundle = Bundle() - bundle.putSerializable("song", song) - musicServiceConnection.transportControls.sendCustomAction("play_song", bundle) + musicServiceConnection.playSong(song) } } diff --git a/app/src/main/java/com/devdunnapps/amplify/ui/songbottomsheet/SongMenuBottomSheetViewModel.kt b/app/src/main/java/com/devdunnapps/amplify/ui/songbottomsheet/SongMenuBottomSheetViewModel.kt index cf01247..3db9606 100644 --- a/app/src/main/java/com/devdunnapps/amplify/ui/songbottomsheet/SongMenuBottomSheetViewModel.kt +++ b/app/src/main/java/com/devdunnapps/amplify/ui/songbottomsheet/SongMenuBottomSheetViewModel.kt @@ -1,7 +1,5 @@ package com.devdunnapps.amplify.ui.songbottomsheet -import android.os.Bundle -import android.support.v4.media.session.PlaybackStateCompat import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -18,7 +16,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import java.io.Serializable import javax.inject.Inject @HiltViewModel @@ -68,19 +65,12 @@ class SongMenuBottomSheetViewModel @Inject constructor( } fun playSong(whenToPlay: WhenToPlay = WhenToPlay.NOW) { - val action = when (whenToPlay) { - WhenToPlay.NOW -> "play_songs_now" - WhenToPlay.NEXT -> "add_songs_to_queue" - WhenToPlay.QUEUE -> "play_songs_next" + val song = (screenState.value as? Resource.Success)?.data?.song ?: return + when (whenToPlay) { + WhenToPlay.NOW -> mediaServiceConnection.playSong(song) + WhenToPlay.NEXT -> mediaServiceConnection.playSongNext(song) + WhenToPlay.QUEUE -> mediaServiceConnection.addSongToQueue(song) } - - val curState = screenState.value as? Resource.Success ?: return - val songs = ArrayList() - songs.add(curState.data.song) - val bundle = Bundle() - bundle.putSerializable("songs", songs) - mediaServiceConnection.transportControls.setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_NONE) - mediaServiceConnection.transportControls.sendCustomAction(action, bundle) } fun removeSongFromPlaylist() { diff --git a/app/src/main/java/com/devdunnapps/amplify/ui/songs/SongsViewModel.kt b/app/src/main/java/com/devdunnapps/amplify/ui/songs/SongsViewModel.kt index 5fcd812..7498d2a 100644 --- a/app/src/main/java/com/devdunnapps/amplify/ui/songs/SongsViewModel.kt +++ b/app/src/main/java/com/devdunnapps/amplify/ui/songs/SongsViewModel.kt @@ -28,8 +28,6 @@ class SongsViewModel @Inject constructor( ).flow.cachedIn(viewModelScope) fun playSong(song: Song) { - val bundle = Bundle() - bundle.putSerializable("song", song) - musicServiceConnection.transportControls.sendCustomAction("play_song", bundle) + musicServiceConnection.playSong(song) } } diff --git a/app/src/main/java/com/devdunnapps/amplify/utils/Extensions.kt b/app/src/main/java/com/devdunnapps/amplify/utils/Extensions.kt index 6445c2d..050317f 100644 --- a/app/src/main/java/com/devdunnapps/amplify/utils/Extensions.kt +++ b/app/src/main/java/com/devdunnapps/amplify/utils/Extensions.kt @@ -1,12 +1,11 @@ package com.devdunnapps.amplify.utils import android.os.SystemClock -import android.support.v4.media.session.PlaybackStateCompat -inline val PlaybackStateCompat.currentPlayBackPosition: Long - get() = if (state == PlaybackStateCompat.STATE_PLAYING) { - val timeDelta = SystemClock.elapsedRealtime() - lastPositionUpdateTime - (position + (timeDelta * playbackSpeed)).toLong() - } else { - position - } +//inline val PlaybackStateCompat.currentPlayBackPosition: Long +// get() = if (state == PlaybackStateCompat.STATE_PLAYING) { +// val timeDelta = SystemClock.elapsedRealtime() - lastPositionUpdateTime +// (position + (timeDelta * playbackSpeed)).toLong() +// } else { +// position +// } diff --git a/app/src/main/java/com/devdunnapps/amplify/utils/MusicService.kt b/app/src/main/java/com/devdunnapps/amplify/utils/MusicService.kt index 50974ff..6bdc2b9 100644 --- a/app/src/main/java/com/devdunnapps/amplify/utils/MusicService.kt +++ b/app/src/main/java/com/devdunnapps/amplify/utils/MusicService.kt @@ -1,32 +1,34 @@ package com.devdunnapps.amplify.utils -import android.app.Notification import android.content.Intent import android.os.Bundle -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.MediaMetadataCompat.* -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat -import androidx.core.content.ContextCompat -import androidx.media.MediaBrowserServiceCompat -import com.devdunnapps.amplify.domain.models.Song +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult import com.devdunnapps.amplify.domain.repository.PlexRepository -import com.google.android.exoplayer2.* -import com.google.android.exoplayer2.audio.AudioAttributes -import com.google.android.exoplayer2.ui.PlayerNotificationManager +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch +private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = "com.devdunnapps.amplify.utils.SHUFFLE_ON" +private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF = "com.devdunnapps.amplify.utils.SHUFFLE_OFF" + +@UnstableApi @AndroidEntryPoint -class MusicService : MediaBrowserServiceCompat() { +class MusicService : MediaSessionService() { // We must implement our own inject because Hilt does not support MediaBrowserServiceCompat @EntryPoint @@ -35,207 +37,77 @@ class MusicService : MediaBrowserServiceCompat() { fun repository(): PlexRepository } - private lateinit var mediaSession: MediaSessionCompat - private lateinit var notificationManager: NotificationManager - private var currentSongID: String? = null + private var mediaSession: MediaSession? = null +// private lateinit var notificationManager: NotificationManager +// private var currentSongID: String? = null private var isForegroundService = false private val job = SupervisorJob() private val serviceScope = CoroutineScope(Dispatchers.IO + job) - private val playerListener = PlayerEventListener() - private val appAudioAttributes: AudioAttributes = AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .build() private val exoPlayer: ExoPlayer by lazy { - ExoPlayer.Builder(this).build().apply { - setHandleAudioBecomingNoisy(true) - setAudioAttributes(appAudioAttributes, true) - addListener(playerListener) - } - } - - private val mediaSessionCallbacks = object : MediaSessionCompat.Callback() { - - override fun onCustomAction(action: String, extras: Bundle) { - super.onCustomAction(action, extras) - exoPlayer.repeatMode = Player.REPEAT_MODE_OFF - when (action) { - "play_song" -> { - exoPlayer.repeatMode = Player.REPEAT_MODE_OFF - val song = extras.getSerializable("song") as Song - val mediaItem = convertSongToMediaItem(song) - if ((mediaItem.localConfiguration?.tag as Song).id != currentSongID) { - playMediaItem(mediaItem) - } else { - play() // this song is already playing so don't restart - } - } - "play_songs_now" -> { - playMediaItems(bundleToMediaItems(extras)) - } - "add_songs_to_queue" -> { - if (exoPlayer.mediaItemCount == 0) { - playMediaItems(bundleToMediaItems(extras)) - } else { - exoPlayer.addMediaItems(bundleToMediaItems(extras)) - } - } - "play_songs_next" -> { - if (exoPlayer.mediaItemCount == 0) { - playMediaItems(bundleToMediaItems(extras)) - } else { - exoPlayer.apply { - addMediaItems(currentMediaItemIndex + 1, bundleToMediaItems(extras)) - } - } - } - } - } - - @Suppress("UNCHECKED_CAST") - private fun bundleToMediaItems(bundle: Bundle): ArrayList { - val songs: ArrayList = bundle.getSerializable("songs") as ArrayList - val mediaItems = arrayListOf() - for (song in songs) { - mediaItems.add(convertSongToMediaItem(song)) - } - return mediaItems - } - - override fun onPause() { - super.onPause() - pause() - } - - override fun onPlay() { - super.onPlay() - play() - } - - override fun onSkipToNext() { - super.onSkipToNext() - exoPlayer.seekToNext() - } - - override fun onSkipToPrevious() { - super.onSkipToPrevious() - exoPlayer.seekToPrevious() - } - - override fun onSeekTo(pos: Long) { - super.onSeekTo(pos) - exoPlayer.seekTo(pos) - } - - override fun onSetRepeatMode(repeatMode: Int) { - super.onSetRepeatMode(repeatMode) - exoPlayer.repeatMode = repeatMode - mediaSession.setRepeatMode(repeatMode) - } - - override fun onSetShuffleMode(shuffleMode: Int) { - super.onSetShuffleMode(shuffleMode) - exoPlayer.shuffleModeEnabled = shuffleMode != PlaybackStateCompat.SHUFFLE_MODE_NONE - mediaSession.setShuffleMode(shuffleMode) - } - } - - private inner class PlayerEventListener : Player.Listener { - override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { - when (playbackState) { - Player.STATE_BUFFERING, - Player.STATE_READY -> { - notificationManager.showNotificationForPlayer(exoPlayer) - if (playbackState == Player.STATE_READY) { - if (!playWhenReady) { - // If playback is paused we remove the foreground state which allows the - // notification to be dismissed. An alternative would be to provide a - // "close" button in the notification which stops playback and clears - // the notification. - stopForeground(false) - isForegroundService = false - } - } - } - else -> { - notificationManager.hideNotification() - } - } - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - if (isPlaying) { - updatePlaybackState(PlaybackStateCompat.STATE_PLAYING) - } else { - updatePlaybackState(PlaybackStateCompat.STATE_PAUSED) - } - } - - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - currentSongID = (mediaItem?.localConfiguration?.tag as Song).id - - // TODO: only mark an item as played if the entire song has been played - val hiltEntryPoint = - EntryPointAccessors.fromApplication(applicationContext, MusicServiceEntryPoint::class.java) - serviceScope.launch { - hiltEntryPoint.repository().markSongAsListened(currentSongID!!) - } - - mediaSession.setMetadata(convertSongToMetadata(mediaItem.localConfiguration?.tag as Song)) - updatePlaybackState(PlaybackStateCompat.STATE_PLAYING) - } + ExoPlayer.Builder(this) + .setHandleAudioBecomingNoisy(true) + .setAudioAttributes(appAudioAttributes, true) + .setTrackSelector(DefaultTrackSelector(this)) + .build() } override fun onCreate() { super.onCreate() - mediaSession = MediaSessionCompat(this, MusicService::class.java.simpleName).apply { - setCallback(mediaSessionCallbacks) - isActive = true - setSessionToken(sessionToken) - } - - notificationManager = NotificationManager( - this, - mediaSession.sessionToken, - PlayerNotificationListener() - ) - } + mediaSession = MediaSession.Builder(this, exoPlayer) + .setCallback(MediaSessionCallback()) + .build() - private fun playMediaItem(mediaItem: MediaItem) { - exoPlayer.apply { - setMediaItem(mediaItem) - prepare() - play() - } +// notificationManager = NotificationManager(this, PlayerNotificationListener()) } - private fun playMediaItems(mediaItems: List) { - exoPlayer.apply { - setMediaItems(mediaItems, true) - prepare() - play() - } - } + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + +// override fun onUpdateNotification(session: MediaSession) { +// } + + private inner class MediaSessionCallback : MediaSession.Callback { + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): MediaSession.ConnectionResult { + val connectionResult = super.onConnect(session, controller) + val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() + return MediaSession.ConnectionResult.accept( + availableSessionCommands.build(), + connectionResult.availablePlayerCommands + ) + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + when (customCommand.customAction) { + CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> exoPlayer.shuffleModeEnabled = true + CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> exoPlayer.shuffleModeEnabled = false + else -> return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_BAD_VALUE)) + } - private fun play() { - exoPlayer.apply { - playWhenReady = true - updatePlaybackState(PlaybackStateCompat.STATE_PLAYING) - mediaSession.isActive = true + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } - } - private fun pause() { - exoPlayer.apply { - playWhenReady = false - if (playbackState == PlaybackStateCompat.STATE_PLAYING) { - updatePlaybackState(PlaybackStateCompat.STATE_PAUSED) - } + override fun onAddMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList + ): ListenableFuture> { + val updatedMediaItems = mediaItems.map { it.buildUpon().setUri(it.requestMetadata.mediaUri).build() } + return Futures.immediateFuture(updatedMediaItems) } } @@ -248,75 +120,34 @@ class MusicService : MediaBrowserServiceCompat() { } override fun onDestroy() { - mediaSession.run { - isActive = false + mediaSession?.run { + player.release() release() + mediaSession = null } job.cancel() - exoPlayer.removeListener(playerListener) - exoPlayer.release() + super.onDestroy() } - private fun updatePlaybackState(state: Int) { - mediaSession.setPlaybackState( - PlaybackStateCompat.Builder() - .setState(state, exoPlayer.currentPosition, 1F) - .setActions(PlaybackStateCompat.ACTION_SEEK_TO - or PlaybackStateCompat.ACTION_PLAY - or PlaybackStateCompat.ACTION_PAUSE - or PlaybackStateCompat.ACTION_PLAY_PAUSE - or PlaybackStateCompat.ACTION_SKIP_TO_NEXT - or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS) - .build() - ) - } - - private fun convertSongToMediaItem(song: Song): MediaItem { - val songUrl = PlexUtils.getInstance(applicationContext).addKeyAndAddress(song.songUrl) - return MediaItem.Builder() - .setUri(songUrl) - .setTag(song) - .build() - } - - private fun convertSongToMetadata(song: Song): MediaMetadataCompat { - val artworkUrl = PlexUtils.getInstance(applicationContext).addKeyAndAddress(song.thumb) - return Builder() - .putString(METADATA_KEY_TITLE, song.title) - .putString(METADATA_KEY_ALBUM, song.albumName) - .putString(METADATA_KEY_ARTIST, song.artistName) - .putString(METADATA_KEY_ALBUM_ART_URI, artworkUrl) - .putString(METADATA_KEY_ART_URI, artworkUrl) - .putLong(METADATA_KEY_DURATION, song.duration) - .putString(METADATA_KEY_MEDIA_ID, song.id) - .putString("ALBUM_ID", song.albumId) - .putString("ARTIST_ID", song.artistId) - .build() - } - - override fun onLoadChildren(parentId: String, result: Result>) {} - - override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?) = BrowserRoot("", null) - - private inner class PlayerNotificationListener : PlayerNotificationManager.NotificationListener { - - override fun onNotificationPosted(notificationId: Int, notification: Notification, ongoing: Boolean) { - if (ongoing && !isForegroundService) { - ContextCompat.startForegroundService( - applicationContext, - Intent(applicationContext, this@MusicService.javaClass) - ) - startForeground(notificationId, notification) - isForegroundService = true - } - } - - override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) { - stopForeground(true) - isForegroundService = false - stopSelf() - } - } +// private inner class PlayerNotificationListener : PlayerNotificationManager.NotificationListener { +// +// override fun onNotificationPosted(notificationId: Int, notification: Notification, ongoing: Boolean) { +// if (ongoing && !isForegroundService) { +// ContextCompat.startForegroundService( +// applicationContext, +// Intent(applicationContext, this@MusicService.javaClass) +// ) +// startForeground(notificationId, notification) +// isForegroundService = true +// } +// } +// +// override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) { +// stopForeground(true) +// isForegroundService = false +// stopSelf() +// } +// } } diff --git a/app/src/main/java/com/devdunnapps/amplify/utils/MusicServiceConnection.kt b/app/src/main/java/com/devdunnapps/amplify/utils/MusicServiceConnection.kt index 983d5c7..c3ad3e8 100644 --- a/app/src/main/java/com/devdunnapps/amplify/utils/MusicServiceConnection.kt +++ b/app/src/main/java/com/devdunnapps/amplify/utils/MusicServiceConnection.kt @@ -2,103 +2,190 @@ package com.devdunnapps.amplify.utils import android.content.ComponentName import android.content.Context -import android.os.Bundle -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaControllerCompat -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat -import androidx.lifecycle.MutableLiveData -import androidx.media.MediaBrowserServiceCompat +import android.media.session.PlaybackState +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaItem.RequestMetadata +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.devdunnapps.amplify.domain.models.Song +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow -class MusicServiceConnection(context: Context, serviceComponent: ComponentName) { - val isConnected = MutableLiveData(false) +class MusicServiceConnection(private val context: Context, serviceComponent: ComponentName) { + private val mediaControllerFuture: ListenableFuture - val playbackState = MutableStateFlow(EMPTY_PLAYBACK_STATE) + private val mediaController: MediaController? + get() = if (mediaControllerFuture.isDone) mediaControllerFuture.get() else null - val nowPlaying = MutableStateFlow(NOTHING_PLAYING) + private val _isPlaying = MutableStateFlow(false) + val isPlaying = _isPlaying.asStateFlow() - val transportControls: MediaControllerCompat.TransportControls - get() = mediaController.transportControls + private val _playbackState = MutableStateFlow(EMPTY_PLAYBACK_STATE.state) + val playbackState = _playbackState.asStateFlow() - val shuffleMode: Int - get() = mediaController.shuffleMode + private val _nowPlaying = MutableStateFlow(NOTHING_PLAYING) + val nowPlaying = _nowPlaying.asStateFlow() - val repeatMode: Int - get() = mediaController.repeatMode + val currentPosition: Long + get() = mediaController?.currentPosition ?: 0 - private val mediaBrowserConnectionCallback = MediaBrowserConnectionCallback(context) + private val _isShuffleEnabled = MutableStateFlow(false) + val isShuffleEnabled = _isShuffleEnabled.asStateFlow() - private val mediaBrowser = MediaBrowserCompat( - context, - serviceComponent, - mediaBrowserConnectionCallback, - null - ).apply { connect() } + private val _repeatMode = MutableStateFlow(Player.REPEAT_MODE_OFF) + val repeatMode = _repeatMode.asStateFlow() - private lateinit var mediaController: MediaControllerCompat + private val _duration = MutableStateFlow(0L) + val duration = _duration.asStateFlow() - private inner class MediaBrowserConnectionCallback( - private val context: Context - ) : MediaBrowserCompat.ConnectionCallback() { + fun enableShuffleMode() { + mediaController?.shuffleModeEnabled = true + } + + fun disableShuffleMode() { + mediaController?.shuffleModeEnabled = false + } - override fun onConnected() { - mediaController = MediaControllerCompat(context, mediaBrowser.sessionToken).apply { - registerCallback(MediaControllerCallback()) - } + fun setRepeatMode(repeatMode: Int) { + mediaController?.repeatMode = repeatMode + } - isConnected.postValue(true) + fun playSong(song: Song) { + mediaController?.apply { + setMediaItem(convertSongToMediaItem(song)) + prepare() + play() } + } - override fun onConnectionSuspended() { - isConnected.postValue(false) + fun playSongNext(song: Song) { + mediaController?.apply { + addMediaItem(1, convertSongToMediaItem(song)) + prepare() + play() } + } - override fun onConnectionFailed() { - isConnected.postValue(false) + fun addSongToQueue(song: Song) { + mediaController?.apply { + addMediaItem(mediaItemCount, convertSongToMediaItem(song)) + prepare() + play() } } - private inner class MediaControllerCallback : MediaControllerCompat.Callback() { + fun playSongs(songs: List) { + mediaController?.apply { + setMediaItems(songs.map { convertSongToMediaItem(it) }) + prepare() + play() + } + } - override fun onPlaybackStateChanged(state: PlaybackStateCompat?) { - playbackState.value = state ?: EMPTY_PLAYBACK_STATE + fun playSongsNext(songs: List) { + mediaController?.apply { + addMediaItems(1, songs.map { convertSongToMediaItem(it) }) + prepare() + play() } + } - override fun onMetadataChanged(metadata: MediaMetadataCompat?) { - // When ExoPlayer stops we will receive a callback with "empty" metadata. This is a - // metadata object which has been instantiated with default values. The default value - // for media ID is null so we assume that if this value is null we are not playing - // anything. - nowPlaying.value = - if (metadata?.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID) == null) - NOTHING_PLAYING - else - metadata + fun addSongsToQueue(songs: List) { + mediaController?.apply { + addMediaItems(mediaItemCount, songs.map { convertSongToMediaItem(it) }) + prepare() + play() } + } + + fun play() { + mediaController?.play() + } - override fun onQueueChanged(queue: MutableList?) = Unit + fun pause() { + mediaController?.pause() + } - override fun onSessionEvent(event: String?, extras: Bundle?) = Unit + fun skipToPrevious() { + mediaController?.seekToPrevious() + } - /** - * Normally if a [MediaBrowserServiceCompat] drops its connection the callback comes via - * [MediaControllerCompat.Callback] (here). But since other connection status events - * are sent to [MediaBrowserCompat.ConnectionCallback], we catch the disconnect here and - * send it on to the other callback. - */ - override fun onSessionDestroyed() { - mediaBrowserConnectionCallback.onConnectionSuspended() - } + fun skipToNext() { + mediaController?.seekToNextMediaItem() + } + + fun seekTo(positionMs: Long) { + mediaController?.seekTo(positionMs) + } + + private fun convertSongToMediaItem(song: Song): MediaItem { + val songUrl = PlexUtils.getInstance(context).addKeyAndAddress(song.songUrl) + return MediaItem.Builder() + .setRequestMetadata(RequestMetadata.Builder().setMediaUri(songUrl.toUri()).build()) + .setMediaMetadata(convertSongToMetadata(song)) + .build() + } + + private fun convertSongToMetadata(song: Song): MediaMetadata { + val artworkUrl = PlexUtils.getInstance(context).addKeyAndAddress(song.thumb) + return MediaMetadata.Builder() + .setTitle(song.title) + .setAlbumTitle(song.albumName) + .setArtist(song.artistName) + .setArtworkUri(artworkUrl.toUri()) + .build() + } + + init { + val sessionToken = SessionToken(context, serviceComponent) + mediaControllerFuture = MediaController.Builder(context, sessionToken).buildAsync() + mediaControllerFuture.addListener( + { + mediaController?.addListener(object : Player.Listener { + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + super.onMediaMetadataChanged(mediaMetadata) + _nowPlaying.value = mediaMetadata + } + + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + _playbackState.value = playbackState + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + _isPlaying.value = isPlaying + } + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + super.onShuffleModeEnabledChanged(shuffleModeEnabled) + _isShuffleEnabled.value = shuffleModeEnabled + } + + override fun onRepeatModeChanged(repeatMode: Int) { + super.onRepeatModeChanged(repeatMode) + _repeatMode.value = repeatMode + } + + override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { + // TODO: doesn't work + if (playbackState == Player.STATE_READY) + _duration.value = mediaController?.duration ?: 0 + } + }) + }, + MoreExecutors.directExecutor() + ) } } -val EMPTY_PLAYBACK_STATE: PlaybackStateCompat = PlaybackStateCompat.Builder() - .setState(PlaybackStateCompat.STATE_NONE, 0, 0f) +val EMPTY_PLAYBACK_STATE: PlaybackState = PlaybackState.Builder() + .setState(PlaybackState.STATE_NONE, 0, 0f) .build() -val NOTHING_PLAYING: MediaMetadataCompat = MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, "") - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, 0) - .build() +val NOTHING_PLAYING: MediaMetadata = MediaMetadata.EMPTY diff --git a/app/src/main/java/com/devdunnapps/amplify/utils/NotificationManager.kt b/app/src/main/java/com/devdunnapps/amplify/utils/NotificationManager.kt index 3342590..3b2bcec 100644 --- a/app/src/main/java/com/devdunnapps/amplify/utils/NotificationManager.kt +++ b/app/src/main/java/com/devdunnapps/amplify/utils/NotificationManager.kt @@ -7,14 +7,13 @@ import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.net.Uri import android.os.Build -import android.support.v4.media.session.MediaControllerCompat -import android.support.v4.media.session.MediaSessionCompat +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerNotificationManager import coil.ImageLoader import coil.request.ImageRequest import com.devdunnapps.amplify.R import com.devdunnapps.amplify.ui.main.MainActivity -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.ui.PlayerNotificationManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -23,9 +22,9 @@ import kotlinx.coroutines.withContext const val NOTIFICATION_LARGE_ICON_SIZE = 144 +@UnstableApi class NotificationManager( private val context: Context, - sessionToken: MediaSessionCompat.Token, notificationListener: PlayerNotificationManager.NotificationListener ) { @@ -33,13 +32,12 @@ class NotificationManager( private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) private val playerNotificationManager: PlayerNotificationManager = PlayerNotificationManager.Builder(context, 1, "Amplify") - .setMediaDescriptionAdapter(DescriptionAdapter(MediaControllerCompat(context, sessionToken))) + .setMediaDescriptionAdapter(DescriptionAdapter()) .setNotificationListener(notificationListener) .setChannelNameResourceId(R.string.playback_channel_name) .setChannelDescriptionResourceId(R.string.playback_channel_desc) .build() .apply { - setMediaSessionToken(sessionToken) setUseNextActionInCompactView(true) setUseRewindAction(false) setUseFastForwardAction(false) @@ -53,21 +51,22 @@ class NotificationManager( playerNotificationManager.setPlayer(player) } - private inner class DescriptionAdapter(private val controller: MediaControllerCompat) : PlayerNotificationManager.MediaDescriptionAdapter { + @UnstableApi + private inner class DescriptionAdapter : PlayerNotificationManager.MediaDescriptionAdapter { var currentIconUri: Uri? = null var currentBitmap: Bitmap? = null override fun getCurrentContentTitle(player: Player): String { - return controller.metadata.description.title.toString() + return player.mediaMetadata.title.toString() } override fun getCurrentContentText(player: Player): String { - return controller.metadata.description.subtitle.toString() + return player.mediaMetadata.artist.toString() } override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? { - val iconUri = controller.metadata.description.iconUri + val iconUri = player.mediaMetadata.artworkUri return if (currentIconUri != iconUri || currentBitmap == null) { // cache the icon so we don't have to recreate it if we don't need to diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8ae118f..5bcad4b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ androidxCore = "1.9.0" androidxNavigation = "2.5.3" androidxLifecycle = "2.5.1" androidxPreference = "1.2.0" -androidxMedia = "1.6.0" +androidxMedia3 = "1.0.0" androidxTestExt = "1.1.5" androidxEspresso = "3.5.1" androidxCompose = "1.3.3" @@ -53,7 +53,9 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig androidx-lifecycle-viewModel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "androidxLifecycle" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } -androidx-media = { group = "androidx.media", name = "media", version.ref = "androidxMedia" } +androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "androidxMedia3" } +androidx-media3-mediaSession = { group = "androidx.media3", name = "media3-session", version.ref = "androidxMedia3" } +androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "androidxMedia3" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment", version.ref = "androidxNavigation" } androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "androidxNavigation" }