Merge remote-tracking branch 'origin/fm/tv-round2' into fm/polish-lane-e
This commit is contained in:
commit
45b49a960f
17 changed files with 1007 additions and 60 deletions
|
|
@ -479,6 +479,8 @@ dependencies {
|
||||||
testImplementation("com.squareup.okhttp3:mockwebserver3")
|
testImplementation("com.squareup.okhttp3:mockwebserver3")
|
||||||
testImplementation(libs.androidx.core.testing)
|
testImplementation(libs.androidx.core.testing)
|
||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
|
testImplementation(libs.androidx.media3.test.utils)
|
||||||
|
testImplementation(libs.androidx.media3.test.utils.robolectric)
|
||||||
testImplementation(libs.hilt.android.testing)
|
testImplementation(libs.hilt.android.testing)
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0")
|
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0")
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import timber.log.Timber
|
||||||
import java.lang.reflect.Constructor
|
import java.lang.reflect.Constructor
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a [Player] instance for video playback
|
* Constructs a [Player] instance for video playback
|
||||||
|
|
@ -174,7 +175,7 @@ class PlayerFactory
|
||||||
ExoPlayer
|
ExoPlayer
|
||||||
.Builder(context)
|
.Builder(context)
|
||||||
.setMediaSourceFactory(mediaSourceFactory)
|
.setMediaSourceFactory(mediaSourceFactory)
|
||||||
.setRenderersFactory(renderersFactory)
|
.setRenderersFactory(SubtitleDelayRenderersFactory(renderersFactory))
|
||||||
.setTrackSelector(trackSelector)
|
.setTrackSelector(trackSelector)
|
||||||
.build()
|
.build()
|
||||||
.apply {
|
.apply {
|
||||||
|
|
@ -303,6 +304,29 @@ val Player.isReleased: Boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delays the subtitles of this player by [delay]; a positive delay shows them later
|
||||||
|
*/
|
||||||
|
fun Player.applySubtitleDelay(delay: Duration) {
|
||||||
|
when (this) {
|
||||||
|
is MpvPlayer -> {
|
||||||
|
subtitleDelay = delay
|
||||||
|
}
|
||||||
|
|
||||||
|
is ExoPlayer -> {
|
||||||
|
for (index in 0 until rendererCount) {
|
||||||
|
val renderer = getRenderer(index)
|
||||||
|
if (renderer is SubtitleDelayRenderer) {
|
||||||
|
createMessage(renderer)
|
||||||
|
.setType(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY)
|
||||||
|
.setPayload(delay.inWholeMicroseconds)
|
||||||
|
.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class PlayerCreation(
|
data class PlayerCreation(
|
||||||
val player: Player,
|
val player: Player,
|
||||||
val assHandler: AssHandler? = null,
|
val assHandler: AssHandler? = null,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
@file:OptIn(markerClass = [UnstableApi::class])
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ForwardingRenderer
|
||||||
|
import androidx.media3.exoplayer.Renderer
|
||||||
|
import androidx.media3.exoplayer.RendererConfiguration
|
||||||
|
import androidx.media3.exoplayer.RenderersFactory
|
||||||
|
import androidx.media3.exoplayer.audio.AudioRendererEventListener
|
||||||
|
import androidx.media3.exoplayer.metadata.MetadataOutput
|
||||||
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
|
import androidx.media3.exoplayer.source.SampleStream
|
||||||
|
import androidx.media3.exoplayer.text.TextOutput
|
||||||
|
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||||
|
import io.github.peerless2012.ass.media.render.AssRenderer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delays the subtitles of the wrapped renderer by shifting the playback position it is given.
|
||||||
|
*
|
||||||
|
* A positive delay shows subtitles later, a negative delay earlier, the same as mpv's `sub-delay`.
|
||||||
|
* The delay is set on the playback thread with a [MSG_SET_SUBTITLE_DELAY] message whose payload is
|
||||||
|
* the delay in microseconds as a [Long].
|
||||||
|
*
|
||||||
|
* Wrapping hides the renderer's type from ExoPlayer's `instanceof TextRenderer` checks, which only
|
||||||
|
* matter for clipped streams and server-side inserted ads.
|
||||||
|
*/
|
||||||
|
class SubtitleDelayRenderer(
|
||||||
|
internal val delegate: Renderer,
|
||||||
|
) : ForwardingRenderer(delegate) {
|
||||||
|
private val period = Timeline.Period()
|
||||||
|
private var delayUs = 0L
|
||||||
|
private var timeline: Timeline = Timeline.EMPTY
|
||||||
|
private var mediaPeriodId: MediaSource.MediaPeriodId? = null
|
||||||
|
private var streamOffsetUs = 0L
|
||||||
|
private var periodEndPositionUs = C.TIME_UNSET
|
||||||
|
private var lastPositionUs = C.TIME_UNSET
|
||||||
|
|
||||||
|
override fun handleMessage(
|
||||||
|
messageType: Int,
|
||||||
|
message: Any?,
|
||||||
|
) {
|
||||||
|
if (messageType == MSG_SET_SUBTITLE_DELAY) {
|
||||||
|
delayUs = message as Long
|
||||||
|
} else {
|
||||||
|
super.handleMessage(messageType, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun enable(
|
||||||
|
configuration: RendererConfiguration,
|
||||||
|
formats: Array<out Format>,
|
||||||
|
stream: SampleStream,
|
||||||
|
positionUs: Long,
|
||||||
|
joining: Boolean,
|
||||||
|
mayRenderStartOfStream: Boolean,
|
||||||
|
startPositionUs: Long,
|
||||||
|
offsetUs: Long,
|
||||||
|
mediaPeriodId: MediaSource.MediaPeriodId,
|
||||||
|
) {
|
||||||
|
lastPositionUs = positionUs
|
||||||
|
updateStream(offsetUs, mediaPeriodId)
|
||||||
|
super.enable(
|
||||||
|
configuration,
|
||||||
|
formats,
|
||||||
|
stream,
|
||||||
|
positionUs - delayUs,
|
||||||
|
joining,
|
||||||
|
mayRenderStartOfStream,
|
||||||
|
startPositionUs,
|
||||||
|
offsetUs,
|
||||||
|
mediaPeriodId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun replaceStream(
|
||||||
|
formats: Array<out Format>,
|
||||||
|
stream: SampleStream,
|
||||||
|
startPositionUs: Long,
|
||||||
|
offsetUs: Long,
|
||||||
|
mediaPeriodId: MediaSource.MediaPeriodId,
|
||||||
|
) {
|
||||||
|
updateStream(offsetUs, mediaPeriodId)
|
||||||
|
super.replaceStream(formats, stream, startPositionUs, offsetUs, mediaPeriodId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setTimeline(timeline: Timeline) {
|
||||||
|
this.timeline = timeline
|
||||||
|
updatePeriodEndPosition()
|
||||||
|
super.setTimeline(timeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun render(
|
||||||
|
positionUs: Long,
|
||||||
|
elapsedRealtimeUs: Long,
|
||||||
|
) {
|
||||||
|
lastPositionUs = positionUs
|
||||||
|
super.render(positionUs - delayUs, elapsedRealtimeUs)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resetPosition(
|
||||||
|
positionUs: Long,
|
||||||
|
sampleStreamIsResetToKeyFrame: Boolean,
|
||||||
|
) {
|
||||||
|
lastPositionUs = positionUs
|
||||||
|
super.resetPosition(positionUs - delayUs, sampleStreamIsResetToKeyFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun supportsResetPositionWithoutKeyFrameReset(positionUs: Long): Boolean =
|
||||||
|
super.supportsResetPositionWithoutKeyFrameReset(positionUs - delayUs)
|
||||||
|
|
||||||
|
override fun getDurationToProgressUs(
|
||||||
|
positionUs: Long,
|
||||||
|
elapsedRealtimeUs: Long,
|
||||||
|
): Long = super.getDurationToProgressUs(positionUs - delayUs, elapsedRealtimeUs)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text renderer only reads the end of its stream once its position is within a second of the
|
||||||
|
* period end, so a positive delay above a second would hold back
|
||||||
|
* [androidx.media3.common.Player.STATE_ENDED] by the delay minus that second. Once the real
|
||||||
|
* position passes the period end there is nothing left to show, so the renderer counts as ended.
|
||||||
|
*/
|
||||||
|
override fun isEnded(): Boolean =
|
||||||
|
super.isEnded() ||
|
||||||
|
(
|
||||||
|
delayUs > 0 &&
|
||||||
|
periodEndPositionUs != C.TIME_UNSET &&
|
||||||
|
lastPositionUs >= periodEndPositionUs
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun disable() {
|
||||||
|
mediaPeriodId = null
|
||||||
|
periodEndPositionUs = C.TIME_UNSET
|
||||||
|
super.disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateStream(
|
||||||
|
offsetUs: Long,
|
||||||
|
mediaPeriodId: MediaSource.MediaPeriodId,
|
||||||
|
) {
|
||||||
|
streamOffsetUs = offsetUs
|
||||||
|
this.mediaPeriodId = mediaPeriodId
|
||||||
|
updatePeriodEndPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePeriodEndPosition() {
|
||||||
|
val periodIndex = mediaPeriodId?.let { timeline.getIndexOfPeriod(it.periodUid) } ?: C.INDEX_UNSET
|
||||||
|
val durationUs =
|
||||||
|
if (periodIndex == C.INDEX_UNSET) {
|
||||||
|
C.TIME_UNSET
|
||||||
|
} else {
|
||||||
|
timeline.getPeriod(periodIndex, period).durationUs
|
||||||
|
}
|
||||||
|
periodEndPositionUs = if (durationUs == C.TIME_UNSET) C.TIME_UNSET else streamOffsetUs + durationUs
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Sets the subtitle delay; the payload is the delay in microseconds as a [Long]. */
|
||||||
|
const val MSG_SET_SUBTITLE_DELAY = Renderer.MSG_CUSTOM_BASE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the subtitle renderers of [delegate] in a [SubtitleDelayRenderer]: media3's text renderer
|
||||||
|
* and the libass [AssRenderer], which draws at the position it is rendered with.
|
||||||
|
*/
|
||||||
|
class SubtitleDelayRenderersFactory(
|
||||||
|
private val delegate: RenderersFactory,
|
||||||
|
) : RenderersFactory {
|
||||||
|
override fun createRenderers(
|
||||||
|
eventHandler: Handler,
|
||||||
|
videoRendererEventListener: VideoRendererEventListener,
|
||||||
|
audioRendererEventListener: AudioRendererEventListener,
|
||||||
|
textRendererOutput: TextOutput,
|
||||||
|
metadataRendererOutput: MetadataOutput,
|
||||||
|
): Array<Renderer> {
|
||||||
|
val renderers =
|
||||||
|
delegate.createRenderers(
|
||||||
|
eventHandler,
|
||||||
|
videoRendererEventListener,
|
||||||
|
audioRendererEventListener,
|
||||||
|
textRendererOutput,
|
||||||
|
metadataRendererOutput,
|
||||||
|
)
|
||||||
|
return Array(renderers.size) { index ->
|
||||||
|
val renderer = renderers[index]
|
||||||
|
if (renderer.trackType == C.TRACK_TYPE_TEXT || renderer is AssRenderer) {
|
||||||
|
SubtitleDelayRenderer(renderer)
|
||||||
|
} else {
|
||||||
|
renderer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createSecondaryRenderer(
|
||||||
|
renderer: Renderer,
|
||||||
|
eventHandler: Handler,
|
||||||
|
videoRendererEventListener: VideoRendererEventListener,
|
||||||
|
audioRendererEventListener: AudioRendererEventListener,
|
||||||
|
textRendererOutput: TextOutput,
|
||||||
|
metadataRendererOutput: MetadataOutput,
|
||||||
|
): Renderer? =
|
||||||
|
delegate.createSecondaryRenderer(
|
||||||
|
(renderer as? SubtitleDelayRenderer)?.delegate ?: renderer,
|
||||||
|
eventHandler,
|
||||||
|
videoRendererEventListener,
|
||||||
|
audioRendererEventListener,
|
||||||
|
textRendererOutput,
|
||||||
|
metadataRendererOutput,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -60,7 +60,9 @@ import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.ui.SubtitleView
|
import androidx.media3.ui.SubtitleView
|
||||||
import androidx.media3.ui.compose.PlayerSurface
|
import androidx.media3.ui.compose.PlayerSurface
|
||||||
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
|
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
|
||||||
|
|
@ -75,6 +77,7 @@ import com.github.damontecres.wholphin.preferences.DpadSeekMode
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
||||||
|
import com.github.damontecres.wholphin.services.applySubtitleDelay
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
|
|
@ -99,6 +102,8 @@ import com.github.damontecres.wholphin.util.Media3SubtitleOverride
|
||||||
import io.github.peerless2012.ass.media.widget.AssSubtitleView
|
import io.github.peerless2012.ass.media.widget.AssSubtitleView
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
@ -185,9 +190,8 @@ fun PlaybackPageContent(
|
||||||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||||
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
||||||
|
|
||||||
LaunchedEffect(state.currentPlayback?.subtitleDelay) {
|
LaunchedEffect(player, state.currentPlayback?.subtitleDelay) {
|
||||||
(player as? MpvPlayer)?.subtitleDelay =
|
player.applySubtitleDelay(state.currentPlayback?.subtitleDelay ?: Duration.ZERO)
|
||||||
state.currentPlayback?.subtitleDelay ?: Duration.ZERO
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val presentationState = rememberPresentationState(player, false)
|
val presentationState = rememberPresentationState(player, false)
|
||||||
|
|
@ -784,8 +788,34 @@ fun PlaybackPageContent(
|
||||||
},
|
},
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
|
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
|
||||||
enableSubtitleDelay = player is MpvPlayer,
|
enableSubtitleDelay = subtitleDelaySupported(player, state.currentPlayback),
|
||||||
enableVideoScale = player !is MpvPlayer,
|
enableVideoScale = player !is MpvPlayer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the subtitle delay can be changed: always with MPV, and with ExoPlayer unless the server
|
||||||
|
* burns the selected subtitles into the video
|
||||||
|
*/
|
||||||
|
fun subtitleDelaySupported(
|
||||||
|
player: Player,
|
||||||
|
currentPlayback: CurrentPlayback?,
|
||||||
|
): Boolean =
|
||||||
|
when (player) {
|
||||||
|
is MpvPlayer -> {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
is ExoPlayer -> {
|
||||||
|
val subtitleStream =
|
||||||
|
currentPlayback?.mediaSourceInfo?.mediaStreams?.firstOrNull {
|
||||||
|
it.type == MediaStreamType.SUBTITLE && it.index == currentPlayback.subtitleIndex
|
||||||
|
}
|
||||||
|
subtitleStream?.deliveryMethod != SubtitleDeliveryMethod.ENCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1669,6 +1669,9 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the enabled subtitle track's saved delay, or zero if no delay is saved.
|
||||||
|
*/
|
||||||
suspend fun loadSubtitleDelay() {
|
suspend fun loadSubtitleDelay() {
|
||||||
state.value.currentPlayback?.let {
|
state.value.currentPlayback?.let {
|
||||||
if (it.subtitleIndexEnabled) {
|
if (it.subtitleIndexEnabled) {
|
||||||
|
|
@ -1681,7 +1684,9 @@ class PlaybackViewModel
|
||||||
it.subtitleIndex,
|
it.subtitleIndex,
|
||||||
it.item.id,
|
it.item.id,
|
||||||
)
|
)
|
||||||
updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) }
|
}
|
||||||
|
updateCurrentPlayback {
|
||||||
|
it?.copy(subtitleDelay = result?.delayMs?.milliseconds ?: Duration.ZERO)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
@file:OptIn(markerClass = [UnstableApi::class])
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.text.CueGroup
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.test.utils.FakeClock
|
||||||
|
import androidx.media3.test.utils.robolectric.CapturingRenderersFactory
|
||||||
|
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.play
|
||||||
|
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.run
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays a short audio file with a sideloaded SRT through a real [ExoPlayer] and checks when the
|
||||||
|
* subtitles appear for a given delay, and that playback still ends when the media does.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(manifest = Config.NONE, sdk = [30])
|
||||||
|
class SubtitleDelayPlaybackTest {
|
||||||
|
@get:Rule val folder = TemporaryFolder()
|
||||||
|
|
||||||
|
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
private val clock = FakeClock(true)
|
||||||
|
private val shownCues = mutableListOf<Pair<String, Long>>()
|
||||||
|
private val playbackStates = mutableListOf<Int>()
|
||||||
|
private lateinit var player: ExoPlayer
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
player =
|
||||||
|
ExoPlayer
|
||||||
|
.Builder(context, SubtitleDelayRenderersFactory(CapturingRenderersFactory(context, clock)))
|
||||||
|
.setMediaSourceFactory(DefaultMediaSourceFactory(context))
|
||||||
|
.setClock(clock)
|
||||||
|
.build()
|
||||||
|
player.addListener(
|
||||||
|
object : Player.Listener {
|
||||||
|
override fun onCues(cueGroup: CueGroup) {
|
||||||
|
cueGroup.cues.firstOrNull()?.let { shownCues.add(it.text.toString() to player.currentPosition) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
playbackStates.add(playbackState)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
player.setMediaItem(mediaItem())
|
||||||
|
player.prepare()
|
||||||
|
// Subtitles never hold up playback, so let the sideloaded file load before playing
|
||||||
|
run(player).untilState(Player.STATE_READY)
|
||||||
|
run(player).untilLoadingIs(false)
|
||||||
|
assertTrue(player.currentTracks.isTypeSelected(C.TRACK_TYPE_TEXT))
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
player.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun positiveDelayShowsSubtitlesLaterAndPlaybackEndsOnTime() {
|
||||||
|
player.applySubtitleDelay(2.seconds)
|
||||||
|
val playStartMs = clock.elapsedRealtime()
|
||||||
|
|
||||||
|
play(player).untilState(Player.STATE_ENDED)
|
||||||
|
|
||||||
|
assertShownAt("First" to 3.seconds)
|
||||||
|
val playedMs = clock.elapsedRealtime() - playStartMs
|
||||||
|
assertTrue(
|
||||||
|
"ended after $playedMs ms of playback, expected $DURATION",
|
||||||
|
abs(playedMs - DURATION.inWholeMilliseconds) <= TOLERANCE_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negativeDelayShowsSubtitlesEarlier() {
|
||||||
|
player.applySubtitleDelay((-500).milliseconds)
|
||||||
|
|
||||||
|
play(player).untilState(Player.STATE_ENDED)
|
||||||
|
|
||||||
|
assertShownAt("First" to 500.milliseconds, "Last" to 2.seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun changingTheDelayWhilePlayingRetimesWithoutRebuffering() {
|
||||||
|
play(player).untilPosition(0, 500)
|
||||||
|
player.applySubtitleDelay(1.seconds)
|
||||||
|
|
||||||
|
play(player).untilState(Player.STATE_ENDED)
|
||||||
|
|
||||||
|
assertShownAt("First" to 2.seconds, "Last" to 3_500.milliseconds)
|
||||||
|
assertEquals(listOf(Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED), playbackStates)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertShownAt(vararg expected: Pair<String, Duration>) {
|
||||||
|
assertEquals(expected.map { it.first }, shownCues.map { it.first })
|
||||||
|
expected.zip(shownCues).forEach { (want, got) ->
|
||||||
|
assertTrue(
|
||||||
|
"${want.first} shown at ${got.second} ms, expected ${want.second}",
|
||||||
|
abs(got.second - want.second.inWholeMilliseconds) <= TOLERANCE_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mediaItem(): MediaItem {
|
||||||
|
val audio = folder.newFile("silence.wav").apply { writeBytes(silentWav()) }
|
||||||
|
val subtitles = folder.newFile("subtitles.srt").apply { writeText(SRT) }
|
||||||
|
return MediaItem
|
||||||
|
.Builder()
|
||||||
|
.setUri(Uri.fromFile(audio))
|
||||||
|
.setSubtitleConfigurations(
|
||||||
|
listOf(
|
||||||
|
MediaItem.SubtitleConfiguration
|
||||||
|
.Builder(Uri.fromFile(subtitles))
|
||||||
|
.setMimeType(MimeTypes.APPLICATION_SUBRIP)
|
||||||
|
.setLanguage("en")
|
||||||
|
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A mono 16-bit PCM WAV file of silence lasting [DURATION] */
|
||||||
|
private fun silentWav(): ByteArray {
|
||||||
|
val dataSize = (SAMPLE_RATE * DURATION.inWholeMilliseconds / 1000 * 2).toInt()
|
||||||
|
return ByteBuffer
|
||||||
|
.allocate(44 + dataSize)
|
||||||
|
.order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
.put("RIFF".toByteArray())
|
||||||
|
.putInt(36 + dataSize)
|
||||||
|
.put("WAVEfmt ".toByteArray())
|
||||||
|
.putInt(16)
|
||||||
|
.putShort(1)
|
||||||
|
.putShort(1)
|
||||||
|
.putInt(SAMPLE_RATE)
|
||||||
|
.putInt(SAMPLE_RATE * 2)
|
||||||
|
.putShort(2)
|
||||||
|
.putShort(16)
|
||||||
|
.put("data".toByteArray())
|
||||||
|
.putInt(dataSize)
|
||||||
|
.array()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SAMPLE_RATE = 8_000
|
||||||
|
const val TOLERANCE_MS = 100L
|
||||||
|
val DURATION = 4.seconds
|
||||||
|
val SRT =
|
||||||
|
"""
|
||||||
|
1
|
||||||
|
00:00:01,000 --> 00:00:02,000
|
||||||
|
First
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:02,500 --> 00:00:03,500
|
||||||
|
Last
|
||||||
|
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
@file:OptIn(markerClass = [UnstableApi::class])
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.Renderer
|
||||||
|
import androidx.media3.exoplayer.RendererConfiguration
|
||||||
|
import androidx.media3.exoplayer.RenderersFactory
|
||||||
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
|
import androidx.media3.exoplayer.source.SinglePeriodTimeline
|
||||||
|
import io.github.peerless2012.ass.media.AssHandler
|
||||||
|
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
|
||||||
|
import io.github.peerless2012.ass.media.render.AssRenderer
|
||||||
|
import io.github.peerless2012.ass.media.type.AssRenderType
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(manifest = Config.NONE, sdk = [30])
|
||||||
|
class SubtitleDelayRendererTest {
|
||||||
|
private val delegate = mockk<Renderer>(relaxed = true)
|
||||||
|
private val renderer = SubtitleDelayRenderer(delegate)
|
||||||
|
private val timeline = SinglePeriodTimeline(DURATION_US, true, false, false, null, MediaItem.EMPTY)
|
||||||
|
private val mediaPeriodId = MediaSource.MediaPeriodId(timeline.getUidOfPeriod(0))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shiftsEveryPositionByTheDelay() {
|
||||||
|
for (delayUs in listOf(2_000_000L, -500_000L)) {
|
||||||
|
val delegate = mockk<Renderer>(relaxed = true)
|
||||||
|
val renderer = SubtitleDelayRenderer(delegate)
|
||||||
|
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, delayUs)
|
||||||
|
val positionUs = OFFSET_US + 5_000_000L
|
||||||
|
val shiftedUs = positionUs - delayUs
|
||||||
|
|
||||||
|
enable(renderer, positionUs)
|
||||||
|
renderer.render(positionUs, 7L)
|
||||||
|
renderer.resetPosition(positionUs, true)
|
||||||
|
renderer.getDurationToProgressUs(positionUs, 7L)
|
||||||
|
renderer.supportsResetPositionWithoutKeyFrameReset(positionUs)
|
||||||
|
|
||||||
|
verify {
|
||||||
|
delegate.enable(any(), any(), any(), shiftedUs, false, true, OFFSET_US, OFFSET_US, mediaPeriodId)
|
||||||
|
delegate.render(shiftedUs, 7L)
|
||||||
|
delegate.resetPosition(shiftedUs, true)
|
||||||
|
delegate.getDurationToProgressUs(shiftedUs, 7L)
|
||||||
|
delegate.supportsResetPositionWithoutKeyFrameReset(shiftedUs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun delayMessageChangesTheShiftAndOtherMessagesPassThrough() {
|
||||||
|
renderer.render(10_000_000L, 0L)
|
||||||
|
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 250_000L)
|
||||||
|
renderer.render(10_000_000L, 0L)
|
||||||
|
renderer.handleMessage(Renderer.MSG_SET_VOLUME, 0.5f)
|
||||||
|
|
||||||
|
verify {
|
||||||
|
delegate.render(10_000_000L, 0L)
|
||||||
|
delegate.render(9_750_000L, 0L)
|
||||||
|
delegate.handleMessage(Renderer.MSG_SET_VOLUME, 0.5f)
|
||||||
|
}
|
||||||
|
verify(exactly = 0) { delegate.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun endsAtThePeriodEndWithAPositiveDelay() {
|
||||||
|
every { delegate.isEnded } returns false
|
||||||
|
enable(renderer, OFFSET_US)
|
||||||
|
renderer.setTimeline(timeline)
|
||||||
|
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 2_000_000L)
|
||||||
|
|
||||||
|
renderer.render(OFFSET_US + DURATION_US - 1, 0L)
|
||||||
|
assertFalse(renderer.isEnded)
|
||||||
|
|
||||||
|
renderer.render(OFFSET_US + DURATION_US, 0L)
|
||||||
|
assertTrue(renderer.isEnded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun otherwiseMirrorsTheDelegateAtThePeriodEnd() {
|
||||||
|
enable(renderer, OFFSET_US)
|
||||||
|
renderer.setTimeline(timeline)
|
||||||
|
for (delayUs in listOf(0L, -2_000_000L)) {
|
||||||
|
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, delayUs)
|
||||||
|
renderer.render(OFFSET_US + DURATION_US, 0L)
|
||||||
|
for (delegateEnded in listOf(false, true)) {
|
||||||
|
every { delegate.isEnded } returns delegateEnded
|
||||||
|
assertEquals(delegateEnded, renderer.isEnded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun doesNotEndWithoutAKnownPeriodDuration() {
|
||||||
|
every { delegate.isEnded } returns false
|
||||||
|
val unknownDuration = SinglePeriodTimeline(C.TIME_UNSET, false, false, false, null, MediaItem.EMPTY)
|
||||||
|
enable(renderer, OFFSET_US, MediaSource.MediaPeriodId(unknownDuration.getUidOfPeriod(0)))
|
||||||
|
renderer.setTimeline(unknownDuration)
|
||||||
|
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 2_000_000L)
|
||||||
|
|
||||||
|
renderer.render(OFFSET_US + DURATION_US * 10, 0L)
|
||||||
|
|
||||||
|
assertFalse(renderer.isEnded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun factoryWrapsOnlySubtitleRenderers() {
|
||||||
|
val video = fakeRenderer(C.TRACK_TYPE_VIDEO)
|
||||||
|
val audio = fakeRenderer(C.TRACK_TYPE_AUDIO)
|
||||||
|
val text = fakeRenderer(C.TRACK_TYPE_TEXT)
|
||||||
|
val base =
|
||||||
|
mockk<RenderersFactory> {
|
||||||
|
every { createRenderers(any(), any(), any(), any(), any()) } returns arrayOf(video, audio, text)
|
||||||
|
}
|
||||||
|
val factory =
|
||||||
|
SubtitleDelayRenderersFactory(AssRenderersFactory(AssHandler(AssRenderType.OVERLAY_OPEN_GL), base))
|
||||||
|
|
||||||
|
val renderers =
|
||||||
|
factory.createRenderers(Handler(Looper.getMainLooper()), mockk(), mockk(), mockk(), mockk())
|
||||||
|
|
||||||
|
assertSame(video, renderers[0])
|
||||||
|
assertSame(audio, renderers[1])
|
||||||
|
assertSame(text, (renderers[2] as SubtitleDelayRenderer).delegate)
|
||||||
|
assertTrue((renderers.last() as SubtitleDelayRenderer).delegate is AssRenderer)
|
||||||
|
assertEquals(4, renderers.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enable(
|
||||||
|
renderer: Renderer,
|
||||||
|
positionUs: Long,
|
||||||
|
periodId: MediaSource.MediaPeriodId = mediaPeriodId,
|
||||||
|
) = renderer.enable(
|
||||||
|
RendererConfiguration.DEFAULT,
|
||||||
|
arrayOf(),
|
||||||
|
mockk(),
|
||||||
|
positionUs,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
OFFSET_US,
|
||||||
|
OFFSET_US,
|
||||||
|
periodId,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fakeRenderer(trackType: Int) = mockk<Renderer>(relaxed = true) { every { this@mockk.trackType } returns trackType }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val OFFSET_US = 1_000_000_000_000L
|
||||||
|
const val DURATION_US = 10_000_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,16 +3,19 @@ package com.github.damontecres.wholphin.ui.playback
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.TrackSelectionParameters
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.ItemTrackModification
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.Playlist
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
||||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
|
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
|
||||||
import com.github.damontecres.wholphin.data.model.ServerUserConfig
|
import com.github.damontecres.wholphin.data.model.ServerUserConfig
|
||||||
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
|
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
|
||||||
|
|
@ -82,6 +85,7 @@ import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
@ -292,6 +296,76 @@ class PlaybackViewModelTests {
|
||||||
return viewModel
|
return viewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Subtitle track switches load saved delays or zero and ignore disabled subtitles`() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
setupPreferences { cinemaMode = false }
|
||||||
|
val tracks =
|
||||||
|
TestTracks
|
||||||
|
.Builder()
|
||||||
|
.addVideo()
|
||||||
|
.addAudio()
|
||||||
|
.addSubtitle(2)
|
||||||
|
val source = tracks.buildMediaSourceInfo()
|
||||||
|
val subtitles = source.mediaStreams!!.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
|
val trackA = subtitles[0].index
|
||||||
|
val trackB = subtitles[1].index
|
||||||
|
coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie)
|
||||||
|
coEvery { mockMediaInfoApi.getPostedPlaybackInfo(any(), any()) } returns
|
||||||
|
successResponse(
|
||||||
|
PlaybackInfoResponse(
|
||||||
|
mediaSources = listOf(source),
|
||||||
|
playSessionId = playSessionId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns source
|
||||||
|
coEvery {
|
||||||
|
mockStreamChoiceService.chooseAudioStream(any(), any(), any(), any(), any())
|
||||||
|
} returns
|
||||||
|
StreamChoiceResult(
|
||||||
|
source.mediaStreams!!.first { it.type == MediaStreamType.AUDIO },
|
||||||
|
StreamChoiceReason.Unknown,
|
||||||
|
)
|
||||||
|
coEvery {
|
||||||
|
mockStreamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any())
|
||||||
|
} returns StreamChoiceResult(subtitles[0], StreamChoiceReason.Unknown)
|
||||||
|
coEvery {
|
||||||
|
mockStreamChoiceService.resolveSubtitleIndex(any(), any(), any(), any(), any())
|
||||||
|
} answers { arg<Int>(3) }
|
||||||
|
every { mockPlayer.currentTracks } returns tracks.buildForExoPlayer().toTracks()
|
||||||
|
every { mockPlayer.trackSelectionParameters } returns TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT
|
||||||
|
coEvery { mockItemPlaybackRepository.getTrackModifications(movie.id, trackA) } returns
|
||||||
|
ItemTrackModification(user.rowId, movie.id, trackA, 2000L)
|
||||||
|
|
||||||
|
val cases =
|
||||||
|
listOf(
|
||||||
|
Triple(trackB, null, Duration.ZERO),
|
||||||
|
Triple(trackB, -750L, (-750).milliseconds),
|
||||||
|
Triple(TrackIndex.DISABLED, null, 2.seconds),
|
||||||
|
)
|
||||||
|
cases.forEach { (targetIndex, savedDelayMs, expectedDelay) ->
|
||||||
|
coEvery { mockItemPlaybackRepository.getTrackModifications(movie.id, trackB) } returns
|
||||||
|
savedDelayMs?.let {
|
||||||
|
ItemTrackModification(user.rowId, movie.id, trackB, it)
|
||||||
|
}
|
||||||
|
val viewModel = createViewModel(Destination.Playback(movie.id, 0L))
|
||||||
|
val initialPlayback = viewModel.state.value.currentPlayback
|
||||||
|
Assert.assertEquals(trackA, initialPlayback?.subtitleIndex)
|
||||||
|
Assert.assertEquals(2.seconds, initialPlayback?.subtitleDelay)
|
||||||
|
|
||||||
|
viewModel.changeSubtitleStream(targetIndex).join()
|
||||||
|
testScheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val playback = viewModel.state.value.currentPlayback
|
||||||
|
Assert.assertEquals(targetIndex, playback?.subtitleIndex)
|
||||||
|
Assert.assertEquals(expectedDelay, playback?.subtitleDelay)
|
||||||
|
}
|
||||||
|
coVerify(exactly = 0) {
|
||||||
|
mockItemPlaybackRepository.getTrackModifications(movie.id, TrackIndex.DISABLED)
|
||||||
|
}
|
||||||
|
verify(exactly = cases.size) { mockPlayer.setMediaItem(any(), any<Long>()) }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Direct play includes the opened live stream ID`() =
|
fun `Direct play includes the opened live stream ID`() =
|
||||||
runTest(testDispatcher) {
|
runTest(testDispatcher) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performSemanticsAction
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
|
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
||||||
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import kotlin.io.path.readText
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subtitle delay control shows for every player that can move the selected subtitles, and only
|
||||||
|
* for those
|
||||||
|
*/
|
||||||
|
@Config(sdk = [34])
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class SubtitleDelayControlTest {
|
||||||
|
@get:Rule val rule = createComposeRule()
|
||||||
|
|
||||||
|
private val subtitleDelay = ApplicationProvider.getApplicationContext<Context>().getString(R.string.subtitle_delay)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun supportedUnlessTheSubtitlesAreBurnedIn() {
|
||||||
|
val exoPlayer = mockk<ExoPlayer>()
|
||||||
|
val cases =
|
||||||
|
listOf(
|
||||||
|
Triple(mockk<MpvPlayer>(), playback(EMBEDDED_INDEX), true),
|
||||||
|
Triple(exoPlayer, playback(EMBEDDED_INDEX), true),
|
||||||
|
Triple(exoPlayer, playback(EXTERNAL_INDEX), true),
|
||||||
|
Triple(exoPlayer, playback(EMBEDDED_INDEX, burnedIn = true), false),
|
||||||
|
Triple(exoPlayer, playback(TrackIndex.DISABLED, burnedIn = true), true),
|
||||||
|
Triple(mockk<Player>(), playback(EMBEDDED_INDEX), false),
|
||||||
|
)
|
||||||
|
for ((player, playback, expected) in cases) {
|
||||||
|
assertEquals(
|
||||||
|
"${player.javaClass.simpleName} ${playback.subtitleIndex}",
|
||||||
|
expected,
|
||||||
|
subtitleDelaySupported(player, playback),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsListTheSubtitleDelayOnlyWhenEnabled() {
|
||||||
|
val enabled = mutableStateOf(true)
|
||||||
|
rule.setContent { WholphinTheme { Dialog(PlaybackDialogType.SETTINGS, enabled.value) {} } }
|
||||||
|
rule.onNodeWithText(subtitleDelay).assertExists()
|
||||||
|
|
||||||
|
enabled.value = false
|
||||||
|
rule.onNodeWithText(subtitleDelay).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun delayButtonsReportTheChange() {
|
||||||
|
val changes = mutableListOf<Duration>()
|
||||||
|
rule.setContent { WholphinTheme { Dialog(PlaybackDialogType.SUBTITLE_DELAY, true) { changes.add(it) } } }
|
||||||
|
|
||||||
|
rule.onNodeWithText("+250ms").performSemanticsAction(SemanticsActions.OnClick)
|
||||||
|
|
||||||
|
assertEquals(listOf(250.milliseconds), changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Dialog(
|
||||||
|
type: PlaybackDialogType,
|
||||||
|
enableSubtitleDelay: Boolean,
|
||||||
|
onChangeSubtitleDelay: (Duration) -> Unit,
|
||||||
|
) = PlaybackDialog(
|
||||||
|
enableSubtitleDelay = enableSubtitleDelay,
|
||||||
|
enableVideoScale = true,
|
||||||
|
type = type,
|
||||||
|
settings =
|
||||||
|
PlaybackSettings(
|
||||||
|
showDebugInfo = false,
|
||||||
|
audioIndex = null,
|
||||||
|
audioStreams = listOf(),
|
||||||
|
subtitleIndex = null,
|
||||||
|
subtitleStreams = listOf(),
|
||||||
|
playbackSpeed = 1f,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
subtitleDelay = Duration.ZERO,
|
||||||
|
hasSubtitleDownloadPermission = false,
|
||||||
|
playbackSpeedEnabled = true,
|
||||||
|
maxBitrate = 0L,
|
||||||
|
),
|
||||||
|
onDismissRequest = {},
|
||||||
|
onControllerInteraction = {},
|
||||||
|
onClickPlaybackDialogType = {},
|
||||||
|
onPlaybackActionClick = {},
|
||||||
|
onChangeSubtitleDelay = onChangeSubtitleDelay,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A playback of `external_subs.json`, a PlaybackInfo response whose subtitle streams carry the
|
||||||
|
* server's delivery method, with [subtitleIndex] selected; [burnedIn] makes the server burn the
|
||||||
|
* embedded subtitles into the video
|
||||||
|
*/
|
||||||
|
private fun playback(
|
||||||
|
subtitleIndex: Int,
|
||||||
|
burnedIn: Boolean = false,
|
||||||
|
): CurrentPlayback {
|
||||||
|
val resource = javaClass.classLoader!!.getResource("external_subs.json")
|
||||||
|
var json = Paths.get(resource.toURI()).readText()
|
||||||
|
if (burnedIn) {
|
||||||
|
json = json.replace("\"DeliveryMethod\": \"Embed\"", "\"DeliveryMethod\": \"Encode\"")
|
||||||
|
}
|
||||||
|
return CurrentPlayback(
|
||||||
|
item = mockk(),
|
||||||
|
tracks = listOf(),
|
||||||
|
backend = PlayerBackend.EXO_PLAYER,
|
||||||
|
playMethod = PlayMethod.DIRECT_PLAY,
|
||||||
|
playSessionId = null,
|
||||||
|
liveStreamId = null,
|
||||||
|
mediaSourceInfo = Json.decodeFromString<MediaSourceInfo>(json),
|
||||||
|
subtitleIndex = subtitleIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val EXTERNAL_INDEX = 0
|
||||||
|
const val EMBEDDED_INDEX = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@ import android.view.WindowManager
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.foundation.layout.requiredSize
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
|
@ -48,7 +50,12 @@ class DiscoverRefreshMotionTest {
|
||||||
@Test fun loadedCatalogueRefreshKeepsReadyActionsOpaqueAndStationary() {
|
@Test fun loadedCatalogueRefreshKeepsReadyActionsOpaqueAndStationary() {
|
||||||
FakeConductarr().use { fake ->
|
FakeConductarr().use { fake ->
|
||||||
fake.localArt = true
|
fake.localArt = true
|
||||||
ConductarrSession(MemoryCacheStore()).use { session ->
|
val stored = MemoryCacheStore()
|
||||||
|
val cache = object : org.justinvisser.wolfskin.conductarr.CacheStore by stored {
|
||||||
|
@Volatile var lost = false
|
||||||
|
override fun read(scope: String, key: String) = if (lost) null else stored.read(scope, key)
|
||||||
|
}
|
||||||
|
ConductarrSession(cache).use { session ->
|
||||||
session.select("server:shared", SHARED_TOKEN, fake.url)
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
runBlocking { session.authenticate() }
|
runBlocking { session.authenticate() }
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
|
|
@ -71,6 +78,7 @@ class DiscoverRefreshMotionTest {
|
||||||
val bounds = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
val bounds = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||||
val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt())
|
val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt())
|
||||||
val baseline = brightness(File(directory, "loaded.png"), bounds, pixels)
|
val baseline = brightness(File(directory, "loaded.png"), bounds, pixels)
|
||||||
|
cache.lost = true
|
||||||
coEvery { facade.findHeld(any(), any()) } coAnswers { refreshLookup.await() }
|
coEvery { facade.findHeld(any(), any()) } coAnswers { refreshLookup.await() }
|
||||||
rule.runOnIdle {
|
rule.runOnIdle {
|
||||||
model.load(route, person, preferences, fixtures = false, force = true)
|
model.load(route, person, preferences, fixtures = false, force = true)
|
||||||
|
|
@ -83,6 +91,16 @@ class DiscoverRefreshMotionTest {
|
||||||
assertEquals("Loaded actions cannot replay their rise", bounds, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
|
assertEquals("Loaded actions cannot replay their rise", bounds, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
|
||||||
assertEquals("Loaded actions cannot replay their fade", baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels))
|
assertEquals("Loaded actions cannot replay their fade", baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels))
|
||||||
}
|
}
|
||||||
|
refreshLookup.complete(null)
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
model.title?.data?.watchReady == true
|
||||||
|
}
|
||||||
|
repeat(12) {
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
|
||||||
|
}
|
||||||
|
assertSameFrame(File(directory, "loaded.png"), File(directory, "refreshed.png"))
|
||||||
} finally {
|
} finally {
|
||||||
model.viewModelScope.cancel()
|
model.viewModelScope.cancel()
|
||||||
refreshLookup.complete(null)
|
refreshLookup.complete(null)
|
||||||
|
|
@ -134,6 +152,9 @@ class DiscoverRefreshMotionTest {
|
||||||
model.title?.data?.watchTarget?.watched == refreshedWatched
|
model.title?.data?.watchTarget?.watched == refreshedWatched
|
||||||
}
|
}
|
||||||
assertEquals(refreshedWatched, model.title!!.watched)
|
assertEquals(refreshedWatched, model.title!!.watched)
|
||||||
|
rule.mainClock.advanceTimeBy(640)
|
||||||
|
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
|
||||||
|
assertSameFrame(File(directory, "loaded.png"), File(directory, "refreshed.png"))
|
||||||
} finally { model.viewModelScope.cancel() }
|
} finally { model.viewModelScope.cancel() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -182,6 +203,8 @@ class DiscoverRefreshMotionTest {
|
||||||
}
|
}
|
||||||
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
|
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
|
||||||
assertTrue(model.title!!.options.any { it.id == "play" })
|
assertTrue(model.title!!.options.any { it.id == "play" })
|
||||||
|
rule.mainClock.advanceTimeBy(640)
|
||||||
|
assertSameFrame(File(directory, "loaded.png"), File(directory, "refreshed.png"))
|
||||||
} finally { model.viewModelScope.cancel(); refresh.complete(held) }
|
} finally { model.viewModelScope.cancel(); refresh.complete(held) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -194,6 +217,12 @@ class DiscoverRefreshMotionTest {
|
||||||
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
|
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
|
||||||
rule.setContent { WolfskinTheme {
|
rule.setContent { WolfskinTheme {
|
||||||
model.title?.let { state ->
|
model.title?.let { state ->
|
||||||
|
val presented = remember(model) { booleanArrayOf(false) }
|
||||||
|
val status = state.data.status
|
||||||
|
SideEffect {
|
||||||
|
if (presented[0]) assertEquals("Every rendered refresh frame stays Ready", TitleReadStatus.Ready, status)
|
||||||
|
if (status == TitleReadStatus.Ready) presented[0] = true
|
||||||
|
}
|
||||||
val paths = listOfNotNull(state.data.item.poster, state.data.item.backdrop) +
|
val paths = listOfNotNull(state.data.item.poster, state.data.item.backdrop) +
|
||||||
state.data.cast.mapNotNull { it.portrait } +
|
state.data.cast.mapNotNull { it.portrait } +
|
||||||
(state.data.related + state.data.episodes).flatMap { listOfNotNull(it.poster, it.backdrop) }
|
(state.data.related + state.data.episodes).flatMap { listOfNotNull(it.poster, it.backdrop) }
|
||||||
|
|
@ -204,6 +233,15 @@ class DiscoverRefreshMotionTest {
|
||||||
} }
|
} }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun assertSameFrame(before: File, after: File) {
|
||||||
|
rule.onRoot().captureRoboImage(after.absolutePath)
|
||||||
|
val first = BitmapFactory.decodeFile(before.absolutePath)
|
||||||
|
val second = BitmapFactory.decodeFile(after.absolutePath)
|
||||||
|
try {
|
||||||
|
assertTrue("Same-data refresh must return the identical rendered frame", first.sameAs(second))
|
||||||
|
} finally { first.recycle(); second.recycle() }
|
||||||
|
}
|
||||||
|
|
||||||
private fun brightness(file: File, bounds: Rect, pixels: IntArray): Long {
|
private fun brightness(file: File, bounds: Rect, pixels: IntArray): Long {
|
||||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,31 @@ import org.robolectric.annotation.GraphicsMode
|
||||||
class DiscoverTitleTimingTest {
|
class DiscoverTitleTimingTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun detailsPushKeepsTheOutgoingDiscoverHeroUntilNavigationReplacesIt() {
|
||||||
|
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
|
||||||
|
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||||
|
}
|
||||||
|
var pushed = ""
|
||||||
|
lateinit var discover: DiscoverState
|
||||||
|
rule.setContent {
|
||||||
|
discover = rememberDiscoverState(shell, { pushed = it }, {}, fixtures = true)
|
||||||
|
WolfskinTheme { ScreenEntry(shell, "discover", true) {
|
||||||
|
ShellSurface(shell) { DiscoverScreen(discover, it) }
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
rule.waitUntil(30_000) { discover.snapshot.status == DiscoverStatus.Ready }
|
||||||
|
rule.mainClock.advanceTimeBy(640)
|
||||||
|
rule.onNodeWithText("Details").assertIsDisplayed()
|
||||||
|
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-entry").apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "hero-before-push.png").absolutePath)
|
||||||
|
rule.runOnIdle { discover.openTitle(requireNotNull(discover.hero)) }
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals(titleRoute("movie:1930"), pushed)
|
||||||
|
rule.onNodeWithText("Details").assertIsDisplayed()
|
||||||
|
rule.onNodeWithText("For someone else").assertDoesNotExist()
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "hero-after-push.png").absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() {
|
@Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() {
|
||||||
FakeConductarr().use { fake ->
|
FakeConductarr().use { fake ->
|
||||||
fake.localArt = true
|
fake.localArt = true
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,13 @@ import android.os.Looper
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.BackEventCompat
|
import androidx.activity.BackEventCompat
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.compose.runtime.snapshots.Snapshot
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
|
|
@ -45,9 +52,13 @@ class TitleEntryBackTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
private lateinit var viewModel: WolfskinTitleViewModel
|
private lateinit var viewModel: WolfskinTitleViewModel
|
||||||
private lateinit var conductarr: ConductarrSession
|
private lateinit var conductarr: ConductarrSession
|
||||||
|
private var homeModel: WolfskinHomeViewModel? = null
|
||||||
|
private var previousFixtures: Boolean? = null
|
||||||
|
|
||||||
@After fun dispose() {
|
@After fun dispose() {
|
||||||
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
|
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
|
||||||
|
homeModel?.viewModelScope?.cancel()
|
||||||
|
previousFixtures?.let(ConductarrPreference::updateFixtures)
|
||||||
if (::conductarr.isInitialized) conductarr.close()
|
if (::conductarr.isInitialized) conductarr.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,6 +67,66 @@ class TitleEntryBackTest {
|
||||||
@Test fun predictiveTitleBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = false, predictive = true)
|
@Test fun predictiveTitleBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = false, predictive = true)
|
||||||
@Test fun predictivePersonBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = true, predictive = true)
|
@Test fun predictivePersonBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = true, predictive = true)
|
||||||
|
|
||||||
|
@Test fun homeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false)
|
||||||
|
@Test fun reducedMotionHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = true)
|
||||||
|
|
||||||
|
private fun verifyHomeViewport(reduced: Boolean) {
|
||||||
|
val navigation = NavigationManager()
|
||||||
|
val viewer = ShellPerson("justin", "Justin", "J", true)
|
||||||
|
val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false }
|
||||||
|
val snapshot = HomeFixture(rule.activity).snapshot(viewer.key)
|
||||||
|
conductarr = ConductarrSession(MemoryCacheStore())
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
ConductarrPreference.updateFixtures(true)
|
||||||
|
val server = com.github.damontecres.wholphin.data.model.JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||||
|
val user = com.github.damontecres.wholphin.data.model.JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
||||||
|
val source = WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr)
|
||||||
|
homeModel = source
|
||||||
|
viewModel = WolfskinTitleViewModel(rule.activity, mockk(), TitleFixtureProvider(rule.activity), navigation, conductarr,
|
||||||
|
mockk(relaxed = true), mockk(relaxed = true))
|
||||||
|
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||||
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||||
|
val onBack = rememberWolfskinBackHandler(shell, navigation)
|
||||||
|
NavDisplay(backStack = navigation.backStack, onBack = onBack,
|
||||||
|
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||||
|
entryProvider = { destination -> NavEntry(destination) {
|
||||||
|
val active = navigation.backStack.lastOrNull() == destination
|
||||||
|
val route = (destination as? Destination.Wolfskin)?.route ?: "home"
|
||||||
|
LaunchedEffect(destination, active) { if (active) shell.enterDestination(route) }
|
||||||
|
ScreenEntry(shell, route, active) {
|
||||||
|
if (destination is Destination.Home) ShellSurface(shell) { modifier ->
|
||||||
|
HomeDestination(shell, server, user, modifier, source)
|
||||||
|
} else TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = true)
|
||||||
|
}
|
||||||
|
} })
|
||||||
|
} } }
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
source.snapshot.rows.any { it.id == "cw" } && !shell.navFocused
|
||||||
|
}
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.waitForIdle()
|
||||||
|
val home = shell.rememberedView("home")
|
||||||
|
val tag = "home-card:${snapshot.rows[home.row].id}:0"
|
||||||
|
val before = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }
|
||||||
|
val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
|
||||||
|
val captures = File(System.getProperty("wolfskin.captureDirectory"), "home-title-back-${if (reduced) "reduced" else "animated"}").apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-before.png").absolutePath)
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
viewModel.title?.data?.status == TitleReadStatus.Ready
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
dispatchBack(predictive = false)
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-after.png").absolutePath)
|
||||||
|
assertEquals(before, home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset })
|
||||||
|
assertEquals(bounds, rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun nativeBackKeyWaitsForSystemCompletionBeforePoppingTheTitle() {
|
@Test fun nativeBackKeyWaitsForSystemCompletionBeforePoppingTheTitle() {
|
||||||
val (navigation, shell) = mountTitle(personPage = false)
|
val (navigation, shell) = mountTitle(personPage = false)
|
||||||
val before = navigation.backStack.toList()
|
val before = navigation.backStack.toList()
|
||||||
|
|
|
||||||
|
|
@ -927,9 +927,12 @@ retain the approved frames except for the requested feedback changes:
|
||||||
cache, 128 MiB disk cache and four image-read limit remain unchanged.
|
cache, 128 MiB disk cache and four image-read limit remain unchanged.
|
||||||
|
|
||||||
`HomeViewportTest` covers short/long poster, tile and wide rows, both row
|
`HomeViewportTest` covers short/long poster, tile and wide rows, both row
|
||||||
directions, first focus and native Back from the actual title surface. Native
|
directions, first focus and exact scroll/card bounds after native Back from the
|
||||||
proof records real Bear season-three metadata through the title read facade;
|
actual title surface. `TitleEntryBackTest` also checks the production Home/title
|
||||||
the proof-only harness is not part of the shipped tests or APK.
|
loaders through `NavDisplay` and its entry decorators on SDK 34 and 36, with
|
||||||
|
animation and reduced motion. Native proof records real Bear season-three
|
||||||
|
metadata through the title read facade; the proof-only harness is not part of
|
||||||
|
the shipped tests or APK.
|
||||||
|
|
||||||
Episode lists request only ProviderIds and Overview. Next up reads only identity,
|
Episode lists request only ProviderIds and Overview. Next up reads only identity,
|
||||||
then hydrates the selected playback target through the existing single-item read,
|
then hydrates the selected playback target through the existing single-item read,
|
||||||
|
|
@ -959,6 +962,10 @@ Selections persist through the same playback repository as the upstream player.
|
||||||
Marking a whole series asks first; an explicit episode remains the exact play and
|
Marking a whole series asks first; an explicit episode remains the exact play and
|
||||||
watched target even while another season is selected. Nested person/title visits
|
watched target even while another season is selected. Nested person/title visits
|
||||||
retain their entry's focus and selection state.
|
retain their entry's focus and selection state.
|
||||||
|
Discover Details opens this same title destination without replacing the outgoing
|
||||||
|
Discover page with a separate preview first. The shared destination owns its
|
||||||
|
initial loading skeleton; an already-ready title keeps its header and actions
|
||||||
|
through catalogue, held-title and fixture refreshes.
|
||||||
Choosing another episode from an episode page replaces only the top navigation
|
Choosing another episode from an episode page replaces only the top navigation
|
||||||
entry, preserving its series/Home parent. Choosing the first episode from a
|
entry, preserving its series/Home parent. Choosing the first episode from a
|
||||||
series, related titles, cast and performer credits still push normal drill-ins.
|
series, related titles, cast and performer credits still push normal drill-ins.
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,8 @@ androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-da
|
||||||
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
||||||
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
|
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
|
||||||
androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" }
|
androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" }
|
||||||
|
androidx-media3-test-utils = { module = "androidx.media3:media3-test-utils", version.ref = "androidx-media3" }
|
||||||
|
androidx-media3-test-utils-robolectric = { module = "androidx.media3:media3-test-utils-robolectric", version.ref = "androidx-media3" }
|
||||||
exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" }
|
exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" }
|
||||||
|
|
||||||
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,6 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.title != null) { DiscoverTitleDestination(state, modifier); return }
|
|
||||||
state.collection?.let {
|
state.collection?.let {
|
||||||
LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false)
|
LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.design
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
|
||||||
|
|
||||||
/** Explicit destination stub exposes only the Discover-owned request and pick actions. */
|
|
||||||
@Composable
|
|
||||||
fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier) {
|
|
||||||
val title = state.title ?: return
|
|
||||||
val focused = state.contentFocused
|
|
||||||
Box(modifier.fillMaxSize().testTag("discover-title-destination-stub")) {
|
|
||||||
DiscoverBackdrop(title, detail = true)
|
|
||||||
Column(Modifier.offset(L.safeSide, L.detailCopyTop).width(L.detailCopyWidth)) {
|
|
||||||
Text(title.title, color = C.fg, style = WolfskinTokens.Type.displayDetail, maxLines = 2)
|
|
||||||
Spacer(Modifier.height(D.heroTitleGap))
|
|
||||||
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()} · ${title.genres.take(2).joinToString(", ")}", color = C.muted, style = WolfskinTokens.Type.body)
|
|
||||||
Spacer(Modifier.height(D.heroMetaGap))
|
|
||||||
if (state.isSignedIn) DiscoverRatings(title.ratings)
|
|
||||||
Spacer(Modifier.height(D.heroMetaGap))
|
|
||||||
Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3)
|
|
||||||
Spacer(Modifier.height(D.heroOverviewGap))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
|
||||||
if (!state.canRequest) DiscoverButton("Try again", focused) { state.refresh() } else {
|
|
||||||
val receipt = state.requests[title.key]?.firstOrNull { it.requester == state.shell.person.key }
|
|
||||||
DiscoverButton(if (receipt == null) "Request" else "Requested · ${receipt.stage}", focused && state.titleButton == 0, glyph = if (receipt == null) "plus" else "clock") {
|
|
||||||
state.titleButton = 0; state.key(ShellKey.Ok)
|
|
||||||
}
|
|
||||||
DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, glyph = "profile", primary = false) { state.openRequest(true) }
|
|
||||||
if (state.isPick(title)) DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, glyph = if (state.isHidden(title)) "undo" else "hide", primary = false) { state.titleButton = 2; state.key(ShellKey.Ok) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -15,6 +15,7 @@ import androidx.compose.ui.test.pressKey
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
@ -59,10 +60,15 @@ class HomeViewportTest {
|
||||||
repeat(row.count - 1) { press(Key.DirectionRight) }
|
repeat(row.count - 1) { press(Key.DirectionRight) }
|
||||||
assertVisible(shell, rows)
|
assertVisible(shell, rows)
|
||||||
capture("viewport-${row.id}")
|
capture("viewport-${row.id}")
|
||||||
|
val viewport = shell.view.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }
|
||||||
|
val bounds = rule.onNodeWithTag("home-card:${row.id}:${row.count - 1}").fetchSemanticsNode().boundsInRoot
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
rule.onNodeWithTag("title-page").assertIsDisplayed()
|
rule.onNodeWithTag("title-page").assertIsDisplayed()
|
||||||
press(Key.Escape)
|
press(Key.Escape)
|
||||||
assertVisible(shell, rows)
|
assertVisible(shell, rows)
|
||||||
|
assertEquals("Back restores the exact scroll, not only the focused card", viewport,
|
||||||
|
shell.view.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset })
|
||||||
|
assertEquals(bounds, rule.onNodeWithTag("home-card:${row.id}:${row.count - 1}").fetchSemanticsNode().boundsInRoot)
|
||||||
}
|
}
|
||||||
repeat(rows.lastIndex) { press(Key.DirectionUp); assertVisible(shell, rows) }
|
repeat(rows.lastIndex) { press(Key.DirectionUp); assertVisible(shell, rows) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue