From ee34999762a75efe12d6f5e3980f402ef6dccbcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Font=C3=A1n?= Date: Wed, 17 Apr 2024 23:02:29 +0200 Subject: [PATCH] feat/WIP: Improved the mediaplayer and added a little implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gabriel Fontán --- .../notifications/MediaNotificationManager.kt | 10 ++ .../metadator/presentation/Navigation.kt | 4 +- .../components/others/MediaplayerSheet.kt | 147 +++++++++++++++++- .../pages/mediaplayer/MediaplayerPage.kt | 53 +++++-- .../pages/mediaplayer/MediaplayerViewModel.kt | 139 +++++++++++++++++ app/src/main/res/values/strings.xml | 2 + 6 files changed, 343 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerViewModel.kt diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaNotificationManager.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaNotificationManager.kt index fa8ee4c..a575173 100644 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaNotificationManager.kt +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaNotificationManager.kt @@ -5,6 +5,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.os.Build +import android.util.Log import androidx.core.app.NotificationCompat import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer @@ -23,6 +24,7 @@ class MediaNotificationManager @Inject constructor( context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager init { + Log.i("MediaNotificationManager", "init: creating notification manager") createNotificationChannel() } @@ -58,12 +60,20 @@ class MediaNotificationManager @Inject constructor( private fun startForegroundNotification(mediaSessionService: MediaSessionService) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Log.d( + "MediaNotificationManager", + "startForegroundNotification: creating notification for API >= 26" + ) val notification = Notification.Builder(context, NOTIFICATION_CHANNEL_ID) .setCategory(Notification.CATEGORY_SERVICE) .build() mediaSessionService.startForeground(NOTIFICATION_ID, notification) } else { + Log.d( + "MediaNotificationManager", + "startForegroundNotification: creating notification for API < 26" + ) val notification = NotificationCompat.Builder(context) .setCategory(NotificationCompat.CATEGORY_SERVICE) .build() diff --git a/app/src/main/java/com/bobbyesp/metadator/presentation/Navigation.kt b/app/src/main/java/com/bobbyesp/metadator/presentation/Navigation.kt index a0f7a6a..e7ffde4 100644 --- a/app/src/main/java/com/bobbyesp/metadator/presentation/Navigation.kt +++ b/app/src/main/java/com/bobbyesp/metadator/presentation/Navigation.kt @@ -49,6 +49,7 @@ import com.bobbyesp.metadator.presentation.common.routesToNavigate import com.bobbyesp.metadator.presentation.pages.MediaStorePageViewModel import com.bobbyesp.metadator.presentation.pages.home.HomePage import com.bobbyesp.metadator.presentation.pages.mediaplayer.MediaplayerPage +import com.bobbyesp.metadator.presentation.pages.mediaplayer.MediaplayerViewModel import com.bobbyesp.metadator.presentation.pages.utilities.tageditor.ID3MetadataEditorPage import com.bobbyesp.metadator.presentation.pages.utilities.tageditor.ID3MetadataEditorPageViewModel import com.bobbyesp.ui.motion.animatedComposable @@ -176,7 +177,8 @@ fun Navigator() { route = Route.MediaplayerNavigator.route ) { animatedComposable(Route.MediaplayerNavigator.Mediaplayer.route) { - MediaplayerPage() + val viewModel = hiltViewModel() + MediaplayerPage(viewModel) } } diff --git a/app/src/main/java/com/bobbyesp/metadator/presentation/components/others/MediaplayerSheet.kt b/app/src/main/java/com/bobbyesp/metadator/presentation/components/others/MediaplayerSheet.kt index a24c95e..4f1c96c 100644 --- a/app/src/main/java/com/bobbyesp/metadator/presentation/components/others/MediaplayerSheet.kt +++ b/app/src/main/java/com/bobbyesp/metadator/presentation/components/others/MediaplayerSheet.kt @@ -1,22 +1,165 @@ package com.bobbyesp.metadator.presentation.components.others +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBarDefaults import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bobbyesp.metadator.R +import com.bobbyesp.metadator.presentation.components.image.ArtworkAsyncImage +import com.bobbyesp.metadator.presentation.pages.mediaplayer.MediaplayerViewModel +import com.bobbyesp.metadator.presentation.theme.MetadatorTheme +import com.bobbyesp.model.Song import com.bobbyesp.ui.components.bottomsheet.draggable.DraggableBottomSheet import com.bobbyesp.ui.components.bottomsheet.draggable.DraggableBottomSheetState +import com.bobbyesp.ui.components.button.DynamicButton +import com.bobbyesp.ui.components.text.MarqueeText @Composable -fun MediaplayerSheet(modifier: Modifier = Modifier, state: DraggableBottomSheetState) { +fun MediaplayerSheet( + modifier: Modifier = Modifier, + state: DraggableBottomSheetState, + viewModel: MediaplayerViewModel +) { + + val songs = viewModel.pageViewState.value.playingSong DraggableBottomSheet( state = state, collapsedContent = { - + MediaplayerCollapsedContent( + queueSongs = listOf(songs ?: return@DraggableBottomSheet) + ) }, backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(NavigationBarDefaults.Elevation) ) { } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun MediaplayerCollapsedContent( + modifier: Modifier = Modifier, + queueSongs: List +) { + val pagesCount = queueSongs.size + + val pagerState = rememberPagerState(pageCount = { + pagesCount + }) + + HorizontalPager(modifier = modifier.fillMaxWidth(), state = pagerState) { + val song = queueSongs.getOrNull(it) ?: return@HorizontalPager + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + ArtworkAsyncImage( + modifier = Modifier + .size(64.dp) + .padding(4.dp), + artworkPath = song.artworkPath + ) + Column( + horizontalAlignment = Alignment.Start, modifier = Modifier + .padding(vertical = 8.dp, horizontal = 6.dp) + .weight(1f) + ) { + MarqueeText( + text = song.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + MarqueeText( + text = song.artist, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ), + fontSize = 12.sp + ) + } + + DynamicButton( + modifier = Modifier + .size(42.dp) + .background( + MaterialTheme.colorScheme.primary, + MaterialTheme.shapes.small + ) + .padding(4.dp), + icon = { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = stringResource( + id = R.string.play + ) + ) + }, icon2 = { + Icon( + imageVector = Icons.Rounded.Pause, + contentDescription = stringResource( + id = R.string.pause + ) + ) + }, isIcon1 = true + ) { + + } + } + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Preview +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun CollapsedContentPrev() { + MetadatorTheme { + MediaplayerCollapsedContent( + queueSongs = listOf( + Song( + id = 1, + title = "Bones", + artist = "Imagine Dragons", + album = "Mercury - Acts 1 & 2", + artworkPath = null, + duration = 100.0, + path = "path" + ) + ) + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerPage.kt b/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerPage.kt index 54af986..c07204e 100644 --- a/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerPage.kt +++ b/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerPage.kt @@ -1,23 +1,38 @@ package com.bobbyesp.metadator.presentation.pages.mediaplayer +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bobbyesp.metadator.presentation.components.cards.songs.HorizontalSongCard import com.bobbyesp.metadator.presentation.components.others.CollapsedPlayerHeight import com.bobbyesp.metadator.presentation.components.others.MediaplayerSheet import com.bobbyesp.metadator.presentation.components.others.PlayerAnimationSpec import com.bobbyesp.ui.components.bottomsheet.draggable.rememberDraggableBottomSheetState +import com.bobbyesp.ui.components.pulltorefresh.rememberPullState +import my.nanihadesuka.compose.LazyColumnScrollbar +import my.nanihadesuka.compose.ScrollbarSelectionActionable +@OptIn(ExperimentalFoundationApi::class) @Composable -fun MediaplayerPage() { +fun MediaplayerPage( + viewModel: MediaplayerViewModel +) { + val mediaStoreLazyColumnState = rememberLazyListState() + val pullState = rememberPullState() + + val songs = viewModel.songsFlow.collectAsStateWithLifecycle(initialValue = emptyList()).value + BoxWithConstraints( modifier = Modifier.fillMaxSize() ) { @@ -36,16 +51,36 @@ fun MediaplayerPage() { .fillMaxSize() .padding(it) ) { - val scope = rememberCoroutineScope() - - Button(onClick = { mediaPlayerSheetState.expandSoft() }) { - Text(text = "Expand soft") + LazyColumnScrollbar( + listState = mediaStoreLazyColumnState, + thumbColor = MaterialTheme.colorScheme.onSurfaceVariant, + thumbSelectedColor = MaterialTheme.colorScheme.primary, + selectionActionable = ScrollbarSelectionActionable.WhenVisible, + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + state = mediaStoreLazyColumnState, + ) { + items(count = songs.size, + key = { index -> songs[index].id }, + contentType = { index -> songs[index].id.toString() }) { index -> + val song = songs[index] + HorizontalSongCard(song = song, + modifier = Modifier.animateItemPlacement(), + onClick = { + viewModel.playSingleSong(song) + }) + } + } } } } MediaplayerSheet( - state = mediaPlayerSheetState + state = mediaPlayerSheetState, + viewModel = viewModel ) } } \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerViewModel.kt b/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerViewModel.kt new file mode 100644 index 0000000..a35a465 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/metadator/presentation/pages/mediaplayer/MediaplayerViewModel.kt @@ -0,0 +1,139 @@ +package com.bobbyesp.metadator.presentation.pages.mediaplayer + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import com.bobbyesp.mediaplayer.service.MediaServiceHandler +import com.bobbyesp.mediaplayer.service.MediaState +import com.bobbyesp.mediaplayer.service.PlayerEvent +import com.bobbyesp.model.Song +import com.bobbyesp.utilities.Time.formatDuration +import com.bobbyesp.utilities.mediastore.MediaStoreReceiver.Advanced.observeSongs +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MediaplayerViewModel @Inject constructor( + @ApplicationContext private val applicationContext: Context, + private val serviceHandler: MediaServiceHandler +) : ViewModel() { + private val mutableMediaplayerPageState = MutableStateFlow(MediaplayerPageState()) + val pageViewState = mutableMediaplayerPageState.asStateFlow() + + val songsFlow = applicationContext.contentResolver.observeSongs() + + data class MediaplayerPageState( + val uiState: PlayerState = PlayerState.Initial, + val playingSong: Song? = null + ) + + init { + viewModelScope.launch(Dispatchers.IO) { + serviceHandler.mediaState.collect { mediaState -> + when (mediaState) { + is MediaState.Buffering -> calculateProgressValues(mediaState.progress) + is MediaState.Playing -> mutableMediaplayerPageState.update { + (it.uiState as? PlayerState.Ready)?.let { readyState -> + it.copy( + uiState = readyState.copy(isPlaying = true) + ) + } ?: it + } + + is MediaState.Idle -> mutableMediaplayerPageState.update { it.copy(uiState = PlayerState.Initial) } + is MediaState.Progress -> calculateProgressValues(mediaState.progress) + is MediaState.Ready -> { + mutableMediaplayerPageState.update { + it.copy( + uiState = PlayerState.Ready(duration = mediaState.duration) + ) + } + } + } + } + } + } + + + fun playSingleSong(song: Song) { + loadSongInfo(song) + viewModelScope.launch { + serviceHandler.onPlayerEvent(PlayerEvent.PlayPause) + } + } + + private fun loadSongInfo(song: Song) { + mutableMediaplayerPageState.update { + it.copy(playingSong = song) + } + val mediaItem = MediaItem.Builder() + .setUri(song.path) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(song.title) + .setArtist(song.artist) + .setAlbumTitle(song.album) + .setArtworkUri(song.artworkPath) + .build() + ).build() + + viewModelScope.launch { + serviceHandler.setMediaItem(mediaItem) + } + } + + private fun calculateProgressValues(currentProgress: Long) { + if (currentProgress <= 0) { + mutableMediaplayerPageState.update { + (it.uiState as? PlayerState.Ready)?.let { readyState -> + it.copy( + uiState = readyState.copy( + progress = 0f, + progressString = "00:00", + isPlaying = false + ) + ) + } ?: it + } + return + } + + (pageViewState.value.uiState as? PlayerState.Ready)?.let { readyState -> + val progress = currentProgress.toFloat() / readyState.duration + val progressString = formatDuration(currentProgress) + mutableMediaplayerPageState.update { + it.copy( + uiState = readyState.copy( + progress = progress, + progressString = progressString + ) + ) + } + } ?: calculateProgressValues(0L) + } + + override fun onCleared() { + viewModelScope.launch { + serviceHandler.killPlayer() + } + super.onCleared() + } + + sealed interface PlayerState { + data object Initial : PlayerState + data class Ready( + val progress: Float = 0f, + val progressString: String = "00:00", + val duration: Long = 0L, + val isPlaying: Boolean = false + ) : PlayerState + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b481c7e..e512583 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -45,5 +45,7 @@ Unsaved changes There are some metadata fields that has been changed and not saved. Do you really want to discard the changes? Mediaplayer + Play + Pause \ No newline at end of file