Round 2 release candidate 3: polish lanes A-E, season reel and trailer return #82
17 changed files with 1007 additions and 60 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
@file:OptIn(markerClass = [UnstableApi::class])
|
||||
|
||||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.os.Handler
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ForwardingRenderer
|
||||
import androidx.media3.exoplayer.Renderer
|
||||
import androidx.media3.exoplayer.RendererConfiguration
|
||||
import androidx.media3.exoplayer.RenderersFactory
|
||||
import androidx.media3.exoplayer.audio.AudioRendererEventListener
|
||||
import androidx.media3.exoplayer.metadata.MetadataOutput
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.SampleStream
|
||||
import androidx.media3.exoplayer.text.TextOutput
|
||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||
import io.github.peerless2012.ass.media.render.AssRenderer
|
||||
|
||||
/**
|
||||
* Delays the subtitles of the wrapped renderer by shifting the playback position it is given.
|
||||
*
|
||||
* A positive delay shows subtitles later, a negative delay earlier, the same as mpv's `sub-delay`.
|
||||
* The delay is set on the playback thread with a [MSG_SET_SUBTITLE_DELAY] message whose payload is
|
||||
* the delay in microseconds as a [Long].
|
||||
*
|
||||
* Wrapping hides the renderer's type from ExoPlayer's `instanceof TextRenderer` checks, which only
|
||||
* matter for clipped streams and server-side inserted ads.
|
||||
*/
|
||||
class SubtitleDelayRenderer(
|
||||
internal val delegate: Renderer,
|
||||
) : ForwardingRenderer(delegate) {
|
||||
private val period = Timeline.Period()
|
||||
private var delayUs = 0L
|
||||
private var timeline: Timeline = Timeline.EMPTY
|
||||
private var mediaPeriodId: MediaSource.MediaPeriodId? = null
|
||||
private var streamOffsetUs = 0L
|
||||
private var periodEndPositionUs = C.TIME_UNSET
|
||||
private var lastPositionUs = C.TIME_UNSET
|
||||
|
||||
override fun handleMessage(
|
||||
messageType: Int,
|
||||
message: Any?,
|
||||
) {
|
||||
if (messageType == MSG_SET_SUBTITLE_DELAY) {
|
||||
delayUs = message as Long
|
||||
} else {
|
||||
super.handleMessage(messageType, message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun enable(
|
||||
configuration: RendererConfiguration,
|
||||
formats: Array<out Format>,
|
||||
stream: SampleStream,
|
||||
positionUs: Long,
|
||||
joining: Boolean,
|
||||
mayRenderStartOfStream: Boolean,
|
||||
startPositionUs: Long,
|
||||
offsetUs: Long,
|
||||
mediaPeriodId: MediaSource.MediaPeriodId,
|
||||
) {
|
||||
lastPositionUs = positionUs
|
||||
updateStream(offsetUs, mediaPeriodId)
|
||||
super.enable(
|
||||
configuration,
|
||||
formats,
|
||||
stream,
|
||||
positionUs - delayUs,
|
||||
joining,
|
||||
mayRenderStartOfStream,
|
||||
startPositionUs,
|
||||
offsetUs,
|
||||
mediaPeriodId,
|
||||
)
|
||||
}
|
||||
|
||||
override fun replaceStream(
|
||||
formats: Array<out Format>,
|
||||
stream: SampleStream,
|
||||
startPositionUs: Long,
|
||||
offsetUs: Long,
|
||||
mediaPeriodId: MediaSource.MediaPeriodId,
|
||||
) {
|
||||
updateStream(offsetUs, mediaPeriodId)
|
||||
super.replaceStream(formats, stream, startPositionUs, offsetUs, mediaPeriodId)
|
||||
}
|
||||
|
||||
override fun setTimeline(timeline: Timeline) {
|
||||
this.timeline = timeline
|
||||
updatePeriodEndPosition()
|
||||
super.setTimeline(timeline)
|
||||
}
|
||||
|
||||
override fun render(
|
||||
positionUs: Long,
|
||||
elapsedRealtimeUs: Long,
|
||||
) {
|
||||
lastPositionUs = positionUs
|
||||
super.render(positionUs - delayUs, elapsedRealtimeUs)
|
||||
}
|
||||
|
||||
override fun resetPosition(
|
||||
positionUs: Long,
|
||||
sampleStreamIsResetToKeyFrame: Boolean,
|
||||
) {
|
||||
lastPositionUs = positionUs
|
||||
super.resetPosition(positionUs - delayUs, sampleStreamIsResetToKeyFrame)
|
||||
}
|
||||
|
||||
override fun supportsResetPositionWithoutKeyFrameReset(positionUs: Long): Boolean =
|
||||
super.supportsResetPositionWithoutKeyFrameReset(positionUs - delayUs)
|
||||
|
||||
override fun getDurationToProgressUs(
|
||||
positionUs: Long,
|
||||
elapsedRealtimeUs: Long,
|
||||
): Long = super.getDurationToProgressUs(positionUs - delayUs, elapsedRealtimeUs)
|
||||
|
||||
/**
|
||||
* A text renderer only reads the end of its stream once its position is within a second of the
|
||||
* period end, so a positive delay above a second would hold back
|
||||
* [androidx.media3.common.Player.STATE_ENDED] by the delay minus that second. Once the real
|
||||
* position passes the period end there is nothing left to show, so the renderer counts as ended.
|
||||
*/
|
||||
override fun isEnded(): Boolean =
|
||||
super.isEnded() ||
|
||||
(
|
||||
delayUs > 0 &&
|
||||
periodEndPositionUs != C.TIME_UNSET &&
|
||||
lastPositionUs >= periodEndPositionUs
|
||||
)
|
||||
|
||||
override fun disable() {
|
||||
mediaPeriodId = null
|
||||
periodEndPositionUs = C.TIME_UNSET
|
||||
super.disable()
|
||||
}
|
||||
|
||||
private fun updateStream(
|
||||
offsetUs: Long,
|
||||
mediaPeriodId: MediaSource.MediaPeriodId,
|
||||
) {
|
||||
streamOffsetUs = offsetUs
|
||||
this.mediaPeriodId = mediaPeriodId
|
||||
updatePeriodEndPosition()
|
||||
}
|
||||
|
||||
private fun updatePeriodEndPosition() {
|
||||
val periodIndex = mediaPeriodId?.let { timeline.getIndexOfPeriod(it.periodUid) } ?: C.INDEX_UNSET
|
||||
val durationUs =
|
||||
if (periodIndex == C.INDEX_UNSET) {
|
||||
C.TIME_UNSET
|
||||
} else {
|
||||
timeline.getPeriod(periodIndex, period).durationUs
|
||||
}
|
||||
periodEndPositionUs = if (durationUs == C.TIME_UNSET) C.TIME_UNSET else streamOffsetUs + durationUs
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Sets the subtitle delay; the payload is the delay in microseconds as a [Long]. */
|
||||
const val MSG_SET_SUBTITLE_DELAY = Renderer.MSG_CUSTOM_BASE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the subtitle renderers of [delegate] in a [SubtitleDelayRenderer]: media3's text renderer
|
||||
* and the libass [AssRenderer], which draws at the position it is rendered with.
|
||||
*/
|
||||
class SubtitleDelayRenderersFactory(
|
||||
private val delegate: RenderersFactory,
|
||||
) : RenderersFactory {
|
||||
override fun createRenderers(
|
||||
eventHandler: Handler,
|
||||
videoRendererEventListener: VideoRendererEventListener,
|
||||
audioRendererEventListener: AudioRendererEventListener,
|
||||
textRendererOutput: TextOutput,
|
||||
metadataRendererOutput: MetadataOutput,
|
||||
): Array<Renderer> {
|
||||
val renderers =
|
||||
delegate.createRenderers(
|
||||
eventHandler,
|
||||
videoRendererEventListener,
|
||||
audioRendererEventListener,
|
||||
textRendererOutput,
|
||||
metadataRendererOutput,
|
||||
)
|
||||
return Array(renderers.size) { index ->
|
||||
val renderer = renderers[index]
|
||||
if (renderer.trackType == C.TRACK_TYPE_TEXT || renderer is AssRenderer) {
|
||||
SubtitleDelayRenderer(renderer)
|
||||
} else {
|
||||
renderer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun createSecondaryRenderer(
|
||||
renderer: Renderer,
|
||||
eventHandler: Handler,
|
||||
videoRendererEventListener: VideoRendererEventListener,
|
||||
audioRendererEventListener: AudioRendererEventListener,
|
||||
textRendererOutput: TextOutput,
|
||||
metadataRendererOutput: MetadataOutput,
|
||||
): Renderer? =
|
||||
delegate.createSecondaryRenderer(
|
||||
(renderer as? SubtitleDelayRenderer)?.delegate ?: renderer,
|
||||
eventHandler,
|
||||
videoRendererEventListener,
|
||||
audioRendererEventListener,
|
||||
textRendererOutput,
|
||||
metadataRendererOutput,
|
||||
)
|
||||
}
|
||||
|
|
@ -60,7 +60,9 @@ import androidx.compose.ui.window.DialogProperties
|
|||
import androidx.core.view.children
|
||||
import androidx.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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,186 @@
|
|||
@file:OptIn(markerClass = [UnstableApi::class])
|
||||
|
||||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.text.CueGroup
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.test.utils.FakeClock
|
||||
import androidx.media3.test.utils.robolectric.CapturingRenderersFactory
|
||||
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.play
|
||||
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.run
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.abs
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Plays a short audio file with a sideloaded SRT through a real [ExoPlayer] and checks when the
|
||||
* subtitles appear for a given delay, and that playback still ends when the media does.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, sdk = [30])
|
||||
class SubtitleDelayPlaybackTest {
|
||||
@get:Rule val folder = TemporaryFolder()
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
private val clock = FakeClock(true)
|
||||
private val shownCues = mutableListOf<Pair<String, Long>>()
|
||||
private val playbackStates = mutableListOf<Int>()
|
||||
private lateinit var player: ExoPlayer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
player =
|
||||
ExoPlayer
|
||||
.Builder(context, SubtitleDelayRenderersFactory(CapturingRenderersFactory(context, clock)))
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(context))
|
||||
.setClock(clock)
|
||||
.build()
|
||||
player.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onCues(cueGroup: CueGroup) {
|
||||
cueGroup.cues.firstOrNull()?.let { shownCues.add(it.text.toString() to player.currentPosition) }
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
playbackStates.add(playbackState)
|
||||
}
|
||||
},
|
||||
)
|
||||
player.setMediaItem(mediaItem())
|
||||
player.prepare()
|
||||
// Subtitles never hold up playback, so let the sideloaded file load before playing
|
||||
run(player).untilState(Player.STATE_READY)
|
||||
run(player).untilLoadingIs(false)
|
||||
assertTrue(player.currentTracks.isTypeSelected(C.TRACK_TYPE_TEXT))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
player.release()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun positiveDelayShowsSubtitlesLaterAndPlaybackEndsOnTime() {
|
||||
player.applySubtitleDelay(2.seconds)
|
||||
val playStartMs = clock.elapsedRealtime()
|
||||
|
||||
play(player).untilState(Player.STATE_ENDED)
|
||||
|
||||
assertShownAt("First" to 3.seconds)
|
||||
val playedMs = clock.elapsedRealtime() - playStartMs
|
||||
assertTrue(
|
||||
"ended after $playedMs ms of playback, expected $DURATION",
|
||||
abs(playedMs - DURATION.inWholeMilliseconds) <= TOLERANCE_MS,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negativeDelayShowsSubtitlesEarlier() {
|
||||
player.applySubtitleDelay((-500).milliseconds)
|
||||
|
||||
play(player).untilState(Player.STATE_ENDED)
|
||||
|
||||
assertShownAt("First" to 500.milliseconds, "Last" to 2.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changingTheDelayWhilePlayingRetimesWithoutRebuffering() {
|
||||
play(player).untilPosition(0, 500)
|
||||
player.applySubtitleDelay(1.seconds)
|
||||
|
||||
play(player).untilState(Player.STATE_ENDED)
|
||||
|
||||
assertShownAt("First" to 2.seconds, "Last" to 3_500.milliseconds)
|
||||
assertEquals(listOf(Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED), playbackStates)
|
||||
}
|
||||
|
||||
private fun assertShownAt(vararg expected: Pair<String, Duration>) {
|
||||
assertEquals(expected.map { it.first }, shownCues.map { it.first })
|
||||
expected.zip(shownCues).forEach { (want, got) ->
|
||||
assertTrue(
|
||||
"${want.first} shown at ${got.second} ms, expected ${want.second}",
|
||||
abs(got.second - want.second.inWholeMilliseconds) <= TOLERANCE_MS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mediaItem(): MediaItem {
|
||||
val audio = folder.newFile("silence.wav").apply { writeBytes(silentWav()) }
|
||||
val subtitles = folder.newFile("subtitles.srt").apply { writeText(SRT) }
|
||||
return MediaItem
|
||||
.Builder()
|
||||
.setUri(Uri.fromFile(audio))
|
||||
.setSubtitleConfigurations(
|
||||
listOf(
|
||||
MediaItem.SubtitleConfiguration
|
||||
.Builder(Uri.fromFile(subtitles))
|
||||
.setMimeType(MimeTypes.APPLICATION_SUBRIP)
|
||||
.setLanguage("en")
|
||||
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||
.build(),
|
||||
),
|
||||
).build()
|
||||
}
|
||||
|
||||
/** A mono 16-bit PCM WAV file of silence lasting [DURATION] */
|
||||
private fun silentWav(): ByteArray {
|
||||
val dataSize = (SAMPLE_RATE * DURATION.inWholeMilliseconds / 1000 * 2).toInt()
|
||||
return ByteBuffer
|
||||
.allocate(44 + dataSize)
|
||||
.order(ByteOrder.LITTLE_ENDIAN)
|
||||
.put("RIFF".toByteArray())
|
||||
.putInt(36 + dataSize)
|
||||
.put("WAVEfmt ".toByteArray())
|
||||
.putInt(16)
|
||||
.putShort(1)
|
||||
.putShort(1)
|
||||
.putInt(SAMPLE_RATE)
|
||||
.putInt(SAMPLE_RATE * 2)
|
||||
.putShort(2)
|
||||
.putShort(16)
|
||||
.put("data".toByteArray())
|
||||
.putInt(dataSize)
|
||||
.array()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SAMPLE_RATE = 8_000
|
||||
const val TOLERANCE_MS = 100L
|
||||
val DURATION = 4.seconds
|
||||
val SRT =
|
||||
"""
|
||||
1
|
||||
00:00:01,000 --> 00:00:02,000
|
||||
First
|
||||
|
||||
2
|
||||
00:00:02,500 --> 00:00:03,500
|
||||
Last
|
||||
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
@file:OptIn(markerClass = [UnstableApi::class])
|
||||
|
||||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.Renderer
|
||||
import androidx.media3.exoplayer.RendererConfiguration
|
||||
import androidx.media3.exoplayer.RenderersFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.SinglePeriodTimeline
|
||||
import io.github.peerless2012.ass.media.AssHandler
|
||||
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
|
||||
import io.github.peerless2012.ass.media.render.AssRenderer
|
||||
import io.github.peerless2012.ass.media.type.AssRenderType
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, sdk = [30])
|
||||
class SubtitleDelayRendererTest {
|
||||
private val delegate = mockk<Renderer>(relaxed = true)
|
||||
private val renderer = SubtitleDelayRenderer(delegate)
|
||||
private val timeline = SinglePeriodTimeline(DURATION_US, true, false, false, null, MediaItem.EMPTY)
|
||||
private val mediaPeriodId = MediaSource.MediaPeriodId(timeline.getUidOfPeriod(0))
|
||||
|
||||
@Test
|
||||
fun shiftsEveryPositionByTheDelay() {
|
||||
for (delayUs in listOf(2_000_000L, -500_000L)) {
|
||||
val delegate = mockk<Renderer>(relaxed = true)
|
||||
val renderer = SubtitleDelayRenderer(delegate)
|
||||
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, delayUs)
|
||||
val positionUs = OFFSET_US + 5_000_000L
|
||||
val shiftedUs = positionUs - delayUs
|
||||
|
||||
enable(renderer, positionUs)
|
||||
renderer.render(positionUs, 7L)
|
||||
renderer.resetPosition(positionUs, true)
|
||||
renderer.getDurationToProgressUs(positionUs, 7L)
|
||||
renderer.supportsResetPositionWithoutKeyFrameReset(positionUs)
|
||||
|
||||
verify {
|
||||
delegate.enable(any(), any(), any(), shiftedUs, false, true, OFFSET_US, OFFSET_US, mediaPeriodId)
|
||||
delegate.render(shiftedUs, 7L)
|
||||
delegate.resetPosition(shiftedUs, true)
|
||||
delegate.getDurationToProgressUs(shiftedUs, 7L)
|
||||
delegate.supportsResetPositionWithoutKeyFrameReset(shiftedUs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delayMessageChangesTheShiftAndOtherMessagesPassThrough() {
|
||||
renderer.render(10_000_000L, 0L)
|
||||
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 250_000L)
|
||||
renderer.render(10_000_000L, 0L)
|
||||
renderer.handleMessage(Renderer.MSG_SET_VOLUME, 0.5f)
|
||||
|
||||
verify {
|
||||
delegate.render(10_000_000L, 0L)
|
||||
delegate.render(9_750_000L, 0L)
|
||||
delegate.handleMessage(Renderer.MSG_SET_VOLUME, 0.5f)
|
||||
}
|
||||
verify(exactly = 0) { delegate.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun endsAtThePeriodEndWithAPositiveDelay() {
|
||||
every { delegate.isEnded } returns false
|
||||
enable(renderer, OFFSET_US)
|
||||
renderer.setTimeline(timeline)
|
||||
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 2_000_000L)
|
||||
|
||||
renderer.render(OFFSET_US + DURATION_US - 1, 0L)
|
||||
assertFalse(renderer.isEnded)
|
||||
|
||||
renderer.render(OFFSET_US + DURATION_US, 0L)
|
||||
assertTrue(renderer.isEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherwiseMirrorsTheDelegateAtThePeriodEnd() {
|
||||
enable(renderer, OFFSET_US)
|
||||
renderer.setTimeline(timeline)
|
||||
for (delayUs in listOf(0L, -2_000_000L)) {
|
||||
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, delayUs)
|
||||
renderer.render(OFFSET_US + DURATION_US, 0L)
|
||||
for (delegateEnded in listOf(false, true)) {
|
||||
every { delegate.isEnded } returns delegateEnded
|
||||
assertEquals(delegateEnded, renderer.isEnded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotEndWithoutAKnownPeriodDuration() {
|
||||
every { delegate.isEnded } returns false
|
||||
val unknownDuration = SinglePeriodTimeline(C.TIME_UNSET, false, false, false, null, MediaItem.EMPTY)
|
||||
enable(renderer, OFFSET_US, MediaSource.MediaPeriodId(unknownDuration.getUidOfPeriod(0)))
|
||||
renderer.setTimeline(unknownDuration)
|
||||
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 2_000_000L)
|
||||
|
||||
renderer.render(OFFSET_US + DURATION_US * 10, 0L)
|
||||
|
||||
assertFalse(renderer.isEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun factoryWrapsOnlySubtitleRenderers() {
|
||||
val video = fakeRenderer(C.TRACK_TYPE_VIDEO)
|
||||
val audio = fakeRenderer(C.TRACK_TYPE_AUDIO)
|
||||
val text = fakeRenderer(C.TRACK_TYPE_TEXT)
|
||||
val base =
|
||||
mockk<RenderersFactory> {
|
||||
every { createRenderers(any(), any(), any(), any(), any()) } returns arrayOf(video, audio, text)
|
||||
}
|
||||
val factory =
|
||||
SubtitleDelayRenderersFactory(AssRenderersFactory(AssHandler(AssRenderType.OVERLAY_OPEN_GL), base))
|
||||
|
||||
val renderers =
|
||||
factory.createRenderers(Handler(Looper.getMainLooper()), mockk(), mockk(), mockk(), mockk())
|
||||
|
||||
assertSame(video, renderers[0])
|
||||
assertSame(audio, renderers[1])
|
||||
assertSame(text, (renderers[2] as SubtitleDelayRenderer).delegate)
|
||||
assertTrue((renderers.last() as SubtitleDelayRenderer).delegate is AssRenderer)
|
||||
assertEquals(4, renderers.size)
|
||||
}
|
||||
|
||||
private fun enable(
|
||||
renderer: Renderer,
|
||||
positionUs: Long,
|
||||
periodId: MediaSource.MediaPeriodId = mediaPeriodId,
|
||||
) = renderer.enable(
|
||||
RendererConfiguration.DEFAULT,
|
||||
arrayOf(),
|
||||
mockk(),
|
||||
positionUs,
|
||||
false,
|
||||
true,
|
||||
OFFSET_US,
|
||||
OFFSET_US,
|
||||
periodId,
|
||||
)
|
||||
|
||||
private fun fakeRenderer(trackType: Int) = mockk<Renderer>(relaxed = true) { every { this@mockk.trackType } returns trackType }
|
||||
|
||||
private companion object {
|
||||
const val OFFSET_US = 1_000_000_000_000L
|
||||
const val DURATION_US = 10_000_000L
|
||||
}
|
||||
}
|
||||
|
|
@ -3,16 +3,19 @@ package com.github.damontecres.wholphin.ui.playback
|
|||
import android.content.Context
|
||||
import 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<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
|
||||
fun `Direct play includes the opened live stream ID`() =
|
||||
runTest(testDispatcher) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import io.mockk.mockk
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.nio.file.Paths
|
||||
import kotlin.io.path.readText
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* The subtitle delay control shows for every player that can move the selected subtitles, and only
|
||||
* for those
|
||||
*/
|
||||
@Config(sdk = [34])
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SubtitleDelayControlTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
private val subtitleDelay = ApplicationProvider.getApplicationContext<Context>().getString(R.string.subtitle_delay)
|
||||
|
||||
@Test
|
||||
fun supportedUnlessTheSubtitlesAreBurnedIn() {
|
||||
val exoPlayer = mockk<ExoPlayer>()
|
||||
val cases =
|
||||
listOf(
|
||||
Triple(mockk<MpvPlayer>(), playback(EMBEDDED_INDEX), true),
|
||||
Triple(exoPlayer, playback(EMBEDDED_INDEX), true),
|
||||
Triple(exoPlayer, playback(EXTERNAL_INDEX), true),
|
||||
Triple(exoPlayer, playback(EMBEDDED_INDEX, burnedIn = true), false),
|
||||
Triple(exoPlayer, playback(TrackIndex.DISABLED, burnedIn = true), true),
|
||||
Triple(mockk<Player>(), playback(EMBEDDED_INDEX), false),
|
||||
)
|
||||
for ((player, playback, expected) in cases) {
|
||||
assertEquals(
|
||||
"${player.javaClass.simpleName} ${playback.subtitleIndex}",
|
||||
expected,
|
||||
subtitleDelaySupported(player, playback),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsListTheSubtitleDelayOnlyWhenEnabled() {
|
||||
val enabled = mutableStateOf(true)
|
||||
rule.setContent { WholphinTheme { Dialog(PlaybackDialogType.SETTINGS, enabled.value) {} } }
|
||||
rule.onNodeWithText(subtitleDelay).assertExists()
|
||||
|
||||
enabled.value = false
|
||||
rule.onNodeWithText(subtitleDelay).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delayButtonsReportTheChange() {
|
||||
val changes = mutableListOf<Duration>()
|
||||
rule.setContent { WholphinTheme { Dialog(PlaybackDialogType.SUBTITLE_DELAY, true) { changes.add(it) } } }
|
||||
|
||||
rule.onNodeWithText("+250ms").performSemanticsAction(SemanticsActions.OnClick)
|
||||
|
||||
assertEquals(listOf(250.milliseconds), changes)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Dialog(
|
||||
type: PlaybackDialogType,
|
||||
enableSubtitleDelay: Boolean,
|
||||
onChangeSubtitleDelay: (Duration) -> Unit,
|
||||
) = PlaybackDialog(
|
||||
enableSubtitleDelay = enableSubtitleDelay,
|
||||
enableVideoScale = true,
|
||||
type = type,
|
||||
settings =
|
||||
PlaybackSettings(
|
||||
showDebugInfo = false,
|
||||
audioIndex = null,
|
||||
audioStreams = listOf(),
|
||||
subtitleIndex = null,
|
||||
subtitleStreams = listOf(),
|
||||
playbackSpeed = 1f,
|
||||
contentScale = ContentScale.Fit,
|
||||
subtitleDelay = Duration.ZERO,
|
||||
hasSubtitleDownloadPermission = false,
|
||||
playbackSpeedEnabled = true,
|
||||
maxBitrate = 0L,
|
||||
),
|
||||
onDismissRequest = {},
|
||||
onControllerInteraction = {},
|
||||
onClickPlaybackDialogType = {},
|
||||
onPlaybackActionClick = {},
|
||||
onChangeSubtitleDelay = onChangeSubtitleDelay,
|
||||
)
|
||||
|
||||
/**
|
||||
* A playback of `external_subs.json`, a PlaybackInfo response whose subtitle streams carry the
|
||||
* server's delivery method, with [subtitleIndex] selected; [burnedIn] makes the server burn the
|
||||
* embedded subtitles into the video
|
||||
*/
|
||||
private fun playback(
|
||||
subtitleIndex: Int,
|
||||
burnedIn: Boolean = false,
|
||||
): CurrentPlayback {
|
||||
val resource = javaClass.classLoader!!.getResource("external_subs.json")
|
||||
var json = Paths.get(resource.toURI()).readText()
|
||||
if (burnedIn) {
|
||||
json = json.replace("\"DeliveryMethod\": \"Embed\"", "\"DeliveryMethod\": \"Encode\"")
|
||||
}
|
||||
return CurrentPlayback(
|
||||
item = mockk(),
|
||||
tracks = listOf(),
|
||||
backend = PlayerBackend.EXO_PLAYER,
|
||||
playMethod = PlayMethod.DIRECT_PLAY,
|
||||
playSessionId = null,
|
||||
liveStreamId = null,
|
||||
mediaSourceInfo = Json.decodeFromString<MediaSourceInfo>(json),
|
||||
subtitleIndex = subtitleIndex,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val EXTERNAL_INDEX = 0
|
||||
const val EMBEDDED_INDEX = 5
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import android.view.WindowManager
|
|||
import androidx.activity.ComponentActivity
|
||||
import androidx.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<TitleReadFacade>()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,31 @@ import org.robolectric.annotation.GraphicsMode
|
|||
class DiscoverTitleTimingTest {
|
||||
@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() {
|
||||
FakeConductarr().use { fake ->
|
||||
fake.localArt = true
|
||||
|
|
|
|||
|
|
@ -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<ComponentActivity>()
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
||||
/** Explicit destination stub exposes only the Discover-owned request and pick actions. */
|
||||
@Composable
|
||||
fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||
val title = state.title ?: return
|
||||
val focused = state.contentFocused
|
||||
Box(modifier.fillMaxSize().testTag("discover-title-destination-stub")) {
|
||||
DiscoverBackdrop(title, detail = true)
|
||||
Column(Modifier.offset(L.safeSide, L.detailCopyTop).width(L.detailCopyWidth)) {
|
||||
Text(title.title, color = C.fg, style = WolfskinTokens.Type.displayDetail, maxLines = 2)
|
||||
Spacer(Modifier.height(D.heroTitleGap))
|
||||
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()} · ${title.genres.take(2).joinToString(", ")}", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
Spacer(Modifier.height(D.heroMetaGap))
|
||||
if (state.isSignedIn) DiscoverRatings(title.ratings)
|
||||
Spacer(Modifier.height(D.heroMetaGap))
|
||||
Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3)
|
||||
Spacer(Modifier.height(D.heroOverviewGap))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
if (!state.canRequest) DiscoverButton("Try again", focused) { state.refresh() } else {
|
||||
val receipt = state.requests[title.key]?.firstOrNull { it.requester == state.shell.person.key }
|
||||
DiscoverButton(if (receipt == null) "Request" else "Requested · ${receipt.stage}", focused && state.titleButton == 0, glyph = if (receipt == null) "plus" else "clock") {
|
||||
state.titleButton = 0; state.key(ShellKey.Ok)
|
||||
}
|
||||
DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, glyph = "profile", primary = false) { state.openRequest(true) }
|
||||
if (state.isPick(title)) DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, glyph = if (state.isHidden(title)) "undo" else "hide", primary = false) { state.titleButton = 2; state.key(ShellKey.Ok) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import androidx.compose.ui.test.pressKey
|
|||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import 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) }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue