diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9b5190ad..05c2e073 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -479,6 +479,8 @@ dependencies { testImplementation("com.squareup.okhttp3:mockwebserver3") testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) + testImplementation(libs.androidx.media3.test.utils) + testImplementation(libs.androidx.media3.test.utils.robolectric) testImplementation(libs.hilt.android.testing) testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0") diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index a2fd0eb7..4ccdce61 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -54,6 +54,7 @@ import timber.log.Timber import java.lang.reflect.Constructor import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration /** * Constructs a [Player] instance for video playback @@ -174,7 +175,7 @@ class PlayerFactory ExoPlayer .Builder(context) .setMediaSourceFactory(mediaSourceFactory) - .setRenderersFactory(renderersFactory) + .setRenderersFactory(SubtitleDelayRenderersFactory(renderersFactory)) .setTrackSelector(trackSelector) .build() .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( val player: Player, val assHandler: AssHandler? = null, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SubtitleDelayRenderer.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SubtitleDelayRenderer.kt new file mode 100644 index 00000000..88feaae1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SubtitleDelayRenderer.kt @@ -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, + 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, + 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 { + 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, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 86d64651..d0cdf6d6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -60,7 +60,9 @@ import androidx.compose.ui.window.DialogProperties import androidx.core.view.children import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.SubtitleView import androidx.media3.ui.compose.PlayerSurface 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.UserPreferences 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.AspectRatios 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 kotlinx.coroutines.delay 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 timber.log.Timber import kotlin.time.Duration @@ -185,9 +190,8 @@ fun PlaybackPageContent( var playbackSpeed by remember { mutableFloatStateOf(1.0f) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } - LaunchedEffect(state.currentPlayback?.subtitleDelay) { - (player as? MpvPlayer)?.subtitleDelay = - state.currentPlayback?.subtitleDelay ?: Duration.ZERO + LaunchedEffect(player, state.currentPlayback?.subtitleDelay) { + player.applySubtitleDelay(state.currentPlayback?.subtitleDelay ?: Duration.ZERO) } val presentationState = rememberPresentationState(player, false) @@ -784,8 +788,34 @@ fun PlaybackPageContent( }, onPlaybackActionClick = onPlaybackActionClick, onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) }, - enableSubtitleDelay = player is MpvPlayer, + enableSubtitleDelay = subtitleDelaySupported(player, state.currentPlayback), 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 + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 9cbbe551..c5228f07 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -1669,6 +1669,9 @@ class PlaybackViewModel } } + /** + * Load the enabled subtitle track's saved delay, or zero if no delay is saved. + */ suspend fun loadSubtitleDelay() { state.value.currentPlayback?.let { if (it.subtitleIndexEnabled) { @@ -1681,7 +1684,9 @@ class PlaybackViewModel it.subtitleIndex, it.item.id, ) - updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) } + } + updateCurrentPlayback { + it?.copy(subtitleDelay = result?.delayMs?.milliseconds ?: Duration.ZERO) } } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayPlaybackTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayPlaybackTest.kt new file mode 100644 index 00000000..5a4a275f --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayPlaybackTest.kt @@ -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() + private val clock = FakeClock(true) + private val shownCues = mutableListOf>() + private val playbackStates = mutableListOf() + 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) { + 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() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayRendererTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayRendererTest.kt new file mode 100644 index 00000000..af8464e8 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayRendererTest.kt @@ -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(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(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 { + 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(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 + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index 87e5670a..064f70e8 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -3,16 +3,19 @@ package com.github.damontecres.wholphin.ui.playback import android.content.Context import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionParameters import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository 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.JellyfinUser import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType 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.PlaybackPreferences import com.github.damontecres.wholphin.preferences.ShowNextUpWhen @@ -82,6 +85,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -292,6 +296,76 @@ class PlaybackViewModelTests { 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(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()) } + } + @Test fun `Direct play includes the opened live stream ID`() = runTest(testDispatcher) { diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelayControlTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelayControlTest.kt new file mode 100644 index 00000000..44ac46c4 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelayControlTest.kt @@ -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().getString(R.string.subtitle_delay) + + @Test + fun supportedUnlessTheSubtitlesAreBurnedIn() { + val exoPlayer = mockk() + val cases = + listOf( + Triple(mockk(), 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(), 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() + 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(json), + subtitleIndex = subtitleIndex, + ) + } + + private companion object { + const val EXTERNAL_INDEX = 0 + const val EMBEDDED_INDEX = 5 + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt index d6b6d14c..2c956245 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt @@ -8,6 +8,8 @@ import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.requiredSize import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.asImageBitmap @@ -48,7 +50,12 @@ class DiscoverRefreshMotionTest { @Test fun loadedCatalogueRefreshKeepsReadyActionsOpaqueAndStationary() { FakeConductarr().use { fake -> 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) runBlocking { session.authenticate() } val facade = mockk() @@ -71,6 +78,7 @@ class DiscoverRefreshMotionTest { val bounds = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt()) val baseline = brightness(File(directory, "loaded.png"), bounds, pixels) + cache.lost = true coEvery { facade.findHeld(any(), any()) } coAnswers { refreshLookup.await() } rule.runOnIdle { 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 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 { model.viewModelScope.cancel() refreshLookup.complete(null) @@ -134,6 +152,9 @@ class DiscoverRefreshMotionTest { model.title?.data?.watchTarget?.watched == refreshedWatched } 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() } } } @@ -182,6 +203,8 @@ class DiscoverRefreshMotionTest { } assertEquals(TitleReadStatus.Ready, model.title!!.data.status) 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) } } } @@ -194,6 +217,12 @@ class DiscoverRefreshMotionTest { val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap() rule.setContent { WolfskinTheme { 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) + state.data.cast.mapNotNull { it.portrait } + (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 { rule.onRoot().captureRoboImage(file.absolutePath) val bitmap = BitmapFactory.decodeFile(file.absolutePath) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt index cd5f3dd0..2162701a 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt @@ -36,6 +36,31 @@ import org.robolectric.annotation.GraphicsMode class DiscoverTitleTimingTest { @get:Rule val rule = createAndroidComposeRule() + @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() { FakeConductarr().use { fake -> fake.localArt = true diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt index 0fc6c7b7..977af5ca 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt @@ -6,6 +6,13 @@ import android.os.Looper import androidx.activity.ComponentActivity import androidx.activity.BackEventCompat 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.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onRoot @@ -45,9 +52,13 @@ class TitleEntryBackTest { @get:Rule val rule = createAndroidComposeRule() private lateinit var viewModel: WolfskinTitleViewModel private lateinit var conductarr: ConductarrSession + private var homeModel: WolfskinHomeViewModel? = null + private var previousFixtures: Boolean? = null @After fun dispose() { if (::viewModel.isInitialized) viewModel.viewModelScope.cancel() + homeModel?.viewModelScope?.cancel() + previousFixtures?.let(ConductarrPreference::updateFixtures) if (::conductarr.isInitialized) conductarr.close() } @@ -56,6 +67,66 @@ class TitleEntryBackTest { @Test fun predictiveTitleBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = false, 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() { val (navigation, shell) = mountTitle(personPage = false) val before = navigation.backStack.toList() diff --git a/docker/README.md b/docker/README.md index abe554a0..5522b541 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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. `HomeViewportTest` covers short/long poster, tile and wide rows, both row -directions, first focus and native Back from the actual title surface. 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. +directions, first focus and exact scroll/card bounds after native Back from the +actual title surface. `TitleEntryBackTest` also checks the production Home/title +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, 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 watched target even while another season is selected. Nested person/title visits 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 entry, preserving its series/Home parent. Choosing the first episode from a series, related titles, cast and performer credits still push normal drill-ins. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9d1d45b2..d0d256f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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-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-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" } coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index 1754b3ce..48972d26 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -74,7 +74,6 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { } } } - if (state.title != null) { DiscoverTitleDestination(state, modifier); return } state.collection?.let { LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false) return diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt deleted file mode 100644 index 81247975..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt +++ /dev/null @@ -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) } - } - } - } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt index 7d5dca54..e30a9f60 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.test.pressKey import com.github.takahirom.roborazzi.captureRoboImage import java.io.File import org.junit.Assert.assertTrue +import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -59,10 +60,15 @@ class HomeViewportTest { repeat(row.count - 1) { press(Key.DirectionRight) } assertVisible(shell, rows) 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) rule.onNodeWithTag("title-page").assertIsDisplayed() press(Key.Escape) 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) } }