From 0bbe5244b7e33a5013a7000db5203e8749225d70 Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Mon, 22 Apr 2024 15:39:50 +0100 Subject: [PATCH] Adds Playback speed --- .../jetcaster/core/player/EpisodePlayer.kt | 10 ++ .../core/player/MockEpisodePlayer.kt | 8 ++ Jetcaster/gradle/libs.versions.toml | 2 +- .../java/com/example/jetcaster/WearApp.kt | 9 ++ .../jetcaster/ui/JetcasterNavController.kt | 8 ++ .../ui/components/SettingsButtons.kt | 32 +++-- .../jetcaster/ui/home/HomeViewModel.kt | 6 - .../jetcaster/ui/library/QueueScreen.kt | 27 ++-- .../jetcaster/ui/library/QueueViewModel.kt | 5 + .../ui/player/PlaybackSpeedScreen.kt | 121 ++++++++++++++++++ .../ui/player/PlaybackSpeedUiState.kt | 21 +++ .../ui/player/PlaybackSpeedUiStateMapper.kt | 29 +++++ .../ui/player/PlaybackSpeedViewModel.kt | 63 +++++++++ .../jetcaster/ui/player/PlayerScreen.kt | 47 ++++--- .../jetcaster/ui/player/PlayerViewModel.kt | 5 - .../res/drawable/{speed.xml => speed_15x.xml} | 0 .../wear/src/main/res/drawable/speed_1x.xml | 4 + .../wear/src/main/res/drawable/speed_2x.xml | 4 + .../wear/src/main/res/values/strings.xml | 7 +- 19 files changed, 353 insertions(+), 55 deletions(-) create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedScreen.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedUiState.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedUiStateMapper.kt create mode 100644 Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedViewModel.kt rename Jetcaster/wear/src/main/res/drawable/{speed.xml => speed_15x.xml} (100%) create mode 100644 Jetcaster/wear/src/main/res/drawable/speed_1x.xml create mode 100644 Jetcaster/wear/src/main/res/drawable/speed_2x.xml diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt index 2cbd7258d4..cbb858a117 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt @@ -102,4 +102,14 @@ interface EpisodePlayer { * Rewinds a currently played episode by a given time interval specified in [duration]. */ fun rewindBy(duration: Duration) + + /** + * Increases the speed of Player playback by a given time specified in [duration]. + */ + fun increaseSpeed(speed: Duration = Duration.ofMillis(500)) + + /** + * Decreases the speed of Player playback by a given time specified in [duration]. + */ + fun decreaseSpeed(speed: Duration = Duration.ofMillis(500)) } diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt index 784fcbd914..4c65a90391 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -173,6 +173,14 @@ class MockEpisodePlayer( } } + override fun increaseSpeed(speed: Duration) { + _playerSpeed.value += speed + } + + override fun decreaseSpeed(speed: Duration) { + _playerSpeed.value -= speed + } + override fun next() { val q = queue.value if (q.isEmpty()) { diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index 9e2e8a8c9a..b3e120e697 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -61,7 +61,7 @@ composeMaterial = "1.2.1" composeFoundation = "1.2.1" coreSplashscreen = "1.0.1" horologistComposeTools = "0.4.8" -horologist = "0.6.6" +horologist = "0.6.9" roborazzi = "1.11.0" androidx-wear-compose = "1.3.0" wear-compose-ui-tooling = "1.3.0" diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt index 92d2c4adb8..ab2933b4ed 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt @@ -28,10 +28,12 @@ import com.example.jetcaster.theme.WearAppTheme import com.example.jetcaster.ui.Episode import com.example.jetcaster.ui.JetcasterNavController.navigateToEpisode import com.example.jetcaster.ui.JetcasterNavController.navigateToLatestEpisode +import com.example.jetcaster.ui.JetcasterNavController.navigateToPlaybackSpeed import com.example.jetcaster.ui.JetcasterNavController.navigateToPodcastDetails import com.example.jetcaster.ui.JetcasterNavController.navigateToUpNext import com.example.jetcaster.ui.JetcasterNavController.navigateToYourPodcast import com.example.jetcaster.ui.LatestEpisodes +import com.example.jetcaster.ui.PlaybackSpeed import com.example.jetcaster.ui.PodcastDetails import com.example.jetcaster.ui.UpNext import com.example.jetcaster.ui.YourPodcasts @@ -40,6 +42,7 @@ import com.example.jetcaster.ui.home.HomeScreen import com.example.jetcaster.ui.library.LatestEpisodesScreen import com.example.jetcaster.ui.library.PodcastsScreen import com.example.jetcaster.ui.library.QueueScreen +import com.example.jetcaster.ui.player.PlaybackSpeedScreen import com.example.jetcaster.ui.player.PlayerScreen import com.example.jetcaster.ui.podcast.PodcastDetailsScreen import com.google.android.horologist.audio.ui.VolumeViewModel @@ -69,6 +72,9 @@ fun WearApp() { onVolumeClick = { navController.navigateToVolume() }, + onPlaybackSpeedChangeClick = { + navController.navigateToPlaybackSpeed() + }, ) }, libraryScreen = { @@ -137,6 +143,9 @@ fun WearApp() { } ) } + composable(route = PlaybackSpeed.navRoute) { + PlaybackSpeedScreen() + } }, ) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt index 673eb527e2..c0246787aa 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt @@ -47,6 +47,10 @@ public object JetcasterNavController { public fun NavController.navigateToEpisode(episodeUri: String) { navigate(Episode.destination(episodeUri)) } + + public fun NavController.navigateToPlaybackSpeed() { + navigate(PlaybackSpeed.destination()) + } } public object YourPodcasts : NavigationScreens("yourPodcasts") { @@ -90,3 +94,7 @@ public object Episode : NavigationScreens("episode?episodeUri={episodeUri}") { public object UpNext : NavigationScreens("upNext") { public fun destination(): String = navRoute } + +public object PlaybackSpeed : NavigationScreens("playbackSpeed") { + public fun destination(): String = navRoute +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt index 4806916d3e..91f32debc5 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt @@ -19,13 +19,14 @@ package com.example.jetcaster.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import com.example.jetcaster.R +import com.example.jetcaster.ui.player.PlayerUiState import com.google.android.horologist.audio.ui.VolumeUiState import com.google.android.horologist.audio.ui.components.SettingsButtonsDefaults import com.google.android.horologist.audio.ui.components.actions.SetVolumeButton @@ -40,7 +41,8 @@ import com.google.android.horologist.compose.material.IconRtlMode fun SettingsButtons( volumeUiState: VolumeUiState, onVolumeClick: () -> Unit, - onAddToQueueClick: () -> Unit, + playerUiState: PlayerUiState, + onPlaybackSpeedChange: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, ) { @@ -49,8 +51,11 @@ fun SettingsButtons( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, ) { - AddToQueueButton( - onAddToQueueClick = onAddToQueueClick, + PlaybackSpeedButton( + currentPlayerSpeed = playerUiState.episodePlayerState + .playbackSpeed.toMillis().toFloat() / 1000, + onPlaybackSpeedChange = onPlaybackSpeedChange, + enabled = enabled ) SettingsButtonsDefaults.BrandIcon( @@ -61,22 +66,29 @@ fun SettingsButtons( SetVolumeButton( onVolumeClick = onVolumeClick, volumeUiState = volumeUiState, + enabled = enabled ) } } @Composable -fun AddToQueueButton( - onAddToQueueClick: () -> Unit, +fun PlaybackSpeedButton( + currentPlayerSpeed: Float, + onPlaybackSpeedChange: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, ) { SettingsButton( modifier = modifier, - onClick = onAddToQueueClick, + onClick = onPlaybackSpeedChange, enabled = enabled, - imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + imageVector = + when (currentPlayerSpeed) { + 1f -> ImageVector.vectorResource(R.drawable.speed_1x) + 1.5f -> ImageVector.vectorResource(R.drawable.speed_15x) + else -> { ImageVector.vectorResource(R.drawable.speed_2x) } + }, iconRtlMode = IconRtlMode.Mirrored, - contentDescription = stringResource(R.string.add_to_queue_content_description), + contentDescription = stringResource(R.string.change_playback_speed_content_description), ) } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index f50fd9f31e..a94ceebcb2 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -121,12 +121,6 @@ class HomeViewModel @Inject constructor( } } - fun onPodcastUnfollowed(podcastUri: String) { - viewModelScope.launch { - podcastStore.unfollowPodcast(podcastUri) - } - } - fun onTogglePodcastFollowed(podcastUri: String) { viewModelScope.launch { podcastStore.togglePodcastFollowed(podcastUri) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueScreen.kt index a546ec7c9f..adb8f095a5 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueScreen.kt @@ -68,11 +68,11 @@ import com.google.android.horologist.media.ui.screens.entity.EntityScreen QueueScreen( uiState = uiState, onPlayButtonClick = onPlayButtonClick, + onPlayEpisodes = queueViewModel::onPlayEpisodes, modifier = modifier, onEpisodeItemClick = onEpisodeItemClick, onDeleteQueueEpisodes = queueViewModel::onDeleteQueueEpisodes, - onDismiss = onDismiss, - queueViewModel = queueViewModel + onDismiss = onDismiss ) } @@ -80,11 +80,11 @@ import com.google.android.horologist.media.ui.screens.entity.EntityScreen fun QueueScreen( uiState: QueueScreenState, onPlayButtonClick: () -> Unit, + onPlayEpisodes: (List) -> Unit, modifier: Modifier = Modifier, onEpisodeItemClick: (EpisodeToPodcast) -> Unit, onDeleteQueueEpisodes: () -> Unit, - onDismiss: () -> Unit, - queueViewModel: QueueViewModel + onDismiss: () -> Unit ) { val columnState = rememberResponsiveColumnState( contentPadding = padding( @@ -109,11 +109,9 @@ fun QueueScreen( }, buttonsContent = { ButtonsContent( - onPlayButtonClick = - { - onPlayButtonClick - queueViewModel.onPlayEpisode(uiState.episodeList[0]) - }, + episodes = uiState.episodeList, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisodes = onPlayEpisodes, onDeleteQueueEpisodes = onDeleteQueueEpisodes ) }, @@ -141,7 +139,9 @@ fun QueueScreen( }, buttonsContent = { ButtonsContent( + episodes = emptyList(), onPlayButtonClick = {}, + onPlayEpisodes = {}, onDeleteQueueEpisodes = { }, enabled = false ) @@ -158,7 +158,7 @@ fun QueueScreen( showDialog = true, onDismiss = onDismiss, title = stringResource(R.string.display_nothing_in_queue), - message = stringResource(R.string.failed_loading_episodes_from_queue) + message = stringResource(R.string.no_episodes_from_queue) ) } } @@ -168,7 +168,9 @@ fun QueueScreen( @OptIn(ExperimentalHorologistApi::class) @Composable fun ButtonsContent( + episodes: List, onPlayButtonClick: () -> Unit, + onPlayEpisodes: (List) -> Unit, onDeleteQueueEpisodes: () -> Unit, enabled: Boolean = true ) { @@ -183,7 +185,10 @@ fun ButtonsContent( Button( imageVector = Icons.Outlined.PlayArrow, contentDescription = stringResource(id = R.string.button_play_content_description), - onClick = onPlayButtonClick, + onClick = { + onPlayButtonClick() + onPlayEpisodes(episodes) + }, modifier = Modifier .weight(weight = 0.3F, fill = false), enabled = enabled diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueViewModel.kt index abdc0b7fcc..12d1ab0661 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/QueueViewModel.kt @@ -53,6 +53,11 @@ class QueueViewModel @Inject constructor( episodePlayer.play() } + fun onPlayEpisodes(episodes: List) { + episodePlayer.currentEpisode = episodes[0] + episodePlayer.play(episodes) + } + fun onDeleteQueueEpisodes() { episodePlayer.removeAllFromQueue() } diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedScreen.kt new file mode 100644 index 0000000000..28bb0da45e --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedScreen.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2024 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.example.jetcaster.ui.player + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.wear.compose.foundation.ExperimentalWearFoundationApi +import androidx.wear.compose.material.ContentAlpha +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.InlineSlider +import androidx.wear.compose.material.InlineSliderDefaults +import androidx.wear.compose.material.LocalContentAlpha +import androidx.wear.compose.material.Text +import com.example.jetcaster.R +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.listTextPadding +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.compose.material.ResponsiveListHeader + +/** + * Playback Speed Screen with an [InlineSlider]. + */ +@OptIn(ExperimentalWearFoundationApi::class) +@Composable +public fun PlaybackSpeedScreen( + modifier: Modifier = Modifier, + playbackSpeedViewModel: PlaybackSpeedViewModel = hiltViewModel(), +) { + val playbackSpeedUiState by playbackSpeedViewModel.speedUiState.collectAsState() + + val columnState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip, + ), + ) + ScreenScaffold(scrollState = columnState) { + ScalingLazyColumn(columnState = columnState) { + item { + ResponsiveListHeader(modifier = Modifier.listTextPadding()) { + Text(stringResource(R.string.speed)) + } + } + item { + PlaybackSpeedScreen( + playbackSpeedUiState = playbackSpeedUiState, + increasePlaybackSpeed = playbackSpeedViewModel::increaseSpeed, + decreasePlaybackSpeed = playbackSpeedViewModel::decreaseSpeed, + modifier = modifier + ) + } + + item { + Text( + text = String.format("%.1fx", playbackSpeedUiState.current), + ) + } + } + } +} + +@Composable +internal fun PlaybackSpeedScreen( + playbackSpeedUiState: PlaybackSpeedUiState, + increasePlaybackSpeed: () -> Unit, + decreasePlaybackSpeed: () -> Unit, + modifier: Modifier +) { + InlineSlider( + value = playbackSpeedUiState.current, + onValueChange = { + if (it > playbackSpeedUiState.current) increasePlaybackSpeed() + else if (it > 0.5) decreasePlaybackSpeed() + }, + increaseIcon = { + Icon( + InlineSliderDefaults.Increase, + stringResource(R.string.increase_playback_speed) + ) + }, + decreaseIcon = { + CompositionLocalProvider( + LocalContentAlpha provides + if (playbackSpeedUiState.current > 1f) + LocalContentAlpha.current else ContentAlpha.disabled + ) { + Icon( + InlineSliderDefaults.Decrease, + stringResource(R.string.decrease_playback_speed) + ) + } + }, + valueRange = 0.5f..2f, + steps = 2, + segmented = true + ) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedUiState.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedUiState.kt new file mode 100644 index 0000000000..e418030ea0 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedUiState.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 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.example.jetcaster.ui.player + +public data class PlaybackSpeedUiState( + val current: Float = 1f +) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedUiStateMapper.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedUiStateMapper.kt new file mode 100644 index 0000000000..56a5de4cc2 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedUiStateMapper.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 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.example.jetcaster.ui.player + +import java.time.Duration + +public object PlaybackSpeedUiStateMapper { + /** + * Functions to map a [PlaybackSpeedUiState] from a [Duration]. The view model + * uses float to represent the values displayed in the [PlayerScreen]. + */ + public fun map(playbackSpeed: Duration): PlaybackSpeedUiState = PlaybackSpeedUiState( + current = (playbackSpeed.toMillis().toFloat() / 1000) + ) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedViewModel.kt new file mode 100644 index 0000000000..16db7ffdee --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlaybackSpeedViewModel.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 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.example.jetcaster.ui.player + +import android.content.ContentValues.TAG +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.player.EpisodePlayer +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * ViewModel for a Plaback Speed Screen. + * + * Holds the state of PlaybackSpeed ([playerSpeed]) . + * + * Playback speed changes can be made via [increaseSpeed] and [decreaseSpeed]. + * + */ + +@HiltViewModel +public open class PlaybackSpeedViewModel @Inject constructor( + private val episodePlayer: EpisodePlayer, +) : ViewModel() { + + val speedUiState = episodePlayer.playerState.map { + PlaybackSpeedUiStateMapper.map(it.playbackSpeed) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + PlaybackSpeedUiState() + ) + + public fun increaseSpeed() { + episodePlayer.increaseSpeed() + } + + public fun decreaseSpeed() { + episodePlayer.decreaseSpeed() + } + + private fun notSupported() { + Log.i(TAG, "Effect not supported") + } +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 541dcc864e..0c2fa5b461 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -36,18 +36,19 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.ExperimentalWearFoundationApi +import androidx.wear.compose.foundation.rememberActiveFocusRequester +import androidx.wear.compose.foundation.rotary.rotary import androidx.wear.compose.material.MaterialTheme import com.example.jetcaster.R -import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.ui.components.SettingsButtons import com.google.android.horologist.audio.ui.VolumeUiState import com.google.android.horologist.audio.ui.VolumeViewModel -import com.google.android.horologist.audio.ui.rotaryVolumeControlsWithFocus -import com.google.android.horologist.compose.rotaryinput.RotaryDefaults +import com.google.android.horologist.audio.ui.volumeRotaryBehavior +import com.google.android.horologist.images.coil.CoilPaintable import com.google.android.horologist.media.ui.components.PodcastControlButtons import com.google.android.horologist.media.ui.components.background.ArtworkColorBackground import com.google.android.horologist.media.ui.components.controls.SeekButtonIncrement @@ -59,6 +60,7 @@ import com.google.android.horologist.media.ui.screens.player.PlayerScreen fun PlayerScreen( volumeViewModel: VolumeViewModel, onVolumeClick: () -> Unit, + onPlaybackSpeedChangeClick: () -> Unit, modifier: Modifier = Modifier, playerScreenViewModel: PlayerViewModel = hiltViewModel(), ) { @@ -69,23 +71,24 @@ fun PlayerScreen( volumeUiState = volumeUiState, onVolumeClick = onVolumeClick, onUpdateVolume = { newVolume -> volumeViewModel.setVolume(newVolume) }, - onAddToQueueClick = playerScreenViewModel::addToQueue, + onPlaybackSpeedChangeClick = onPlaybackSpeedChangeClick, modifier = modifier ) } +@OptIn(ExperimentalWearFoundationApi::class) @Composable private fun PlayerScreen( playerScreenViewModel: PlayerViewModel, volumeUiState: VolumeUiState, onVolumeClick: () -> Unit, - onAddToQueueClick: (PlayerEpisode) -> Unit, + onPlaybackSpeedChangeClick: () -> Unit, onUpdateVolume: (Int) -> Unit, modifier: Modifier = Modifier, ) { val uiState by playerScreenViewModel.uiState.collectAsStateWithLifecycle() - when (val s = uiState) { + when (val state = uiState) { PlayerScreenUiState.Loading -> LoadingMediaDisplay(modifier) PlayerScreenUiState.Empty -> { PlayerScreen( @@ -111,7 +114,8 @@ private fun PlayerScreen( SettingsButtons( volumeUiState = volumeUiState, onVolumeClick = onVolumeClick, - onAddToQueueClick = {}, + playerUiState = PlayerUiState(), + onPlaybackSpeedChange = onPlaybackSpeedChangeClick, enabled = false, ) }, @@ -121,7 +125,7 @@ private fun PlayerScreen( is PlayerScreenUiState.Ready -> { // When screen is ready, episode is always not null, however EpisodePlayerState may // return a null episode - val episode = s.playerState.episodePlayerState.currentEpisode + val episode = state.playerState.episodePlayerState.currentEpisode PlayerScreen( mediaDisplay = { @@ -143,35 +147,36 @@ private fun PlayerScreen( onPlayButtonClick = playerScreenViewModel::onPlay, onPauseButtonClick = playerScreenViewModel::onPause, playPauseButtonEnabled = true, - playing = s.playerState.episodePlayerState.isPlaying, + playing = state.playerState.episodePlayerState.isPlaying, onSeekBackButtonClick = playerScreenViewModel::onRewindBy, seekBackButtonEnabled = true, onSeekForwardButtonClick = playerScreenViewModel::onAdvanceBy, seekForwardButtonEnabled = true, seekBackButtonIncrement = SeekButtonIncrement.Ten, seekForwardButtonIncrement = SeekButtonIncrement.Ten, - trackPositionUiModel = s.playerState.trackPositionUiModel + trackPositionUiModel = state.playerState.trackPositionUiModel ) }, buttons = { SettingsButtons( volumeUiState = volumeUiState, onVolumeClick = onVolumeClick, - onAddToQueueClick = { - episode?.let { onAddToQueueClick(episode) } - }, + playerUiState = state.playerState, + onPlaybackSpeedChange = onPlaybackSpeedChangeClick, enabled = true, ) }, - modifier = modifier.rotaryVolumeControlsWithFocus( - volumeUiStateProvider = { volumeUiState }, - onRotaryVolumeInput = onUpdateVolume, - localView = LocalView.current, - isLowRes = RotaryDefaults.isLowResInput(), - ), + modifier = modifier + .rotary( + volumeRotaryBehavior( + volumeUiStateProvider = { volumeUiState }, + onRotaryVolumeInput = { onUpdateVolume }, + ), + focusRequester = rememberActiveFocusRequester(), + ), background = { ArtworkColorBackground( - artworkUri = episode?.let { episode.podcastImageUrl }, + paintable = episode?.let { CoilPaintable(episode.podcastImageUrl) }, defaultColor = MaterialTheme.colors.primary, modifier = Modifier.fillMaxSize(), ) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 3f0b7e16aa..2de53ded36 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -18,7 +18,6 @@ package com.example.jetcaster.ui.player import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState import com.google.android.horologist.annotations.ExperimentalHorologistApi @@ -89,10 +88,6 @@ class PlayerViewModel @Inject constructor( fun onRewindBy() { episodePlayer.rewindBy(Duration.ofSeconds(10)) } - - fun addToQueue(episode: PlayerEpisode) { - episodePlayer.addToQueue(episode) - } } sealed class PlayerScreenUiState { diff --git a/Jetcaster/wear/src/main/res/drawable/speed.xml b/Jetcaster/wear/src/main/res/drawable/speed_15x.xml similarity index 100% rename from Jetcaster/wear/src/main/res/drawable/speed.xml rename to Jetcaster/wear/src/main/res/drawable/speed_15x.xml diff --git a/Jetcaster/wear/src/main/res/drawable/speed_1x.xml b/Jetcaster/wear/src/main/res/drawable/speed_1x.xml new file mode 100644 index 0000000000..4dbcfb9d7a --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/speed_1x.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/drawable/speed_2x.xml b/Jetcaster/wear/src/main/res/drawable/speed_2x.xml new file mode 100644 index 0000000000..55db09ed1a --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/speed_2x.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/values/strings.xml b/Jetcaster/wear/src/main/res/values/strings.xml index 3b9d21d5f9..254b22e5e7 100644 --- a/Jetcaster/wear/src/main/res/values/strings.xml +++ b/Jetcaster/wear/src/main/res/values/strings.xml @@ -63,6 +63,11 @@ Not following Nothing playing + Speed + Increase playback speed + Decrease playback speed + Change playback speed + No podcasts available at the moment Loading No episodes available at the moment @@ -71,7 +76,7 @@ No episode in the queue Add an episode to the queue - Failed at loading episodes from the queue + There are no episodes from the queue Add to queue Episode info not available at the moment