diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt index 011c131110fb..2efc217ba05b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt @@ -2311,6 +2311,11 @@ abstract class AbstractFlashcardViewer : cardMediaPlayer.onVideoFinished() return true } + if (url.startsWith("videopause:")) { + // note: 'q:0' is provided + cardMediaPlayer.onVideoPaused() + return true + } if (url.startsWith("state-mutation-error:")) { onStateMutationError() return true diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt index c9e85d74fdb4..776aef4ddaa5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt @@ -90,6 +90,7 @@ import java.io.File */ @NeedsTest("Integration test: A video is autoplayed if it's the first media on a card") @NeedsTest("A sound is played after a video finishes") +@NeedsTest("Pausing a video calls onSoundGroupCompleted") class CardMediaPlayer : Closeable { private val soundTagPlayer: SoundTagPlayer @@ -331,10 +332,17 @@ class CardMediaPlayer : Closeable { } } + @NeedsTest("finish moves to next sound") fun onVideoFinished() { soundTagPlayer.videoPlayer.onVideoFinished() } + @NeedsTest("pause starts automatic answer") + fun onVideoPaused() { + Timber.i("video paused") + soundTagPlayer.videoPlayer.onVideoPaused() + } + companion object { const val TTS_PLAYER_TIMEOUT_MS = 2_500L diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/SoundTagPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/SoundTagPlayer.kt index 1d70f62b30ee..c363305a5008 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/SoundTagPlayer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/SoundTagPlayer.kt @@ -21,6 +21,7 @@ import android.media.AudioAttributes import android.media.AudioManager import android.media.MediaPlayer import android.net.Uri +import androidx.annotation.VisibleForTesting import androidx.media.AudioFocusRequestCompat import androidx.media.AudioManagerCompat import com.ichi2.anki.AnkiDroidApp @@ -69,14 +70,20 @@ class SoundTagPlayer(private val soundUriBase: String, val videoPlayer: VideoPla Timber.d("Playing SoundOrVideoTag") when (tagType) { SoundOrVideoTag.Type.AUDIO -> playSound(continuation, tag, soundErrorListener) - SoundOrVideoTag.Type.VIDEO -> { - Timber.d("Playing video") - videoPlayer.playVideo(continuation, tag) - } + SoundOrVideoTag.Type.VIDEO -> playVideo(continuation, tag) } } } + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun playVideo( + continuation: CancellableContinuation, + tag: SoundOrVideoTag + ) { + Timber.d("Playing video") + videoPlayer.playVideo(continuation, tag) + } + private fun playSound( continuation: CancellableContinuation, tag: SoundOrVideoTag, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/VideoPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/VideoPlayer.kt index 658dcb0e9a7f..d6915a69cd69 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/VideoPlayer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/VideoPlayer.kt @@ -22,6 +22,7 @@ import com.ichi2.libanki.SoundOrVideoTag import kotlinx.coroutines.CancellableContinuation import timber.log.Timber import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * Interacts with ` """.trimMargin() return result @@ -154,7 +156,7 @@ object Sound { is SoundOrVideoTag -> { when (tag.getType(mediaDir)) { SoundOrVideoTag.Type.AUDIO -> asAudio() - SoundOrVideoTag.Type.VIDEO -> asVideo() + SoundOrVideoTag.Type.VIDEO -> asVideo(tag) } } else -> throw IllegalStateException("unrecognised tag") diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/VideoPlayerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/VideoPlayerTest.kt new file mode 100644 index 000000000000..b23a87d10ec6 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/VideoPlayerTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2024 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.cardviewer + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.RobolectricTest +import com.ichi2.libanki.SoundOrVideoTag +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CompletionHandler +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.coroutines.CoroutineContext +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(AndroidJUnit4::class) +class VideoPlayerTest : RobolectricTest() { + + @Test + fun `stops audio playback when paused`() { + val v = VideoPlayer { JavascriptEvaluator { } } + + val m = MockContinuation() + v.playVideo(m, SoundOrVideoTag("a.mp4")) + + assertNull(m.result) + v.onVideoPaused() + + val result = assertNotNull(m.result) + assertThat("failure", result.isFailure) + val exception = result.exceptionOrNull() as? SoundException + assertThat("Audio is stopped", exception != null && exception.continuationBehavior == SoundErrorBehavior.STOP_AUDIO) + } + + // TODO: use a mock - couldn't get mockk working here + class MockContinuation : CancellableContinuation { + var result: Result? = null + + override val context: CoroutineContext + get() = TODO("Not yet implemented") + override val isActive: Boolean + get() = TODO("Not yet implemented") + override val isCancelled: Boolean + get() = TODO("Not yet implemented") + override val isCompleted: Boolean + get() = TODO("Not yet implemented") + + override fun cancel(cause: Throwable?): Boolean { + TODO("Not yet implemented") + } + + @InternalCoroutinesApi + override fun completeResume(token: Any) { + TODO("Not yet implemented") + } + + @InternalCoroutinesApi + override fun initCancellability() { + TODO("Not yet implemented") + } + + override fun invokeOnCancellation(handler: CompletionHandler) { + TODO("Not yet implemented") + } + + @InternalCoroutinesApi + override fun tryResumeWithException(exception: Throwable): Any? { + TODO("Not yet implemented") + } + + @ExperimentalCoroutinesApi + override fun CoroutineDispatcher.resumeUndispatchedWithException(exception: Throwable) { + TODO("Not yet implemented") + } + + @ExperimentalCoroutinesApi + override fun CoroutineDispatcher.resumeUndispatched(value: Unit) { + TODO("Not yet implemented") + } + + @InternalCoroutinesApi + override fun tryResume( + value: Unit, + idempotent: Any?, + onCancellation: ((cause: Throwable) -> Unit)? + ): Any? { + TODO("Not yet implemented") + } + + @InternalCoroutinesApi + override fun tryResume(value: Unit, idempotent: Any?): Any? { + TODO("Not yet implemented") + } + + @ExperimentalCoroutinesApi + override fun resume(value: Unit, onCancellation: ((cause: Throwable) -> Unit)?) { + TODO("Not yet implemented") + } + + override fun resumeWith(result: Result) { + this.result = result + } + } +}