Skip to content

Commit

Permalink
feat: handle video pausing
Browse files Browse the repository at this point in the history
This is required in the previewer to stop a coroutine from blocking

Issue 14693: Video AutoPlay
  • Loading branch information
david-allison authored and mikehardy committed Apr 19, 2024
1 parent ac58b92 commit ed49388
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Unit>,
tag: SoundOrVideoTag
) {
Timber.d("Playing video")
videoPlayer.playVideo(continuation, tag)
}

private fun playSound(
continuation: CancellableContinuation<Unit>,
tag: SoundOrVideoTag,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<video>` tags, triggering the start and detecting completion of videos
Expand Down Expand Up @@ -69,4 +70,10 @@ class VideoPlayer(private val jsEval: () -> JavascriptEvaluator?) {
continuation?.resume(Unit)
continuation = null
}

fun onVideoPaused() {
Timber.i("video paused")
continuation?.resumeWithException(SoundException(SoundErrorBehavior.STOP_AUDIO))
continuation = null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ abstract class CardViewerFragment(@LayoutRes layout: Int) : Fragment(layout) {
viewModel.onVideoFinished()
return true
}
if (urlString.startsWith("videopause:")) {
viewModel.onVideoPaused()
return true
}
if (urlString.startsWith("tts-voices:")) {
TtsVoicesDialogFragment().show(childFragmentManager, null)
return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ abstract class CardViewerViewModel(

fun onVideoFinished() = cardMediaPlayer.onVideoFinished()

// A coroutine in the cardMediaPlayer waits for the video to complete
// This cancels it
fun onVideoPaused() = cardMediaPlayer.onVideoPaused()

/* *********************************************************************************************
*************************************** Internal methods ***************************************
********************************************************************************************* */
Expand Down
4 changes: 3 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/libanki/Sound.kt
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ object Sound {
val playsound = "${playTag.side}:${playTag.index}"

val onEnded = """window.location.href = "videoended:$playsound";"""
val onPause = """if (this.currentTime != this.duration) { window.location.href = "videopause:$playsound"; }"""

// TODO: Make the loading screen nicer if the video doesn't autoplay
@Language("HTML")
Expand All @@ -144,6 +145,7 @@ object Sound {
| controls
| data-file="${TextUtils.htmlEncode(tag.filename)}"
| onended='$onEnded'
| onpause='$onPause'
| data-play="$playsound" controlsList="nodownload"></video>
""".trimMargin()
return result
Expand All @@ -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")
Expand Down
122 changes: 122 additions & 0 deletions AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/VideoPlayerTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2024 David Allison <[email protected]>
*
* 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 <http://www.gnu.org/licenses/>.
*/

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<Unit> {
var result: Result<Unit>? = 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<Unit>) {
this.result = result
}
}
}

0 comments on commit ed49388

Please sign in to comment.