Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom Playback Effects: Hide segmented control for local files #3097

Merged
merged 16 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ import au.com.shiftyjelly.pocketcasts.views.fragments.BaseDialogFragment
import com.google.android.material.button.MaterialButtonToggleGroup
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import au.com.shiftyjelly.pocketcasts.localization.R as LR

@AndroidEntryPoint
Expand Down Expand Up @@ -108,9 +107,9 @@ class EffectsFragment : BaseDialogFragment(), CompoundButton.OnCheckedChangeList
binding = null
}

private fun update(podcastEffectsPair: PlayerViewModel.PodcastEffectsPair) {
val podcast = podcastEffectsPair.podcast
val effects = podcastEffectsPair.effects
private fun update(podcastEffectsData: PlayerViewModel.PodcastEffectsData) {
val podcast = podcastEffectsData.podcast
val effects = podcastEffectsData.effects

val binding = binding ?: return

Expand Down Expand Up @@ -272,26 +271,27 @@ class EffectsFragment : BaseDialogFragment(), CompoundButton.OnCheckedChangeList
playbackManager.trackPlaybackEffectsEvent(event, props, SourceView.PLAYER_PLAYBACK_EFFECTS)
}

@OptIn(ExperimentalCoroutinesApi::class)
private fun FragmentEffectsBinding.setupEffectsSettingsSegmentedTabBar() {
effectsSettingsSegmentedTabBar.setContent {
val podcast by viewModel.effectsLive.asFlow()
.mapLatest { it.podcast }
val podcastEffectsData by viewModel.effectsLive.asFlow()
.distinctUntilChanged { t1, t2 -> t1.podcast.uuid == t2.podcast.uuid && t1.podcast.playbackEffects == t2.podcast.playbackEffects }
ashiagr marked this conversation as resolved.
Show resolved Hide resolved
.collectAsStateWithLifecycle(null)
if (podcast == null) return@setContent

EffectsSettingsSegmentedTabBar(
selectedItem = if (podcast?.overrideGlobalEffects == true) {
PlaybackEffectsSettingsTab.ThisPodcast
} else {
PlaybackEffectsSettingsTab.AllPodcasts
},
onItemSelected = {
viewModel.updatedOverrideGlobalEffects(requireNotNull(podcast), PlaybackEffectsSettingsTab.entries[it])
},
modifier = Modifier
.padding(top = 24.dp),
)
val podcast = podcastEffectsData?.podcast ?: return@setContent

if (podcastEffectsData?.showCustomEffectsSettings == true) {
EffectsSettingsSegmentedTabBar(
selectedItem = if (podcastEffectsData?.podcast?.overrideGlobalEffects == true) {
PlaybackEffectsSettingsTab.ThisPodcast
} else {
PlaybackEffectsSettingsTab.AllPodcasts
},
onItemSelected = {
viewModel.onEffectsSettingsSegmentedTabSelected(podcast, PlaybackEffectsSettingsTab.entries[it])
},
modifier = Modifier
.padding(top = 24.dp),
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import au.com.shiftyjelly.pocketcasts.preferences.model.ArtworkConfiguration
import au.com.shiftyjelly.pocketcasts.preferences.model.ShelfItem
import au.com.shiftyjelly.pocketcasts.repositories.bookmark.BookmarkManager
import au.com.shiftyjelly.pocketcasts.repositories.di.ApplicationScope
import au.com.shiftyjelly.pocketcasts.repositories.di.IoDispatcher
import au.com.shiftyjelly.pocketcasts.repositories.download.DownloadHelper
import au.com.shiftyjelly.pocketcasts.repositories.download.DownloadManager
import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager
Expand Down Expand Up @@ -64,6 +65,7 @@ import kotlin.coroutines.CoroutineContext
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
import kotlin.time.toDuration
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
Expand Down Expand Up @@ -91,12 +93,13 @@ class PlayerViewModel @Inject constructor(
private val episodeAnalytics: EpisodeAnalytics,
@ApplicationContext private val context: Context,
@ApplicationScope private val applicationScope: CoroutineScope,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel(), CoroutineScope {

override val coroutineContext: CoroutineContext
get() = Dispatchers.Default

data class PodcastEffectsPair(val podcast: Podcast, val effects: PlaybackEffects)
data class PodcastEffectsData(val podcast: Podcast, val effects: PlaybackEffects, val showCustomEffectsSettings: Boolean = true)
data class PlayerHeader(
val positionMs: Int = 0,
val durationMs: Int = -1,
Expand Down Expand Up @@ -263,7 +266,7 @@ class PlayerViewModel @Inject constructor(

val upNextLive: LiveData<List<Any>> = upNextPlusData.toFlowable(BackpressureStrategy.LATEST).toLiveData()

val effectsObservable: Flowable<PodcastEffectsPair> = playbackStateObservable
val effectsObservable: Flowable<PodcastEffectsData> = playbackStateObservable
.toFlowable(BackpressureStrategy.LATEST)
.map { it.episodeUuid }
.switchMap { episodeManager.observeEpisodeByUuidRx(it) }
Expand All @@ -274,7 +277,14 @@ class PlayerViewModel @Inject constructor(
Flowable.just(Podcast.userPodcast.copy(overrideGlobalEffects = false))
}
}
.map { PodcastEffectsPair(it, if (it.overrideGlobalEffects) it.playbackEffects else settings.globalPlaybackEffects.value) }
.map { podcast ->
val isUserPodcast = podcast.uuid == Podcast.userPodcast.uuid
PodcastEffectsData(
podcast = podcast,
effects = if (podcast.overrideGlobalEffects) podcast.playbackEffects else settings.globalPlaybackEffects.value,
showCustomEffectsSettings = !isUserPodcast,
)
}
.doOnNext { Timber.i("Effects: Podcast: ${it.podcast.overrideGlobalEffects} ${it.effects}") }
.observeOn(AndroidSchedulers.mainThread())
val effectsLive = effectsObservable.toLiveData()
Expand Down Expand Up @@ -691,11 +701,11 @@ class PlayerViewModel @Inject constructor(
}
}

fun updatedOverrideGlobalEffects(podcast: Podcast, selectedTab: PlaybackEffectsSettingsTab) {
fun onEffectsSettingsSegmentedTabSelected(podcast: Podcast, selectedTab: PlaybackEffectsSettingsTab) {
val currentEpisode = playbackManager.getCurrentEpisode()
val isCurrentPodcast = currentEpisode?.podcastOrSubstituteUuid == podcast.uuid
if (!isCurrentPodcast) return
launch {
viewModelScope.launch(ioDispatcher) {
val override = selectedTab == PlaybackEffectsSettingsTab.ThisPodcast
podcastManager.updateOverrideGlobalEffects(podcast, override)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package au.com.shiftyjelly.pocketcasts.player.viewmodel

import android.content.Context
import android.content.res.Resources
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.asFlow
import app.cash.turbine.test
import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTracker
import au.com.shiftyjelly.pocketcasts.analytics.EpisodeAnalytics
import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode
import au.com.shiftyjelly.pocketcasts.models.entity.Podcast
import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode
import au.com.shiftyjelly.pocketcasts.models.entity.UserEpisode
import au.com.shiftyjelly.pocketcasts.models.to.PlaybackEffects
import au.com.shiftyjelly.pocketcasts.player.viewmodel.PlayerViewModel.PlaybackEffectsSettingsTab
import au.com.shiftyjelly.pocketcasts.preferences.Settings
import au.com.shiftyjelly.pocketcasts.preferences.UserSetting
import au.com.shiftyjelly.pocketcasts.preferences.model.ArtworkConfiguration
import au.com.shiftyjelly.pocketcasts.preferences.model.ShelfItem
import au.com.shiftyjelly.pocketcasts.repositories.bookmark.BookmarkManager
import au.com.shiftyjelly.pocketcasts.repositories.download.DownloadManager
import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager
import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackState
import au.com.shiftyjelly.pocketcasts.repositories.playback.SleepTimer
import au.com.shiftyjelly.pocketcasts.repositories.playback.UpNextQueue
import au.com.shiftyjelly.pocketcasts.repositories.playback.UpNextQueue.State
import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager
import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager
import au.com.shiftyjelly.pocketcasts.repositories.podcast.UserEpisodeManager
import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule
import au.com.shiftyjelly.pocketcasts.ui.theme.Theme
import com.jakewharton.rxrelay2.BehaviorRelay
import io.reactivex.Flowable
import io.reactivex.Observable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.mock
import org.mockito.junit.MockitoJUnitRunner
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

@ExperimentalCoroutinesApi
@RunWith(MockitoJUnitRunner::class)
class PlayerViewModelTest {

@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()

@get:Rule
val coroutineRule = MainCoroutineRule()

@Mock
private lateinit var episodeManager: EpisodeManager

@Mock
private lateinit var settings: Settings

@Mock
private lateinit var playbackManager: PlaybackManager

@Mock
private lateinit var bookmarkManager: BookmarkManager

@Mock
private lateinit var downloadManager: DownloadManager

@Mock
private lateinit var podcastManager: PodcastManager

@Mock
private lateinit var userEpisodeManager: UserEpisodeManager

@Mock
private lateinit var theme: Theme

@Mock
private lateinit var sleepTimer: SleepTimer

@Mock
private lateinit var analyticsTracker: AnalyticsTracker

@Mock
private lateinit var episodeAnalytics: EpisodeAnalytics

@Mock
private lateinit var context: Context

@Mock
private lateinit var applicationScope: CoroutineScope

@Mock
private lateinit var upNextQueue: UpNextQueue

@Mock
private lateinit var globalEffects: PlaybackEffects

@Mock
private lateinit var podcastPlaybackEffects: PlaybackEffects

@Mock
private lateinit var podcastEpisode: PodcastEpisode

@Mock
private lateinit var userEpisode: UserEpisode

@Mock
private lateinit var podcast: Podcast

@Mock
private lateinit var userSettingsGlobalEffects: UserSetting<PlaybackEffects>

private val podcastUuid = "podcastUuid"
private lateinit var viewModel: PlayerViewModel

@Test
fun `does not override global effects when all podcasts effects settings segmented tab selected`() {
initViewModel()

viewModel.onEffectsSettingsSegmentedTabSelected(podcast, PlaybackEffectsSettingsTab.AllPodcasts)

verify(podcastManager).updateOverrideGlobalEffects(podcast, false)
}

@Test
fun `override global effects when this podcasts effects settings segmented tab selected`() {
initViewModel()

viewModel.onEffectsSettingsSegmentedTabSelected(podcast, PlaybackEffectsSettingsTab.ThisPodcast)

verify(podcastManager).updateOverrideGlobalEffects(podcast, true)
}

@Test
fun `effects settings segmented tab bar shown for podcast episode`() = runTest {
initViewModel()

playbackManager.playbackStateRelay.accept(PlaybackState(podcast = podcast))

viewModel.effectsLive.asFlow().test {
assertTrue(awaitItem().showCustomEffectsSettings)
}
}

@Test
fun `effects settings segmented tab bar hidden for user episode`() = runTest {
initViewModel(currentEpisode = userEpisode)

playbackManager.playbackStateRelay.accept(PlaybackState(podcast = Podcast.userPodcast))

viewModel.effectsLive.asFlow().test {
assertFalse(awaitItem().showCustomEffectsSettings)
}
}

private fun initViewModel(
currentEpisode: BaseEpisode = podcastEpisode,
) {
whenever(playbackManager.playbackStateRelay).thenReturn(BehaviorRelay.create<PlaybackState>().toSerialized())
whenever(playbackManager.upNextQueue).thenReturn(upNextQueue)
whenever(upNextQueue.getChangesObservableWithLiveCurrentEpisode(episodeManager, podcastManager)).thenReturn(Observable.just(State.Empty))
val userSettingsIntMock = mock<UserSetting<Int>>()
whenever(userSettingsIntMock.flow).thenReturn(MutableStateFlow(0))
val userSettingsArtworkConfigurationMock = mock<UserSetting<ArtworkConfiguration>>()
whenever(userSettingsArtworkConfigurationMock.flow).thenReturn(MutableStateFlow(ArtworkConfiguration(false, emptySet())))
whenever(settings.skipBackInSecs).thenReturn(userSettingsIntMock)
whenever(settings.skipForwardInSecs).thenReturn(userSettingsIntMock)
whenever(settings.artworkConfiguration).thenReturn(userSettingsArtworkConfigurationMock)
val userSettingsShelfItemsMock = mock<UserSetting<List<ShelfItem>>>()
whenever(userSettingsShelfItemsMock.flow).thenReturn(MutableStateFlow(emptyList()))
whenever(settings.shelfItems).thenReturn(userSettingsShelfItemsMock)
val resourcesMock = mock<Resources>()
whenever(resourcesMock.getString(anyOrNull(), anyOrNull())).thenReturn("")
whenever(context.resources).thenReturn(resourcesMock)
whenever(podcast.uuid).thenReturn(podcastUuid)
whenever(userSettingsGlobalEffects.value).thenReturn(globalEffects)
whenever(settings.globalPlaybackEffects).thenReturn(userSettingsGlobalEffects)
whenever(podcastEpisode.podcastOrSubstituteUuid).thenReturn(podcastUuid)
whenever(playbackManager.getCurrentEpisode()).thenReturn(currentEpisode)
whenever(episodeManager.observeEpisodeByUuidRx(anyOrNull())).thenReturn(Flowable.just(currentEpisode))
whenever(podcast.playbackEffects).thenReturn(podcastPlaybackEffects)
whenever(podcastManager.observePodcastByUuid(anyOrNull())).thenReturn(Flowable.just(podcast))
val useRealTimeForPlaybackRemainingTimeMock = mock<UserSetting<Boolean>>()
whenever(useRealTimeForPlaybackRemainingTimeMock.flow).thenReturn(MutableStateFlow(false))
whenever(settings.useRealTimeForPlaybackRemaingTime).thenReturn(useRealTimeForPlaybackRemainingTimeMock)

viewModel = PlayerViewModel(
playbackManager = playbackManager,
episodeManager = episodeManager,
userEpisodeManager = userEpisodeManager,
podcastManager = podcastManager,
bookmarkManager = bookmarkManager,
downloadManager = downloadManager,
sleepTimer = sleepTimer,
settings = settings,
theme = theme,
analyticsTracker = analyticsTracker,
episodeAnalytics = episodeAnalytics,
context = context,
applicationScope = applicationScope,
ioDispatcher = UnconfinedTestDispatcher(),
)
}
}