diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt index b8d584d6cd4f..e359a884ab33 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt @@ -56,13 +56,16 @@ abstract class CardViewerViewModel( val showingAnswer = savedStateHandle.getMutableStateFlow(KEY_SHOWING_ANSWER, false) - protected val cardMediaPlayer = + protected abstract val cardMediaPlayer: CardMediaPlayer + + protected fun createCardMediaPlayer(): CardMediaPlayer = CardMediaPlayer( javascriptEvaluator = { launchCatchingIO { eval.emit(it) } }, mediaErrorListener = mediaErrorHandler, ).also { addCloseable(it) } + abstract var currentCard: Deferred abstract val server: AnkiServer diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt index 8dee1b2e5bd5..3e1ec30b70cb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt @@ -23,6 +23,7 @@ import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.Flag import com.ichi2.anki.asyncIO import com.ichi2.anki.browser.IdsFile +import com.ichi2.anki.cardviewer.CardMediaPlayer import com.ichi2.anki.cardviewer.SingleCardSide import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.launchCatchingIO @@ -43,12 +44,14 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import timber.log.Timber -class PreviewerViewModel( +open class PreviewerViewModel( savedStateHandle: SavedStateHandle, ) : CardViewerViewModel(savedStateHandle), ChangeManager.Subscriber { + override val cardMediaPlayer by lazy { createCardMediaPlayer() } val currentIndex = savedStateHandle.getMutableStateFlow( KEY_CURRENT_INDEX, @@ -246,6 +249,16 @@ class PreviewerViewModel( TypeAnswer.removeTags(text) } + override suspend fun showQuestion() { + cardMediaPlayer.stop() + super.showQuestion() + } + + override suspend fun showAnswer() { + cardMediaPlayer.stop() + super.showAnswer() + } + override fun opExecuted( changes: OpChanges, handler: Any?, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TemplatePreviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TemplatePreviewerViewModel.kt index 757d547fa733..f2019b810237 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TemplatePreviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TemplatePreviewerViewModel.kt @@ -45,6 +45,7 @@ import org.jetbrains.annotations.VisibleForTesting class TemplatePreviewerViewModel( savedStateHandle: SavedStateHandle, ) : CardViewerViewModel(savedStateHandle) { + override val cardMediaPlayer by lazy { createCardMediaPlayer() } private val notetype: NotetypeJson private val fillEmpty: Boolean private val isCloze: Boolean diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt index b739b72b86a3..9911c664afbd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt @@ -84,6 +84,7 @@ class ReviewerViewModel( ChangeManager.Subscriber, BindingProcessor, AutoAdvance.ActionListener { + override val cardMediaPlayer by lazy { createCardMediaPlayer() } private var queueState: Deferred = asyncIO { withCol { sched.currentQueueState() } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt index a7465f90e8c1..6c70d2b0e5f6 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt @@ -19,18 +19,22 @@ import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.Flag import com.ichi2.anki.browser.IdsFile +import com.ichi2.anki.cardviewer.CardMediaPlayer import com.ichi2.anki.servicelayer.NoteService import com.ichi2.anki.utils.ext.flag import com.ichi2.testutils.JvmTest import com.ichi2.testutils.common.Flaky import com.ichi2.testutils.common.OS import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.spyk +import io.mockk.verify import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -42,7 +46,13 @@ import org.junit.runner.RunWith class PreviewerViewModelTest : JvmTest() { private val idsFile: IdsFile = mockk() + private class TestPreviewerViewModel( + savedStateHandle: SavedStateHandle, + override val cardMediaPlayer: CardMediaPlayer, + ) : PreviewerViewModel(savedStateHandle) + private lateinit var viewModel: PreviewerViewModel + private lateinit var mockMediaPlayer: CardMediaPlayer private fun TestScope.onNextButtonClick() { viewModel.onNextButtonClick() @@ -79,9 +89,9 @@ class PreviewerViewModelTest : JvmTest() { set(PreviewerFragment.CARD_IDS_FILE_ARG, idsFile) } - viewModel = spyk(PreviewerViewModel(savedStateHandle)) - // the default implementation requires the Collection media directory, - // which needs Robolectric with CollectionStorageMode.IN_MEMORY_WITH_MEDIA or ON_DISK + mockMediaPlayer = mockk(relaxed = true) + viewModel = spyk(TestPreviewerViewModel(savedStateHandle, mockMediaPlayer)) + coEvery { viewModel.prepareCardTextForDisplay(any()) } answers { firstArg() } } @@ -270,4 +280,26 @@ class PreviewerViewModelTest : JvmTest() { onSliderChange(sliderPosition = 2) assertEquals("Index should update for valid input", 1, viewModel.currentIndex.value) } + + @Test + fun `audio stops when changing sides`() = + runTest { + viewModel.onPageFinished(false) + viewModel.showingAnswer.value = false + + // 1. Next Button (Question -> Answer) + onNextButtonClick() + coVerify(atLeast = 1) { mockMediaPlayer.stop() } + + // Reset mocks + io.mockk.clearMocks(mockMediaPlayer, answers = false, recordedCalls = true, childMocks = false) + + onPreviousButtonClick() + coVerify(atLeast = 1) { mockMediaPlayer.stop() } + + io.mockk.clearMocks(mockMediaPlayer, answers = false, recordedCalls = true, childMocks = false) + + toggleBackSideOnly() + coVerify(atLeast = 1) { mockMediaPlayer.stop() } + } }