Merge remote-tracking branch 'origin/fm/tv-round2' into fm/polish-lane-e

This commit is contained in:
justinzeus 2026-11-08 07:54:51 +01:00
commit 45b49a960f
17 changed files with 1007 additions and 60 deletions

View file

@ -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")

View file

@ -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,

View file

@ -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,
)
}

View file

@ -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
}
}

View file

@ -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)
} }
} }
} }

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -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) {

View file

@ -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
}
}

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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.

View file

@ -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" }

View file

@ -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

View file

@ -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) }
}
}
}
}
}

View file

@ -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) }
} }