diff --git a/media-data/api/current.api b/media-data/api/current.api index e918a538ed..94a808ef33 100644 --- a/media-data/api/current.api +++ b/media-data/api/current.api @@ -6,54 +6,3 @@ package com.google.android.horologist.media.data { } -package com.google.android.horologist.media.data.model { - - public final class TrackPosition { - ctor public TrackPosition(long current, long duration); - method public long component1(); - method public long component2(); - method public com.google.android.horologist.media.data.model.TrackPosition copy(long current, long duration); - method public long getCurrent(); - method public long getDuration(); - method public float getPercent(); - property public final long current; - property public final long duration; - property public final float percent; - field public static final com.google.android.horologist.media.data.model.TrackPosition.Companion Companion; - } - - public static final class TrackPosition.Companion { - method public com.google.android.horologist.media.data.model.TrackPosition getUnknown(); - property public final com.google.android.horologist.media.data.model.TrackPosition Unknown; - } - -} - -package com.google.android.horologist.media.data.repository { - - @com.google.android.horologist.media.data.ExperimentalHorologistMediaDataApi public interface PlayerRepository { - method public kotlinx.coroutines.flow.StateFlow getAvailableCommands(); - method public kotlinx.coroutines.flow.StateFlow getCurrentMediaItem(); - method public Long? getSeekBackIncrement(); - method public Long? getSeekForwardIncrement(); - method public kotlinx.coroutines.flow.StateFlow getShuffleModeEnabled(); - method public kotlinx.coroutines.flow.StateFlow getTrackPosition(); - method public kotlinx.coroutines.flow.StateFlow isPlaying(); - method public void pause(); - method public void prepareAndPlay(androidx.media3.common.MediaItem mediaItem, optional boolean play); - method public void prepareAndPlay(optional java.util.List? mediaItems, optional int startIndex, optional boolean play); - method public void seekBack(); - method public void seekForward(); - method public void seekToNextMediaItem(); - method public void seekToPreviousMediaItem(); - method public void toggleShuffle(); - method public void updatePosition(); - property public abstract kotlinx.coroutines.flow.StateFlow availableCommands; - property public abstract kotlinx.coroutines.flow.StateFlow currentMediaItem; - property public abstract kotlinx.coroutines.flow.StateFlow isPlaying; - property public abstract kotlinx.coroutines.flow.StateFlow shuffleModeEnabled; - property public abstract kotlinx.coroutines.flow.StateFlow trackPosition; - } - -} - diff --git a/media-data/src/main/java/com/google/android/horologist/media/data/model/TrackPosition.kt b/media-data/src/main/java/com/google/android/horologist/media/data/model/TrackPosition.kt deleted file mode 100644 index fe7d4e9f1b..0000000000 --- a/media-data/src/main/java/com/google/android/horologist/media/data/model/TrackPosition.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.horologist.media.data.model - -/** - * Data class for representing the current track position, track length and - * percent progress. Track position and duration are measure in milliseconds. - */ -public data class TrackPosition(val current: Long, val duration: Long) { - - val percent: Float - get() = current.toFloat() / duration.toFloat() - - public companion object { - public val Unknown: TrackPosition = TrackPosition(0, 0) - } -} diff --git a/media-data/src/main/java/com/google/android/horologist/media/data/repository/PlayerRepository.kt b/media-data/src/main/java/com/google/android/horologist/media/data/repository/PlayerRepository.kt deleted file mode 100644 index 1853ef59bc..0000000000 --- a/media-data/src/main/java/com/google/android/horologist/media/data/repository/PlayerRepository.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.horologist.media.data.repository - -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import com.google.android.horologist.media.data.ExperimentalHorologistMediaDataApi -import com.google.android.horologist.media.data.model.TrackPosition -import kotlinx.coroutines.flow.StateFlow - -/** - * Repository for the current [Player]. - */ -@ExperimentalHorologistMediaDataApi -public interface PlayerRepository { - - /** - * The list of available commands at this moment in time. All UI - * controls that affect the Player should be enabled/disabled based on - * the available commands. - */ - public val availableCommands: StateFlow - - /** - * Is the player already playing or set to start playing as soon - * as the media has buffered? - */ - public val isPlaying: StateFlow - - /** - * The current media item playing, or that would play when user hit play. - */ - public val currentMediaItem: StateFlow - - /** - * The current track position of the player. This is not updated automatically - * so [updatePosition] should be called within a ViewModel coroutineScope regularly - * to update the UI while the activity is in the foreground. - */ - public val trackPosition: StateFlow - - /** - * The current value for shuffling of media items mode. - */ - public val shuffleModeEnabled: StateFlow - - /** - * Plays the mediaItem after preparing (buffering). - */ - public fun prepareAndPlay( - mediaItem: MediaItem, - play: Boolean = true, - ) - - /** - * Plays the mediaItems after preparing (buffering). - * If mediaItems is null, the existing mediaItems and position - * will be kept. - */ - public fun prepareAndPlay( - mediaItems: List? = null, - startIndex: Int = 0, - play: Boolean = true, - ) - - /** - * Go to the previous track, see [Player.seekToPreviousMediaItem]. - */ - public fun seekToPreviousMediaItem() - - /** - * Next track, see [Player.seekToNextMediaItem] - */ - public fun seekToNextMediaItem() - - /** - * Returns the [seekBack] increment. - * - * @return The seek back increment, in milliseconds, or null if this info is not available. - */ - public fun getSeekBackIncrement(): Long? - - /** - * Seek back by the default amount, see [Player.seekForward] - */ - public fun seekBack() - - /** - * Returns the [seekForward] increment. - * - * @return The seek forward increment, in milliseconds, or null if this info is not available. - */ - public fun getSeekForwardIncrement(): Long? - - /** - * Seek forward by the default amount, see [Player.seekForward] - */ - public fun seekForward() - - /** - * Pause the player, see [Player.pause] - */ - public fun pause() - - /** - * Sets whether shuffling of media items is enabled, see [Player.setShuffleModeEnabled] - */ - public fun toggleShuffle() - - /** - * Update the position to show track progress correctly on screen. - * Updating roughly once a second while activity is foregrounded is appropriate. - */ - public fun updatePosition() -} diff --git a/media-ui/api/current.api b/media-ui/api/current.api index c71832f7b2..193210da0e 100644 --- a/media-ui/api/current.api +++ b/media-ui/api/current.api @@ -175,15 +175,14 @@ package com.google.android.horologist.media.ui.state { } @com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi public class PlayerViewModel extends androidx.lifecycle.ViewModel { - ctor public PlayerViewModel(com.google.android.horologist.media.data.repository.PlayerRepository playerRepository); + ctor public PlayerViewModel(com.google.android.horologist.media.repository.PlayerRepository playerRepository); method public final kotlinx.coroutines.flow.StateFlow getPlayerUiState(); method public final void pause(); - method public final void prepareAndPlay(); + method public final void play(); method public final void seekBack(); method public final void seekForward(); - method public final void seekToNextMediaItem(); - method public final void seekToPreviousMediaItem(); - method public final void toggleShuffle(); + method public final void skipToNextMediaItem(); + method public final void skipToPreviousMediaItem(); property public final kotlinx.coroutines.flow.StateFlow playerUiState; field public static final com.google.android.horologist.media.ui.state.PlayerViewModel.Companion Companion; } @@ -196,17 +195,17 @@ package com.google.android.horologist.media.ui.state { package com.google.android.horologist.media.ui.state.mapper { @com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi public final class MediaItemUiModelMapper { - method public com.google.android.horologist.media.ui.state.model.MediaItemUiModel map(androidx.media3.common.MediaItem mediaItem); + method public com.google.android.horologist.media.ui.state.model.MediaItemUiModel map(com.google.android.horologist.media.model.MediaItem mediaItem); field public static final com.google.android.horologist.media.ui.state.mapper.MediaItemUiModelMapper INSTANCE; } @com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi public final class PlayerUiStateMapper { - method public com.google.android.horologist.media.ui.state.PlayerUiState map(androidx.media3.common.Player.Commands playerCommands, boolean shuffleModeEnabled, boolean isPlaying, androidx.media3.common.MediaItem? mediaItem, com.google.android.horologist.media.data.model.TrackPosition? trackPosition); + method public com.google.android.horologist.media.ui.state.PlayerUiState map(com.google.android.horologist.media.model.PlayerState currentState, java.util.Set availableCommands, com.google.android.horologist.media.model.MediaItem? mediaItem, com.google.android.horologist.media.model.MediaItemPosition? mediaItemPosition, boolean shuffleModeEnabled); field public static final com.google.android.horologist.media.ui.state.mapper.PlayerUiStateMapper INSTANCE; } @com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi public final class TrackPositionUiModelMapper { - method public com.google.android.horologist.media.ui.state.model.TrackPositionUiModel map(com.google.android.horologist.media.data.model.TrackPosition trackPosition); + method public com.google.android.horologist.media.ui.state.model.TrackPositionUiModel map(com.google.android.horologist.media.model.MediaItemPosition mediaItemPosition); field public static final com.google.android.horologist.media.ui.state.mapper.TrackPositionUiModelMapper INSTANCE; } diff --git a/media-ui/build.gradle b/media-ui/build.gradle index c8db5a1819..68735eaeb9 100644 --- a/media-ui/build.gradle +++ b/media-ui/build.gradle @@ -79,7 +79,7 @@ project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile.class).co } dependencies { - implementation projects.mediaData + implementation projects.media implementation libs.kotlin.stdlib implementation libs.androidx.wear implementation libs.androidx.lifecycle.runtime @@ -89,7 +89,6 @@ dependencies { implementation libs.compose.ui.tooling implementation libs.compose.material.iconscore implementation libs.compose.material.iconsext - implementation libs.androidx.media3.common debugImplementation libs.compose.ui.test.manifest debugImplementation libs.compose.ui.toolingpreview diff --git a/media-ui/src/androidTest/java/com/google/android/horologist/media/ui/screens/PlayerScreenTest.kt b/media-ui/src/androidTest/java/com/google/android/horologist/media/ui/screens/PlayerScreenTest.kt index 06dc6fba6c..0f5486de87 100644 --- a/media-ui/src/androidTest/java/com/google/android/horologist/media/ui/screens/PlayerScreenTest.kt +++ b/media-ui/src/androidTest/java/com/google/android/horologist/media/ui/screens/PlayerScreenTest.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.performClick -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.Player import androidx.wear.compose.material.Text +import com.google.android.horologist.media.model.Command +import com.google.android.horologist.media.model.MediaItem +import com.google.android.horologist.media.model.PlayerState import com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi import com.google.android.horologist.media.ui.state.PlayerViewModel import com.google.android.horologist.test.toolbox.matchers.hasProgressBar @@ -84,11 +84,11 @@ class PlayerScreenTest { fun givenPlayerRepoIsNOTPlaying_whenPlayIsClicked_thenPlayerRepoIsPlaying() { // given val playerRepository = FakePlayerRepository() - playerRepository.addCommand(Player.COMMAND_PLAY_PAUSE) + playerRepository.addCommand(Command.PlayPause) val playerViewModel = PlayerViewModel(playerRepository) - assertThat(playerRepository.isPlaying.value).isFalse() + assertThat(playerRepository.currentState.value).isNotEqualTo(PlayerState.Playing) composeTestRule.setContent { PlayerScreen(playerViewModel = playerViewModel) } @@ -97,19 +97,21 @@ class PlayerScreenTest { .performClick() // then - composeTestRule.waitUntil(timeoutMillis = 1_000) { playerRepository.isPlaying.value } + composeTestRule.waitUntil(timeoutMillis = 1_000) { + playerRepository.currentState.value == PlayerState.Playing + } } @Test fun givenPlayerRepoIsPlaying_whenPauseIsClicked_thenPlayerRepoIsNOTPlaying() { // given val playerRepository = FakePlayerRepository() - playerRepository.addCommand(Player.COMMAND_PLAY_PAUSE) - playerRepository.prepareAndPlay() + playerRepository.addCommand(Command.PlayPause) + playerRepository.play() val playerViewModel = PlayerViewModel(playerRepository) - assertThat(playerRepository.isPlaying.value).isTrue() + assertThat(playerRepository.currentState.value).isEqualTo(PlayerState.Playing) composeTestRule.setContent { PlayerScreen(playerViewModel = playerViewModel) } @@ -118,7 +120,9 @@ class PlayerScreenTest { .performClick() // then - composeTestRule.waitUntil(timeoutMillis = 1_000) { !playerRepository.isPlaying.value } + composeTestRule.waitUntil(timeoutMillis = 1_000) { + playerRepository.currentState.value != PlayerState.Playing + } } @Test @@ -126,12 +130,15 @@ class PlayerScreenTest { // given val playerRepository = FakePlayerRepository() - val mediaItem1 = MediaItem.Builder().build() - val mediaItem2 = MediaItem.Builder().build() - playerRepository.prepareAndPlay(listOf(mediaItem1, mediaItem2), 1) + val mediaItem1 = MediaItem("", "") + val mediaItem2 = MediaItem("", "") + playerRepository.setMediaItems(listOf(mediaItem1, mediaItem2)) + playerRepository.play(1) val playerViewModel = PlayerViewModel(playerRepository) + assertThat(playerRepository.currentMediaItem.value).isEqualTo(mediaItem2) + composeTestRule.setContent { PlayerScreen(playerViewModel = playerViewModel) } // when @@ -139,7 +146,9 @@ class PlayerScreenTest { .performClick() // then - composeTestRule.waitUntil(timeoutMillis = 1_000) { playerRepository.currentMediaItem.value == mediaItem1 } + composeTestRule.waitUntil(timeoutMillis = 1_000) { + playerRepository.currentMediaItem.value == mediaItem1 + } } @Test @@ -147,12 +156,15 @@ class PlayerScreenTest { // given val playerRepository = FakePlayerRepository() - val mediaItem1 = MediaItem.Builder().build() - val mediaItem2 = MediaItem.Builder().build() - playerRepository.prepareAndPlay(listOf(mediaItem1, mediaItem2), 0) + val mediaItem1 = MediaItem("", "") + val mediaItem2 = MediaItem("", "") + playerRepository.setMediaItems(listOf(mediaItem1, mediaItem2)) + playerRepository.play(0) val playerViewModel = PlayerViewModel(playerRepository) + assertThat(playerRepository.currentMediaItem.value).isEqualTo(mediaItem1) + composeTestRule.setContent { PlayerScreen(playerViewModel = playerViewModel) } // when @@ -160,7 +172,9 @@ class PlayerScreenTest { .performClick() // then - composeTestRule.waitUntil(timeoutMillis = 1_000) { playerRepository.currentMediaItem.value == mediaItem2 } + composeTestRule.waitUntil(timeoutMillis = 1_000) { + playerRepository.currentMediaItem.value == mediaItem2 + } } @Test @@ -190,7 +204,7 @@ class PlayerScreenTest { fun whenPlayPauseCommandBecomesAvailable_thenPlayPauseButtonGetsEnabled() { // given val playerRepository = FakePlayerRepository() - playerRepository.prepareAndPlay() + playerRepository.play() val playerViewModel = PlayerViewModel(playerRepository) @@ -202,7 +216,7 @@ class PlayerScreenTest { button.assertIsNotEnabled() // when - playerRepository.addCommand(Player.COMMAND_PLAY_PAUSE) + playerRepository.addCommand(Command.PlayPause) // then button.assertIsEnabled() @@ -222,7 +236,7 @@ class PlayerScreenTest { button.assertIsNotEnabled() // when - playerRepository.addCommand(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + playerRepository.addCommand(Command.SkipToPreviousMediaItem) // then button.assertIsEnabled() @@ -242,7 +256,7 @@ class PlayerScreenTest { button.assertIsNotEnabled() // when - playerRepository.addCommand(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + playerRepository.addCommand(Command.SkipToNextMediaItem) // then button.assertIsEnabled() @@ -254,15 +268,9 @@ class PlayerScreenTest { val playerRepository = FakePlayerRepository() val artist = "artist" val title = "title" - val mediaItem = MediaItem.Builder() - .setMediaMetadata( - MediaMetadata.Builder() - .setArtist(artist) - .setDisplayTitle(title) - .build() - ) - .build() - playerRepository.prepareAndPlay(mediaItem) + val mediaItem = MediaItem(title = title, artist = artist) + playerRepository.setMediaItem(mediaItem) + playerRepository.play() val playerViewModel = PlayerViewModel(playerRepository) @@ -279,15 +287,9 @@ class PlayerScreenTest { val playerRepository = FakePlayerRepository() val artist = "artist" val title = "title" - val mediaItem = MediaItem.Builder() - .setMediaMetadata( - MediaMetadata.Builder() - .setArtist(artist) - .setDisplayTitle(title) - .build() - ) - .build() - playerRepository.prepareAndPlay(mediaItem) + val mediaItem = MediaItem(title = title, artist = artist) + playerRepository.setMediaItem(mediaItem) + playerRepository.play() val playerViewModel = PlayerViewModel(playerRepository) diff --git a/media-ui/src/androidTest/java/com/google/android/horologist/test/toolbox/testdoubles/FakePlayerRepository.kt b/media-ui/src/androidTest/java/com/google/android/horologist/test/toolbox/testdoubles/FakePlayerRepository.kt index 7fe1ed24af..4ad0cf8d5f 100644 --- a/media-ui/src/androidTest/java/com/google/android/horologist/test/toolbox/testdoubles/FakePlayerRepository.kt +++ b/media-ui/src/androidTest/java/com/google/android/horologist/test/toolbox/testdoubles/FakePlayerRepository.kt @@ -14,102 +14,140 @@ * limitations under the License. */ -@file:OptIn(ExperimentalHorologistMediaDataApi::class) +@file:OptIn(ExperimentalHorologistMediaApi::class) package com.google.android.horologist.test.toolbox.testdoubles -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.Player.Command -import com.google.android.horologist.media.data.ExperimentalHorologistMediaDataApi -import com.google.android.horologist.media.data.model.TrackPosition -import com.google.android.horologist.media.data.repository.PlayerRepository +import com.google.android.horologist.media.ExperimentalHorologistMediaApi +import com.google.android.horologist.media.model.Command +import com.google.android.horologist.media.model.MediaItem +import com.google.android.horologist.media.model.MediaItemPosition +import com.google.android.horologist.media.model.PlayerState +import com.google.android.horologist.media.repository.PlayerRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds class FakePlayerRepository : PlayerRepository { - private var _availableCommandsList = MutableStateFlow(Player.Commands.EMPTY) - override val availableCommands: StateFlow = _availableCommandsList + private var _availableCommandsList = MutableStateFlow(emptySet()) + override val availableCommands: StateFlow> = _availableCommandsList - private var _playing = MutableStateFlow(false) - override val isPlaying: StateFlow = _playing + private var _currentState = MutableStateFlow(PlayerState.Idle) + override val currentState: StateFlow = _currentState private var _currentMediaItem: MutableStateFlow = MutableStateFlow(null) override val currentMediaItem: StateFlow = _currentMediaItem - private var _trackPosition: MutableStateFlow = MutableStateFlow(null) - override val trackPosition: StateFlow = _trackPosition + private var _mediaItemPosition: MutableStateFlow = MutableStateFlow(null) + override val mediaItemPosition: StateFlow = _mediaItemPosition private var _shuffleModeEnabled = MutableStateFlow(false) override val shuffleModeEnabled: StateFlow = _shuffleModeEnabled - private var mediaItems: List? = null + private var _mediaItems: List? = null private var currentItemIndex = -1 - override fun prepareAndPlay(mediaItem: MediaItem, play: Boolean) { - _currentMediaItem.value = mediaItem - if (play) { - _playing.value = true - } + override fun prepare() { + // do nothing } - override fun prepareAndPlay(mediaItems: List?, startIndex: Int, play: Boolean) { - mediaItems?.let { - this.mediaItems = it - currentItemIndex = startIndex - _currentMediaItem.value = it[startIndex] - } + override fun play() { + _currentState.value = PlayerState.Playing + } - if (play) { - _playing.value = true - } + override fun play(mediaItemIndex: Int) { + currentItemIndex = mediaItemIndex + _currentState.value = PlayerState.Playing } - override fun seekToPreviousMediaItem() { + override fun pause() { + _currentState.value = PlayerState.Ready + } + + override fun hasPreviousMediaItem(): Boolean = currentItemIndex > 0 + + override fun skipToPreviousMediaItem() { currentItemIndex-- - _currentMediaItem.value = mediaItems!![currentItemIndex] + _currentMediaItem.value = _mediaItems!![currentItemIndex] } - override fun seekToNextMediaItem() { + override fun hasNextMediaItem(): Boolean = + _mediaItems?.let { + currentItemIndex < it.size - 2 + } ?: false + + override fun skipToNextMediaItem() { currentItemIndex++ - _currentMediaItem.value = mediaItems!![currentItemIndex] + _currentMediaItem.value = _mediaItems!![currentItemIndex] } - override fun getSeekBackIncrement(): Long? { - TODO("Not yet implemented") - } + override fun getSeekBackIncrement(): Duration = 0.seconds // not implemented override fun seekBack() { - TODO("Not yet implemented") + // do nothing } - override fun getSeekForwardIncrement(): Long? { - TODO("Not yet implemented") - } + override fun getSeekForwardIncrement(): Duration = 0.seconds // not implemented override fun seekForward() { - TODO("Not yet implemented") + // do nothing } - override fun pause() { - _playing.value = false + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { + // do nothing + } + + override fun setMediaItem(mediaItem: MediaItem) { + _currentMediaItem.value = mediaItem + currentItemIndex = 0 } - override fun toggleShuffle() { - TODO("Not yet implemented") + override fun setMediaItems(mediaItems: List) { + _mediaItems = mediaItems + currentItemIndex = 0 + _currentMediaItem.value = mediaItems[currentItemIndex] + } + + override fun addMediaItem(mediaItem: MediaItem) { + // do nothing + } + + override fun addMediaItem(index: Int, mediaItem: MediaItem) { + // do nothing + } + + override fun removeMediaItem(index: Int) { + // do nothing + } + + override fun clearMediaItems() { + // do nothing + } + + override fun getMediaItemCount(): Int = _mediaItems?.size ?: 0 + + override fun getMediaItemAt(index: Int): MediaItem? = null // not implemented + + override fun getCurrentMediaItemIndex(): Int = 0 // not implemented + + override fun release() { + // do nothing } - override fun updatePosition() { - _trackPosition.value = _trackPosition.value?.let { - it.copy(current = it.current + 1) - } ?: TrackPosition(1, 10) + fun updatePosition() { + _mediaItemPosition.value = _mediaItemPosition.value?.let { + val newCurrent = it.current + 1.seconds + if (it is MediaItemPosition.KnownDuration) { + MediaItemPosition.create(newCurrent, it.duration) + } else { + MediaItemPosition.UnknownDuration(newCurrent) + } + } ?: MediaItemPosition.create(1.seconds, 10.seconds) } - fun addCommand(@Command command: Int) { - _availableCommandsList.value = Player.Commands.Builder() - .addAll(_availableCommandsList.value) - .add(command) - .build() + fun addCommand(command: Command) { + _availableCommandsList.value += command } } diff --git a/media-ui/src/main/java/com/google/android/horologist/media/ui/screens/PlayerScreen.kt b/media-ui/src/main/java/com/google/android/horologist/media/ui/screens/PlayerScreen.kt index a1c542613b..240485f6e3 100644 --- a/media-ui/src/main/java/com/google/android/horologist/media/ui/screens/PlayerScreen.kt +++ b/media-ui/src/main/java/com/google/android/horologist/media/ui/screens/PlayerScreen.kt @@ -108,10 +108,10 @@ public object PlayerScreenDefaults { playerViewModel: PlayerViewModel, showProgress: Boolean = true ): PlayerScreenControlButtons = DefaultPlayerScreenControlButtons( - onPlayClick = { playerViewModel.prepareAndPlay() }, + onPlayClick = { playerViewModel.play() }, onPauseClick = { playerViewModel.pause() }, - onSeekToPreviousButtonClick = { playerViewModel.seekToPreviousMediaItem() }, - onSeekToNextButtonClick = { playerViewModel.seekToNextMediaItem() }, + onSeekToPreviousButtonClick = { playerViewModel.skipToPreviousMediaItem() }, + onSeekToNextButtonClick = { playerViewModel.skipToNextMediaItem() }, showProgress = showProgress ) diff --git a/media-ui/src/main/java/com/google/android/horologist/media/ui/state/PlayerViewModel.kt b/media-ui/src/main/java/com/google/android/horologist/media/ui/state/PlayerViewModel.kt index a7eb38fa0b..92034dd354 100644 --- a/media-ui/src/main/java/com/google/android/horologist/media/ui/state/PlayerViewModel.kt +++ b/media-ui/src/main/java/com/google/android/horologist/media/ui/state/PlayerViewModel.kt @@ -18,8 +18,8 @@ package com.google.android.horologist.media.ui.state import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.android.horologist.media.data.ExperimentalHorologistMediaDataApi -import com.google.android.horologist.media.data.repository.PlayerRepository +import com.google.android.horologist.media.ExperimentalHorologistMediaApi +import com.google.android.horologist.media.repository.PlayerRepository import com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi import com.google.android.horologist.media.ui.state.mapper.PlayerUiStateMapper import com.google.android.horologist.media.ui.state.model.MediaItemUiModel @@ -29,18 +29,18 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn -@OptIn(ExperimentalHorologistMediaDataApi::class) +@OptIn(ExperimentalHorologistMediaApi::class) @ExperimentalHorologistMediaUiApi public open class PlayerViewModel( private val playerRepository: PlayerRepository ) : ViewModel() { public val playerUiState: StateFlow = combine( + playerRepository.currentState, playerRepository.availableCommands, - playerRepository.shuffleModeEnabled, - playerRepository.isPlaying, playerRepository.currentMediaItem, - playerRepository.trackPosition, + playerRepository.mediaItemPosition, + playerRepository.shuffleModeEnabled, PlayerUiStateMapper::map ).stateIn( scope = viewModelScope, @@ -48,20 +48,20 @@ public open class PlayerViewModel( initialValue = INITIAL_PLAYER_UI_STATE ) - public fun prepareAndPlay() { - playerRepository.prepareAndPlay() + public fun play() { + playerRepository.play() } public fun pause() { playerRepository.pause() } - public fun seekToPreviousMediaItem() { - playerRepository.seekToPreviousMediaItem() + public fun skipToPreviousMediaItem() { + playerRepository.skipToPreviousMediaItem() } - public fun seekToNextMediaItem() { - playerRepository.seekToNextMediaItem() + public fun skipToNextMediaItem() { + playerRepository.skipToNextMediaItem() } public fun seekBack() { @@ -72,10 +72,6 @@ public open class PlayerViewModel( playerRepository.seekForward() } - public fun toggleShuffle() { - playerRepository.toggleShuffle() - } - public companion object { private val INITIAL_MEDIA_ITEM = MediaItemUiModel(null, null) diff --git a/media-ui/src/main/java/com/google/android/horologist/media/ui/state/mapper/MediaItemUiModelMapper.kt b/media-ui/src/main/java/com/google/android/horologist/media/ui/state/mapper/MediaItemUiModelMapper.kt index b4e66a4420..430601452f 100644 --- a/media-ui/src/main/java/com/google/android/horologist/media/ui/state/mapper/MediaItemUiModelMapper.kt +++ b/media-ui/src/main/java/com/google/android/horologist/media/ui/state/mapper/MediaItemUiModelMapper.kt @@ -16,7 +16,7 @@ package com.google.android.horologist.media.ui.state.mapper -import androidx.media3.common.MediaItem +import com.google.android.horologist.media.model.MediaItem import com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi import com.google.android.horologist.media.ui.state.model.MediaItemUiModel @@ -27,7 +27,7 @@ import com.google.android.horologist.media.ui.state.model.MediaItemUiModel public object MediaItemUiModelMapper { public fun map(mediaItem: MediaItem): MediaItemUiModel = MediaItemUiModel( - title = mediaItem.mediaMetadata.displayTitle?.toString(), - artist = mediaItem.mediaMetadata.artist?.toString() + title = mediaItem.title, + artist = mediaItem.artist ) } diff --git a/media-ui/src/main/java/com/google/android/horologist/media/ui/state/mapper/PlayerUiStateMapper.kt b/media-ui/src/main/java/com/google/android/horologist/media/ui/state/mapper/PlayerUiStateMapper.kt index a6e8302605..216e7f4334 100644 --- a/media-ui/src/main/java/com/google/android/horologist/media/ui/state/mapper/PlayerUiStateMapper.kt +++ b/media-ui/src/main/java/com/google/android/horologist/media/ui/state/mapper/PlayerUiStateMapper.kt @@ -16,40 +16,41 @@ package com.google.android.horologist.media.ui.state.mapper -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import com.google.android.horologist.media.data.model.TrackPosition +import com.google.android.horologist.media.model.Command +import com.google.android.horologist.media.model.MediaItem +import com.google.android.horologist.media.model.MediaItemPosition +import com.google.android.horologist.media.model.PlayerState import com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi import com.google.android.horologist.media.ui.state.PlayerUiState /** - * Map [Player.Commands] plus other set of properties into a [PlayerUiState] + * Map [PlayerState], [Command] plus other set of properties into a [PlayerUiState]. */ @ExperimentalHorologistMediaUiApi public object PlayerUiStateMapper { public fun map( - playerCommands: Player.Commands, - shuffleModeEnabled: Boolean, - isPlaying: Boolean, + currentState: PlayerState, + availableCommands: Set, mediaItem: MediaItem?, - trackPosition: TrackPosition?, + mediaItemPosition: MediaItemPosition?, + shuffleModeEnabled: Boolean, ): PlayerUiState { - val playPauseCommandAvailable = playerCommands.contains(Player.COMMAND_PLAY_PAUSE) + val playPauseCommandAvailable = availableCommands.contains(Command.PlayPause) return PlayerUiState( playEnabled = playPauseCommandAvailable, pauseEnabled = playPauseCommandAvailable, - seekBackEnabled = playerCommands.contains(Player.COMMAND_SEEK_BACK), - seekForwardEnabled = playerCommands.contains(Player.COMMAND_SEEK_FORWARD), - seekToPreviousEnabled = playerCommands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM), - seekToNextEnabled = playerCommands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM), - shuffleEnabled = playerCommands.contains(Player.COMMAND_SET_SHUFFLE_MODE), + seekBackEnabled = availableCommands.contains(Command.SeekBack), + seekForwardEnabled = availableCommands.contains(Command.SeekForward), + seekToPreviousEnabled = availableCommands.contains(Command.SkipToPreviousMediaItem), + seekToNextEnabled = availableCommands.contains(Command.SkipToNextMediaItem), + shuffleEnabled = availableCommands.contains(Command.SetShuffle), shuffleOn = shuffleModeEnabled, playPauseEnabled = playPauseCommandAvailable, - playing = isPlaying, + playing = currentState == PlayerState.Playing, mediaItem = mediaItem?.let(MediaItemUiModelMapper::map), - trackPosition = trackPosition?.let(TrackPositionUiModelMapper::map) + trackPosition = mediaItemPosition?.let(TrackPositionUiModelMapper::map) ) } } diff --git a/media-ui/src/main/java/com/google/android/horologist/media/ui/state/mapper/TrackPositionUiModelMapper.kt b/media-ui/src/main/java/com/google/android/horologist/media/ui/state/mapper/TrackPositionUiModelMapper.kt index 032f6b71a8..5483660f01 100644 --- a/media-ui/src/main/java/com/google/android/horologist/media/ui/state/mapper/TrackPositionUiModelMapper.kt +++ b/media-ui/src/main/java/com/google/android/horologist/media/ui/state/mapper/TrackPositionUiModelMapper.kt @@ -16,19 +16,27 @@ package com.google.android.horologist.media.ui.state.mapper -import com.google.android.horologist.media.data.model.TrackPosition +import com.google.android.horologist.media.model.MediaItemPosition import com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi import com.google.android.horologist.media.ui.state.model.TrackPositionUiModel /** - * Map a [TrackPosition] into a [TrackPositionUiModel] + * Map a [MediaItemPosition] into a [TrackPositionUiModel] */ @ExperimentalHorologistMediaUiApi public object TrackPositionUiModelMapper { - public fun map(trackPosition: TrackPosition): TrackPositionUiModel = TrackPositionUiModel( - current = trackPosition.current, - duration = trackPosition.duration, - percent = trackPosition.percent - ) + public fun map(mediaItemPosition: MediaItemPosition): TrackPositionUiModel { + val (duration, percent) = if (mediaItemPosition is MediaItemPosition.KnownDuration) { + mediaItemPosition.duration.inWholeMilliseconds to mediaItemPosition.percent + } else { + 0L to 0F + } + + return TrackPositionUiModel( + current = mediaItemPosition.current.inWholeMilliseconds, + duration = duration, + percent = percent + ) + } } diff --git a/media-ui/src/test/java/com/google/android/horologist/media/ui/state/mapper/MediaItemUiModelMapperTest.kt b/media-ui/src/test/java/com/google/android/horologist/media/ui/state/mapper/MediaItemUiModelMapperTest.kt index 7ef7d7b262..12e05f245c 100644 --- a/media-ui/src/test/java/com/google/android/horologist/media/ui/state/mapper/MediaItemUiModelMapperTest.kt +++ b/media-ui/src/test/java/com/google/android/horologist/media/ui/state/mapper/MediaItemUiModelMapperTest.kt @@ -18,8 +18,7 @@ package com.google.android.horologist.media.ui.state.mapper -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata +import com.google.android.horologist.media.model.MediaItem import com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -31,13 +30,7 @@ class MediaItemUiModelMapperTest { // given val title = "title" val artist = "artist" - val metadataBuilder = MediaMetadata.Builder() - .setDisplayTitle(title) - .setArtist(artist) - - val mediaItem = MediaItem.Builder() - .setMediaMetadata(metadataBuilder.build()) - .build() + val mediaItem = MediaItem(title = title, artist = artist) // when val result = MediaItemUiModelMapper.map(mediaItem) diff --git a/media-ui/src/test/java/com/google/android/horologist/media/ui/state/mapper/PlayerUiStateMapperTest.kt b/media-ui/src/test/java/com/google/android/horologist/media/ui/state/mapper/PlayerUiStateMapperTest.kt index e8fc6e7f4f..4db8d6033e 100644 --- a/media-ui/src/test/java/com/google/android/horologist/media/ui/state/mapper/PlayerUiStateMapperTest.kt +++ b/media-ui/src/test/java/com/google/android/horologist/media/ui/state/mapper/PlayerUiStateMapperTest.kt @@ -18,18 +18,19 @@ package com.google.android.horologist.media.ui.state.mapper -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.Player import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.android.horologist.media.data.model.TrackPosition +import com.google.android.horologist.media.model.Command +import com.google.android.horologist.media.model.MediaItem +import com.google.android.horologist.media.model.MediaItemPosition +import com.google.android.horologist.media.model.PlayerState import com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi import com.google.android.horologist.media.ui.state.PlayerUiState import com.google.common.truth.Truth.assertThat -import org.junit.Assert +import org.junit.Assert.assertNotNull import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.seconds @RunWith(AndroidJUnit4::class) @Config(manifest = Config.NONE) @@ -38,15 +39,15 @@ class PlayerUiStateMapperTest { @Test fun givenNoCommandsAreAvailable_thenAllIsDisabled() { // given - val commands = Player.Commands.EMPTY + val commands = setOf() // when val result = PlayerUiStateMapper.map( - commands, - shuffleModeEnabled = false, - isPlaying = false, + currentState = PlayerState.Ready, + availableCommands = commands, mediaItem = null, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = false, ) // then @@ -71,15 +72,15 @@ class PlayerUiStateMapperTest { @Test fun givenPlayPauseCommandIsAvailable_thenPlayIsEnabled() { // given - val commands = Player.Commands.Builder().add(Player.COMMAND_PLAY_PAUSE).build() + val commands = setOf(Command.PlayPause) // when val result = PlayerUiStateMapper.map( - commands, - shuffleModeEnabled = false, - isPlaying = false, + currentState = PlayerState.Ready, + availableCommands = commands, mediaItem = null, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = false, ) // then @@ -89,15 +90,15 @@ class PlayerUiStateMapperTest { @Test fun givenPlayPauseCommandIsAvailable_thenPauseIsEnabled() { // given - val commands = Player.Commands.Builder().add(Player.COMMAND_PLAY_PAUSE).build() + val commands = setOf(Command.PlayPause) // when val result = PlayerUiStateMapper.map( - commands, - shuffleModeEnabled = false, - isPlaying = false, + currentState = PlayerState.Ready, + availableCommands = commands, mediaItem = null, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = false, ) // then @@ -107,15 +108,15 @@ class PlayerUiStateMapperTest { @Test fun givenSeekBackCommandIsAvailable_thenSeekBackIsEnabled() { // given - val commands = Player.Commands.Builder().add(Player.COMMAND_SEEK_BACK).build() + val commands = setOf(Command.SeekBack) // when val result = PlayerUiStateMapper.map( - commands, - shuffleModeEnabled = false, - isPlaying = false, + currentState = PlayerState.Ready, + availableCommands = commands, mediaItem = null, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = false, ) // then @@ -125,15 +126,15 @@ class PlayerUiStateMapperTest { @Test fun givenSeekForwardCommandIsAvailable_thenSeekForwardIsEnabled() { // given - val commands = Player.Commands.Builder().add(Player.COMMAND_SEEK_FORWARD).build() + val commands = setOf(Command.SeekForward) // when val result = PlayerUiStateMapper.map( - commands, - shuffleModeEnabled = false, - isPlaying = false, + currentState = PlayerState.Ready, + availableCommands = commands, mediaItem = null, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = false, ) // then @@ -141,18 +142,17 @@ class PlayerUiStateMapperTest { } @Test - fun givenSeekToPreviousMediaItemCommandIsAvailable_thenSeekToPreviousIsEnabled() { + fun givenSkipToPreviousMediaItemCommandIsAvailable_thenSeekToPreviousIsEnabled() { // given - val commands = - Player.Commands.Builder().add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM).build() + val commands = setOf(Command.SkipToPreviousMediaItem) // when val result = PlayerUiStateMapper.map( - commands, - shuffleModeEnabled = false, - isPlaying = false, + currentState = PlayerState.Ready, + availableCommands = commands, mediaItem = null, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = false, ) // then @@ -160,18 +160,17 @@ class PlayerUiStateMapperTest { } @Test - fun givenSeekToNextMediaItemCommandIsAvailable_thenSeekToNextIsEnabled() { + fun givenSkipToNextMediaItemCommandIsAvailable_thenSeekToNextIsEnabled() { // given - val commands = - Player.Commands.Builder().add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM).build() + val commands = setOf(Command.SkipToNextMediaItem) // when val result = PlayerUiStateMapper.map( - commands, - shuffleModeEnabled = false, - isPlaying = false, + currentState = PlayerState.Ready, + availableCommands = commands, mediaItem = null, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = false, ) // then @@ -181,16 +180,15 @@ class PlayerUiStateMapperTest { @Test fun givenSetShuffleModeCommandIsAvailable_thenShuffleIsEnabled() { // given - val commands = - Player.Commands.Builder().add(Player.COMMAND_SET_SHUFFLE_MODE).build() + val commands = setOf(Command.SetShuffle) // when val result = PlayerUiStateMapper.map( - commands, - shuffleModeEnabled = false, - isPlaying = false, + currentState = PlayerState.Ready, + availableCommands = commands, mediaItem = null, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = false, ) // then @@ -204,11 +202,11 @@ class PlayerUiStateMapperTest { // when val result = PlayerUiStateMapper.map( - Player.Commands.EMPTY, - shuffleModeEnabled = shuffleEnabled, - isPlaying = false, + currentState = PlayerState.Ready, + availableCommands = emptySet(), mediaItem = null, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = shuffleEnabled, ) // then @@ -222,11 +220,11 @@ class PlayerUiStateMapperTest { // when val result = PlayerUiStateMapper.map( - Player.Commands.EMPTY, - shuffleModeEnabled = shuffleEnabled, - isPlaying = false, + currentState = PlayerState.Ready, + availableCommands = emptySet(), mediaItem = null, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = shuffleEnabled, ) // then @@ -236,15 +234,15 @@ class PlayerUiStateMapperTest { @Test fun givenPlayPauseCommandIsAvailable_thenPlayPauseIsEnabled() { // given - val commands = Player.Commands.Builder().add(Player.COMMAND_PLAY_PAUSE).build() + val commands = setOf(Command.PlayPause) // when val result = PlayerUiStateMapper.map( - commands, - shuffleModeEnabled = false, - isPlaying = false, + currentState = PlayerState.Ready, + availableCommands = commands, mediaItem = null, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = false, ) // then @@ -254,37 +252,37 @@ class PlayerUiStateMapperTest { @Test fun givenIsNOTPlaying_thenPlayingIsFalse() { // given - val isPlaying = false + val state = PlayerState.Ready // when val result = PlayerUiStateMapper.map( - Player.Commands.EMPTY, - shuffleModeEnabled = false, - isPlaying = isPlaying, + currentState = state, + availableCommands = emptySet(), mediaItem = null, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = false, ) // then - assertThat(result.playing).isEqualTo(isPlaying) + assertThat(result.playing).isFalse() } @Test fun givenIsPlaying_thenPlayingIsTrue() { // given - val isPlaying = true + val state = PlayerState.Playing // when val result = PlayerUiStateMapper.map( - Player.Commands.EMPTY, - shuffleModeEnabled = false, - isPlaying = isPlaying, + currentState = state, + availableCommands = emptySet(), mediaItem = null, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = false, ) // then - assertThat(result.playing).isEqualTo(isPlaying) + assertThat(result.playing).isTrue() } @Test @@ -292,50 +290,45 @@ class PlayerUiStateMapperTest { // given val title = "title" val artist = "artist" - val metadataBuilder = MediaMetadata.Builder() - .setDisplayTitle(title) - .setArtist(artist) - - val mediaItem = MediaItem.Builder() - .setMediaMetadata(metadataBuilder.build()) - .build() + val mediaItem = MediaItem(title = title, artist = artist) // when val result = PlayerUiStateMapper.map( - Player.Commands.EMPTY, - shuffleModeEnabled = false, - isPlaying = false, + currentState = PlayerState.Ready, + availableCommands = emptySet(), mediaItem = mediaItem, - trackPosition = null + mediaItemPosition = null, + shuffleModeEnabled = false, ) // then - Assert.assertNotNull(result.mediaItem) + assertNotNull(result.mediaItem) val expectedMediaItem = result.mediaItem!! assertThat(expectedMediaItem.title).isEqualTo(title) assertThat(expectedMediaItem.artist).isEqualTo(artist) } @Test - fun givenTrackPosition_thenTrackPositionIsMappedCorrectly() { + fun givenMediaItemPosition_thenTrackPositionIsMappedCorrectly() { // given - val current = 1L - val duration = 2L - val trackPosition = TrackPosition(current, duration) + val current = 1.seconds + val duration = 2.seconds + val mediaItemPosition = MediaItemPosition.create(current, duration) // when val result = PlayerUiStateMapper.map( - Player.Commands.EMPTY, - shuffleModeEnabled = false, - isPlaying = false, + currentState = PlayerState.Ready, + availableCommands = emptySet(), mediaItem = null, - trackPosition = trackPosition + mediaItemPosition = mediaItemPosition, + shuffleModeEnabled = false, ) // then - Assert.assertNotNull(result.trackPosition) + assertNotNull(result.trackPosition) val expectedTrackPosition = result.trackPosition!! - assertThat(expectedTrackPosition.current).isEqualTo(current) + assertThat(expectedTrackPosition.current).isEqualTo(current.inWholeMilliseconds) + assertThat(expectedTrackPosition.duration).isEqualTo(duration.inWholeMilliseconds) assertThat(expectedTrackPosition.percent).isEqualTo(0.5f) } } diff --git a/media-ui/src/test/java/com/google/android/horologist/media/ui/state/mapper/TrackPositionUiModelMapperTest.kt b/media-ui/src/test/java/com/google/android/horologist/media/ui/state/mapper/TrackPositionUiModelMapperTest.kt index 4fbb5ae8f0..3e68b4cbbd 100644 --- a/media-ui/src/test/java/com/google/android/horologist/media/ui/state/mapper/TrackPositionUiModelMapperTest.kt +++ b/media-ui/src/test/java/com/google/android/horologist/media/ui/state/mapper/TrackPositionUiModelMapperTest.kt @@ -18,26 +18,27 @@ package com.google.android.horologist.media.ui.state.mapper -import com.google.android.horologist.media.data.model.TrackPosition +import com.google.android.horologist.media.model.MediaItemPosition import com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi import com.google.common.truth.Truth.assertThat import org.junit.Test +import kotlin.time.Duration.Companion.seconds class TrackPositionUiModelMapperTest { @Test - fun givenTrackPosition_thenMapsCorrectly() { + fun givenMediaItemPosition_thenMapsCorrectly() { // given - val current = 1L - val duration = 2L - val trackPosition = TrackPosition(current, duration) + val current = 1.seconds + val duration = 2.seconds + val mediaItemPosition = MediaItemPosition.create(current, duration) // when - val result = TrackPositionUiModelMapper.map(trackPosition) + val result = TrackPositionUiModelMapper.map(mediaItemPosition) // then - assertThat(result.current).isEqualTo(current) - assertThat(result.duration).isEqualTo(duration) + assertThat(result.current).isEqualTo(current.inWholeMilliseconds) + assertThat(result.duration).isEqualTo(duration.inWholeMilliseconds) assertThat(result.percent).isEqualTo(0.5f) } } diff --git a/media-ui/src/test/java/com/google/android/horologist/test/toolbox/testdoubles/StubPlayerRepository.kt b/media-ui/src/test/java/com/google/android/horologist/test/toolbox/testdoubles/StubPlayerRepository.kt index ec09d814ad..5bc574d3a2 100644 --- a/media-ui/src/test/java/com/google/android/horologist/test/toolbox/testdoubles/StubPlayerRepository.kt +++ b/media-ui/src/test/java/com/google/android/horologist/test/toolbox/testdoubles/StubPlayerRepository.kt @@ -16,77 +16,110 @@ package com.google.android.horologist.test.toolbox.testdoubles -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import com.google.android.horologist.media.data.ExperimentalHorologistMediaDataApi -import com.google.android.horologist.media.data.model.TrackPosition -import com.google.android.horologist.media.data.repository.PlayerRepository +import com.google.android.horologist.media.ExperimentalHorologistMediaApi +import com.google.android.horologist.media.model.Command +import com.google.android.horologist.media.model.MediaItem +import com.google.android.horologist.media.model.MediaItemPosition +import com.google.android.horologist.media.model.PlayerState +import com.google.android.horologist.media.repository.PlayerRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds -@OptIn(ExperimentalHorologistMediaDataApi::class) +@OptIn(ExperimentalHorologistMediaApi::class) class StubPlayerRepository : PlayerRepository { - override val availableCommands: StateFlow - get() = MutableStateFlow(Player.Commands.EMPTY) + override val availableCommands: StateFlow> + get() = MutableStateFlow(emptySet()) - override val isPlaying: StateFlow - get() = MutableStateFlow(false) + override val currentState: StateFlow + get() = MutableStateFlow(PlayerState.Idle) override val currentMediaItem: StateFlow get() = MutableStateFlow(null) - override val trackPosition: StateFlow + override val mediaItemPosition: StateFlow get() = MutableStateFlow(null) override val shuffleModeEnabled: StateFlow get() = MutableStateFlow(false) - override fun prepareAndPlay(mediaItem: MediaItem, play: Boolean) { + override fun prepare() { + // do nothing + } + + override fun play() { + // do nothing + } + + override fun play(mediaItemIndex: Int) { // do nothing } - override fun prepareAndPlay( - mediaItems: List?, - startIndex: Int, - play: Boolean - ) { + override fun pause() { // do nothing } - override fun seekToPreviousMediaItem() { + override fun hasPreviousMediaItem(): Boolean = false + + override fun skipToPreviousMediaItem() { // do nothing } - override fun seekToNextMediaItem() { + override fun hasNextMediaItem(): Boolean = false + + override fun skipToNextMediaItem() { // do nothing } - override fun getSeekBackIncrement(): Long = SEEK_INCREMENT + override fun getSeekBackIncrement(): Duration = 0.seconds override fun seekBack() { // do nothing } - override fun getSeekForwardIncrement(): Long = SEEK_INCREMENT + override fun getSeekForwardIncrement(): Duration = 0.seconds override fun seekForward() { // do nothing } - override fun pause() { + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { + // do nothing + } + + override fun setMediaItem(mediaItem: MediaItem) { // do nothing } - override fun toggleShuffle() { + override fun setMediaItems(mediaItems: List) { // do nothing } - override fun updatePosition() { + override fun addMediaItem(mediaItem: MediaItem) { // do nothing } - companion object { - const val SEEK_INCREMENT = 15000L + override fun addMediaItem(index: Int, mediaItem: MediaItem) { + // do nothing + } + + override fun removeMediaItem(index: Int) { + // do nothing + } + + override fun clearMediaItems() { + // do nothing + } + + override fun getMediaItemCount(): Int = 0 + + override fun getMediaItemAt(index: Int): MediaItem? = null + + override fun getCurrentMediaItemIndex(): Int = -1 + + override fun release() { + // do nothing } } diff --git a/sample/build.gradle b/sample/build.gradle index 91eff6119f..dccfd6076f 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -66,7 +66,7 @@ android { freeCompilerArgs += "-opt-in=com.google.android.horologist.audio.ExperimentalHorologistAudioApi" freeCompilerArgs += "-opt-in=com.google.android.horologist.audio.ui.ExperimentalHorologistAudioUiApi" freeCompilerArgs += "-opt-in=com.google.android.horologist.media.ui.ExperimentalHorologistMediaUiApi" - freeCompilerArgs += "-opt-in=com.google.android.horologist.media.data.ExperimentalHorologistMediaDataApi" + freeCompilerArgs += "-opt-in=com.google.android.horologist.media.ExperimentalHorologistMediaApi" freeCompilerArgs += "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi" freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" freeCompilerArgs += "-opt-in=com.google.accompanist.pager.ExperimentalPagerApi" @@ -84,8 +84,8 @@ dependencies { implementation projects.audioUi implementation projects.tiles implementation projects.composables + implementation projects.media implementation projects.mediaUi - implementation projects.mediaData implementation projects.networkAwareness implementation libs.compose.ui.tooling @@ -107,8 +107,6 @@ dependencies { implementation libs.kotlin.stdlib - implementation libs.androidx.media3.common - androidTestImplementation libs.compose.ui.test.junit4 androidTestImplementation libs.espresso.core androidTestImplementation libs.junit diff --git a/sample/src/main/java/com/google/android/horologist/sample/media/MediaDataSource.kt b/sample/src/main/java/com/google/android/horologist/sample/media/MediaDataSource.kt index ace8ef0f9a..ed97479d6b 100644 --- a/sample/src/main/java/com/google/android/horologist/sample/media/MediaDataSource.kt +++ b/sample/src/main/java/com/google/android/horologist/sample/media/MediaDataSource.kt @@ -16,35 +16,26 @@ package com.google.android.horologist.sample.media -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata +import com.google.android.horologist.media.model.MediaItem /** * Simple data source for a list of fake [MediaItem]s */ class MediaDataSource { + // the stage is set, the green flag, drops! private val songs = listOf( - Pair("Highway Star", "Deep Purple"), - Pair("Paranoid", "Black Sabbath"), - Pair("Peter Gunn", "Henry Mancini"), - Pair("Bad to the Bone", "George Thorogood and the Destroyers"), - Pair("Born to Be Wild", "Steppenwolf"), + "Highway Star" to "Deep Purple", + "Paranoid" to "Black Sabbath", + "Peter Gunn" to "Henry Mancini", + "Bad to the Bone" to "George Thorogood and the Destroyers", + "Born to Be Wild" to "Steppenwolf", ) fun fetchData(): List { return mutableListOf().also { - for (song in songs) { - it.add( - MediaItem.Builder() - .setMediaMetadata( - MediaMetadata.Builder() - .setDisplayTitle(song.first) - .setArtist(song.second) - .build() - ) - .build() - ) + for ((title, artist) in songs) { + it.add(MediaItem(title = title, artist = artist)) } } } diff --git a/sample/src/main/java/com/google/android/horologist/sample/media/PlayerRepositoryImpl.kt b/sample/src/main/java/com/google/android/horologist/sample/media/PlayerRepositoryImpl.kt index 83fc3ea526..c41c96fee8 100644 --- a/sample/src/main/java/com/google/android/horologist/sample/media/PlayerRepositoryImpl.kt +++ b/sample/src/main/java/com/google/android/horologist/sample/media/PlayerRepositoryImpl.kt @@ -16,19 +16,19 @@ package com.google.android.horologist.sample.media -import androidx.annotation.OptIn -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.Player.Command -import androidx.media3.common.util.UnstableApi -import com.google.android.horologist.media.data.model.TrackPosition -import com.google.android.horologist.media.data.repository.PlayerRepository +import com.google.android.horologist.media.model.Command +import com.google.android.horologist.media.model.MediaItem +import com.google.android.horologist.media.model.MediaItemPosition +import com.google.android.horologist.media.model.PlayerState +import com.google.android.horologist.media.repository.PlayerRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds /** * A fake implementation of [PlayerRepository]. @@ -39,22 +39,22 @@ class PlayerRepositoryImpl( private val mediaDataSource: MediaDataSource ) : PlayerRepository { - private var _availableCommandsList = MutableStateFlow(Player.Commands.EMPTY) - override val availableCommands: StateFlow = _availableCommandsList + private val _availableCommands = MutableStateFlow>(emptySet()) + override val availableCommands: StateFlow> = _availableCommands - private var _playing = MutableStateFlow(false) - override val isPlaying: StateFlow = _playing + private val _currentState: MutableStateFlow = MutableStateFlow(PlayerState.Idle) + override val currentState: StateFlow = _currentState - private var _currentMediaItem: MutableStateFlow = MutableStateFlow(null) + private val _currentMediaItem: MutableStateFlow = MutableStateFlow(null) override val currentMediaItem: StateFlow = _currentMediaItem - private var _trackPosition: MutableStateFlow = MutableStateFlow(null) - override val trackPosition: StateFlow = _trackPosition + private val _mediaItemPosition: MutableStateFlow = MutableStateFlow(null) + override val mediaItemPosition: StateFlow = _mediaItemPosition private var _shuffleModeEnabled = MutableStateFlow(false) override val shuffleModeEnabled: StateFlow = _shuffleModeEnabled - private var mediaItems: List? = null + private var _mediaItems: List? = null private var currentItemIndex = -1 private lateinit var coroutineScope: CoroutineScope @@ -66,7 +66,7 @@ class PlayerRepositoryImpl( fun fetchData() { mediaDataSource.fetchData().also { - mediaItems = it + _mediaItems = it currentItemIndex++ _currentMediaItem.value = it[currentItemIndex] updateCommands() @@ -74,61 +74,50 @@ class PlayerRepositoryImpl( } private fun updateCommands() { - mediaItems?.let { - addCommand(Player.COMMAND_PLAY_PAUSE) + val commands = mutableSetOf() + commands.addAll(_availableCommands.value) + + _mediaItems?.let { + commands += Command.PlayPause if (currentItemIndex < it.size - 1) { - addCommand(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + commands += Command.SkipToNextMediaItem } else { - removeCommand(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + commands -= Command.SkipToNextMediaItem } } ?: run { - removeCommand(Player.COMMAND_PLAY_PAUSE) + commands -= Command.PlayPause } if (currentItemIndex > 0) { - addCommand(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + commands += Command.SkipToPreviousMediaItem } else { - removeCommand(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + commands -= Command.SkipToPreviousMediaItem } - } - - override fun prepareAndPlay(mediaItem: MediaItem, play: Boolean) { - _currentMediaItem.value = mediaItem - if (play) { - pretendIsPlaying() - } + _availableCommands.value = commands } - override fun prepareAndPlay(mediaItems: List?, startIndex: Int, play: Boolean) { - mediaItems?.let { - this.mediaItems = it - currentItemIndex = startIndex - _currentMediaItem.value = it[startIndex] - } - - if (play) { - pretendIsPlaying() - } + override fun prepare() { + // do nothing } - private fun pretendIsPlaying() { - _playing.value = true + override fun play() { + _currentState.value = PlayerState.Playing - if (trackPosition.value?.current == DURATION) { - _trackPosition.value = INITIAL_TRACK_POSITION + if (_mediaItemPosition.value?.current == DURATION) { + _mediaItemPosition.value = INITIAL_MEDIA_ITEM_POSITION } playingJob = coroutineScope.launch { - repeat(DURATION.toInt()) { - delay(1_000L) + repeat(DURATION.inWholeSeconds.toInt()) { + delay(1.seconds.inWholeMilliseconds) updatePosition() } - mediaItems?.let { + _mediaItems?.let { if (currentItemIndex < it.size - 1) { - delay(1_000L) - seekToNextMediaItem() + delay(1.seconds.inWholeMilliseconds) + skipToNextMediaItem() } else { pause() } @@ -138,87 +127,122 @@ class PlayerRepositoryImpl( } } - override fun seekToPreviousMediaItem() { - val wasPlaying = _playing.value + override fun play(mediaItemIndex: Int) { + // do nothing + } + + private fun updatePosition() { + _mediaItemPosition.value = _mediaItemPosition.value?.let { + MediaItemPosition.create( + current = it.current + 1.seconds, + duration = DURATION + ) + } ?: MediaItemPosition.create( + current = INITIAL_MEDIA_ITEM_POSITION.current + 1.seconds, + duration = DURATION + ) + } + + override fun pause() { + _currentState.value = PlayerState.Ready + playingJob?.cancel() + } + + override fun hasPreviousMediaItem(): Boolean = currentItemIndex > 0 + + override fun skipToPreviousMediaItem() { + val wasPlaying = _currentState.value == PlayerState.Playing if (wasPlaying) { playingJob?.cancel() - _playing.value = false + _currentState.value = PlayerState.Ready } currentItemIndex-- - _currentMediaItem.value = mediaItems!![currentItemIndex] - _trackPosition.value = INITIAL_TRACK_POSITION + _currentMediaItem.value = _mediaItems!![currentItemIndex] + _mediaItemPosition.value = INITIAL_MEDIA_ITEM_POSITION updateCommands() if (wasPlaying) { - prepareAndPlay() + play() } } - override fun seekToNextMediaItem() { - val wasPlaying = _playing.value + override fun hasNextMediaItem(): Boolean = + _mediaItems?.let { + currentItemIndex < it.size - 2 + } ?: false + + override fun skipToNextMediaItem() { + val wasPlaying = _currentState.value == PlayerState.Playing if (wasPlaying) { playingJob?.cancel() - _playing.value = false + _currentState.value = PlayerState.Ready } currentItemIndex++ - _currentMediaItem.value = mediaItems!![currentItemIndex] - _trackPosition.value = INITIAL_TRACK_POSITION + _currentMediaItem.value = _mediaItems!![currentItemIndex] + _mediaItemPosition.value = INITIAL_MEDIA_ITEM_POSITION updateCommands() if (wasPlaying) { - prepareAndPlay() + play() } } - override fun getSeekBackIncrement(): Long = 0 + override fun getSeekBackIncrement(): Duration = 0.seconds // not implemented override fun seekBack() { // do nothing } - override fun getSeekForwardIncrement(): Long = 0 + override fun getSeekForwardIncrement(): Duration = 0.seconds // not implemented override fun seekForward() { // do nothing } - override fun pause() { - _playing.value = false - playingJob?.cancel() + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { + // do nothing + } + + override fun setMediaItem(mediaItem: MediaItem) { + // do nothing + } + + override fun setMediaItems(mediaItems: List) { + // do nothing } - override fun toggleShuffle() { + override fun addMediaItem(mediaItem: MediaItem) { // do nothing } - override fun updatePosition() { - _trackPosition.value = _trackPosition.value?.let { - it.copy(current = it.current + 1) - } ?: INITIAL_TRACK_POSITION.copy(current = INITIAL_TRACK_POSITION.current + 1) + override fun addMediaItem(index: Int, mediaItem: MediaItem) { + // do nothing } - private fun addCommand(@Command command: Int) { - @OptIn(UnstableApi::class) - _availableCommandsList.value = Player.Commands.Builder() - .addAll(_availableCommandsList.value) - .add(command) - .build() + override fun removeMediaItem(index: Int) { + // do nothing + } + + override fun clearMediaItems() { + // do nothing } - private fun removeCommand(@Command command: Int) { - @OptIn(UnstableApi::class) - _availableCommandsList.value = Player.Commands.Builder() - .addAll(_availableCommandsList.value) - .remove(command) - .build() + override fun getMediaItemCount(): Int = _mediaItems?.size ?: 0 + + override fun getMediaItemAt(index: Int): MediaItem? = null // not implemented + + override fun getCurrentMediaItemIndex(): Int = 0 // not implemented + + override fun release() { + // do nothing } companion object { - private const val DURATION = 180L + private val DURATION = 180.seconds - private val INITIAL_TRACK_POSITION = TrackPosition(0, DURATION) + private val INITIAL_MEDIA_ITEM_POSITION = MediaItemPosition.create(0.seconds, DURATION) } class Factory(