Candidate 3: merge polish-lane-d
This commit is contained in:
commit
4785fd8d5e
13 changed files with 820 additions and 78 deletions
|
|
@ -1,6 +1,9 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import org.junit.Rule
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -35,6 +38,7 @@ import java.util.UUID
|
|||
@Config(sdk = [34], application = Application::class)
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
class TitlePlaybackNavigationTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001")
|
||||
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002")
|
||||
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||
|
|
@ -110,24 +114,36 @@ class TitlePlaybackNavigationTest {
|
|||
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
|
||||
val session = ConductarrSession(MemoryCacheStore())
|
||||
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||
rule.mainClock.autoAdvance = false
|
||||
rule.setContent { model.title?.let { PlaybackHandoffVeil(it.playbackStart) } }
|
||||
fun finishHandoff() {
|
||||
rule.waitForIdle()
|
||||
assertFalse("Preparation alone cannot navigate before black", navigation.backStack.last() is Destination.Playback)
|
||||
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.base.toLong() + 48)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
try {
|
||||
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
|
||||
advanceUntilIdle()
|
||||
model.title!!.click("header", 0)
|
||||
advanceUntilIdle()
|
||||
finishHandoff()
|
||||
assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last())
|
||||
navigation.goBack()
|
||||
model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" })
|
||||
advanceUntilIdle()
|
||||
finishHandoff()
|
||||
assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last())
|
||||
navigation.goBack()
|
||||
model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false)
|
||||
advanceUntilIdle()
|
||||
model.title!!.click("header", 0)
|
||||
advanceUntilIdle()
|
||||
finishHandoff()
|
||||
assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last())
|
||||
coVerify(exactly = 1) { facade.preparePlayback(seriesId) }
|
||||
} finally {
|
||||
rule.mainClock.autoAdvance = true
|
||||
model.viewModelScope.cancel()
|
||||
session.close()
|
||||
Dispatchers.resetMain()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,481 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/** Native title transitions preserve focus, stream identity and immediate cached content. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class TitlePolishTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private var model: WolfskinTitleViewModel? = null
|
||||
private var session: ConductarrSession? = null
|
||||
private lateinit var state: TitleState
|
||||
private lateinit var shell: ShellState
|
||||
private val reduced = mutableStateOf(false)
|
||||
private val entryActive = mutableStateOf(true)
|
||||
private val samples = mutableListOf<String>()
|
||||
private var bitmaps: Map<String, ImageBitmap> = emptyMap()
|
||||
private val person = ShellPerson("justin", "Justin", "J", false)
|
||||
private val id = UUID.fromString("00000000-0000-0000-0000-000000000091")
|
||||
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||
|
||||
@After fun dispose() { model?.viewModelScope?.cancel(); session?.close() }
|
||||
private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||
private fun prepareShell() {
|
||||
shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination(titleRoute(id.toString())); navFocused = false }
|
||||
}
|
||||
private fun render(data: TitlePageData = fixture().title("Locke", "justin"), reducedMotion: Boolean = false) {
|
||||
reduced.value = reducedMotion
|
||||
prepareShell()
|
||||
state = TitleState(data, person) { }
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
|
||||
TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp))
|
||||
} } }
|
||||
rule.waitForIdle()
|
||||
rule.mainClock.advanceTimeBy(600)
|
||||
rule.mainClock.autoAdvance = false
|
||||
}
|
||||
private fun mountModel(facade: TitleReadFacade, data: TitlePageData, reducedMotion: Boolean) : NavigationManager {
|
||||
reduced.value = reducedMotion
|
||||
prepareShell()
|
||||
coEvery { facade.readSaved(id, any()) } returns null
|
||||
coEvery { facade.read(id, any(), any()) } returns data
|
||||
val navigation = NavigationManager().apply { navigateTo(Destination.Wolfskin(titleRoute(id.toString()))) }
|
||||
session = ConductarrSession(MemoryCacheStore())
|
||||
model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session!!,
|
||||
mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalTitleBitmaps provides bitmaps) { WolfskinTheme {
|
||||
val route = titleRoute(id.toString())
|
||||
ScreenEntry(shell, route, entryActive.value && navigation.backStack.lastOrNull() == Destination.Wolfskin(route)) {
|
||||
TitleDestination(route, shell, preferences, Modifier.requiredSize(960.dp, 540.dp), model!!, fixtures = false)
|
||||
}
|
||||
} } }
|
||||
rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); model!!.title?.data?.status == TitleReadStatus.Ready }
|
||||
state = model!!.title!!
|
||||
rule.waitForIdle()
|
||||
rule.mainClock.advanceTimeBy(600)
|
||||
rule.mainClock.autoAdvance = false
|
||||
return navigation
|
||||
}
|
||||
private fun tick() { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
|
||||
private fun path(idea: String, name: String, index: Int): File = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")),
|
||||
"ui-polish/$idea/${if (reduced.value) "reduced-" else ""}$name-${index.toString().padStart(2, '0')}.png").also { requireNotNull(it.parentFile).mkdirs() }
|
||||
private fun frame(idea: String, name: String, index: Int, sample: (android.graphics.Bitmap) -> Int): Int {
|
||||
val file = path(idea, name, index)
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
return try { sample(bitmap).also { samples.add("$name,${index * 16},$it") } } finally { bitmap.recycle() }
|
||||
}
|
||||
private fun brightness(bitmap: android.graphics.Bitmap, x: Int, y: Int): Int = bitmap.getPixel(x, y).let {
|
||||
android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it)
|
||||
}
|
||||
private fun saveSamples(idea: String) {
|
||||
File(path(idea, "samples", 0).parentFile, "${if (reduced.value) "reduced-" else ""}${samples.first().substringBefore(',')}-samples.csv")
|
||||
.writeText("phase,timeMs,value\n" + samples.joinToString("\n"))
|
||||
}
|
||||
private fun monotonic(values: List<Int>) {
|
||||
assertNotEquals("Motion changes visible pixels", values.first(), values.last())
|
||||
val direction = if (values.last() > values.first()) 1 else -1
|
||||
values.zipWithNext().forEach { (before, after) -> assertTrue("No reversal: $values", (after - before) * direction >= -1) }
|
||||
}
|
||||
|
||||
@Test fun versionSheetFadesWithScrimAndReturnsFocusOnce() = versionSheet(false)
|
||||
@Test fun reducedVersionSheetFadesWithScrimAndReturnsFocusOnce() = versionSheet(true)
|
||||
private fun versionSheet(reducedMotion: Boolean) {
|
||||
render(fixture().title("Locke", "justin"), reducedMotion)
|
||||
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "version" }) }
|
||||
rule.onRoot().performKeyInput { pressKey(androidx.compose.ui.input.key.Key.DirectionCenter) }
|
||||
val duration = if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.sheetIn
|
||||
val scrim = mutableListOf<Int>()
|
||||
val panel = mutableListOf<Int>()
|
||||
repeat(duration / 16 + 3) { index ->
|
||||
frame("P06", "title-version", index) { bitmap ->
|
||||
scrim.add(brightness(bitmap, 400, 900)); brightness(bitmap, 1900, 900).also { panel.add(it) }
|
||||
}
|
||||
tick()
|
||||
}
|
||||
monotonic(scrim); monotonic(panel)
|
||||
val opener = state.column
|
||||
rule.onRoot().performKeyInput { pressKey(androidx.compose.ui.input.key.Key.Escape) }
|
||||
val owner = hasTestTag("title-page") or hasTestTag("title-option-version")
|
||||
val closing = mutableListOf<Int>()
|
||||
repeat((if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.sheetOut) / 16 + 3) { index ->
|
||||
closing.add(frame("P06", "title-version-out", index) { brightness(it, 1900, 900) })
|
||||
if (index == 2) {
|
||||
rule.waitForIdle()
|
||||
rule.runOnIdle { }
|
||||
rule.onNode(owner and isFocused(), useUnmergedTree = true).assertExists()
|
||||
rule.onNodeWithTag("title-sheet", useUnmergedTree = true)
|
||||
.assert(SemanticsMatcher.keyNotDefined(androidx.compose.ui.semantics.SemanticsProperties.Focused))
|
||||
rule.onAllNodes(hasAnyAncestor(hasTestTag("title-sheet")) and
|
||||
SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsProperties.Focused), useUnmergedTree = true).assertCountEquals(0)
|
||||
}
|
||||
assertNull(state.sheet); assertEquals(opener, state.column); assertFalse(shell.navFocused)
|
||||
tick()
|
||||
}
|
||||
monotonic(closing)
|
||||
rule.onNodeWithTag("title-sheet").assertDoesNotExist()
|
||||
rule.onNode(owner and isFocused(), useUnmergedTree = true).assertExists()
|
||||
saveSamples("P06")
|
||||
}
|
||||
|
||||
private fun pendingPlayback(reducedMotion: Boolean): Triple<TitleReadFacade, CompletableDeferred<BaseItemDto?>, NavigationManager> {
|
||||
val facade = mockk<TitleReadFacade>()
|
||||
val prepared = CompletableDeferred<BaseItemDto?>()
|
||||
coEvery { facade.preparePlayback(id) } coAnswers { prepared.await() }
|
||||
val data = fixture().title("Locke", "justin")
|
||||
val target = data.item.copy(id = id.toString())
|
||||
val navigation = mountModel(facade, data.copy(item = target, playTarget = target, watchTarget = target,
|
||||
related = listOf(TitleItem("other", "Other title", "movie"))), reducedMotion)
|
||||
return Triple(facade, prepared, navigation)
|
||||
}
|
||||
|
||||
@Test fun playSaysStartingThenFadesToBlackBeforeThePlayer() = playback(false)
|
||||
@Test fun reducedPlaySaysStartingThenFadesToBlackBeforeThePlayer() = playback(true)
|
||||
private fun playback(reducedMotion: Boolean) {
|
||||
val (facade, prepared, navigation) = pendingPlayback(reducedMotion)
|
||||
val resting = rule.onNodeWithTag("title-option-play").fetchSemanticsNode().boundsInRoot
|
||||
rule.runOnIdle { state.click("header", 0) }
|
||||
repeat(13) { index -> frame("E04", "title-start", index) { brightness(it, 100, 900) }; tick() }
|
||||
rule.onNodeWithText("Starting…").assertIsDisplayed()
|
||||
assertEquals(resting.width, rule.onNodeWithTag("title-option-play").fetchSemanticsNode().boundsInRoot.width, .1f)
|
||||
rule.runOnIdle { state.click("header", 0) }
|
||||
coVerify(exactly = 1) { facade.preparePlayback(id) }
|
||||
rule.runOnIdle { prepared.complete(BaseItemDto(id = id, type = BaseItemKind.MOVIE)) }
|
||||
rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.playbackStart.launch != null }
|
||||
assertFalse(navigation.backStack.last() is Destination.Playback)
|
||||
val values = mutableListOf<Int>()
|
||||
val duration = if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.base
|
||||
repeat(duration / 16 + 3) { index ->
|
||||
values.add(frame("E04", "title-veil", index) { brightness(it, 100, 900) })
|
||||
if (index * 16 < duration) assertFalse(navigation.backStack.last() is Destination.Playback)
|
||||
tick()
|
||||
}
|
||||
assertEquals(Destination.Playback(id, 0L), navigation.backStack.last())
|
||||
assertEquals(1, navigation.backStack.count { it is Destination.Playback })
|
||||
assertTrue("The handoff reaches black before navigation", values.contains(0))
|
||||
assertNull(state.playbackStart.starting)
|
||||
rule.runOnIdle { navigation.goBack() }
|
||||
coEvery { facade.preparePlayback(id) } throws IllegalStateException("offline")
|
||||
rule.runOnIdle { state.click("header", 0) }
|
||||
rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.toast == RecoveryMessage.PlaybackUnavailable }
|
||||
repeat(14) { tick() }
|
||||
rule.onNodeWithText("Starting…").assertDoesNotExist()
|
||||
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
|
||||
assertFalse(navigation.backStack.last() is Destination.Playback)
|
||||
saveSamples("E04")
|
||||
}
|
||||
|
||||
@Test fun progressFillsToTheNewPointAfterPlayback() = progress(false)
|
||||
@Test fun reducedProgressSnapsToTheNewPointAfterPlayback() = progress(true)
|
||||
@Test fun lateRuntimeMetadataAnimatesAnExistingResumeTrack() = progress(false, runtimeKnown = false)
|
||||
@Test fun reducedLateRuntimeMetadataSnapsAnExistingResumeTrack() = progress(true, runtimeKnown = false)
|
||||
private fun progress(reducedMotion: Boolean, runtimeKnown: Boolean = true) {
|
||||
val data = fixture().title("Locke", "justin")
|
||||
val target = data.item.copy(runtimeSeconds = if (runtimeKnown) 1000 else 0, resumeSeconds = 134)
|
||||
val from = if (runtimeKnown) .134f else 0f
|
||||
val phase = if (runtimeKnown) "title-progress" else "title-progress-late-runtime"
|
||||
render(data.copy(playTarget = target), reducedMotion)
|
||||
val bounds = rule.onNodeWithTag("title-progress").fetchSemanticsNode().boundsInRoot
|
||||
val width = (WolfskinTokens.Title.progressWidth.value * 2).toInt()
|
||||
fun edge(bitmap: android.graphics.Bitmap): Int = (0 until width).lastOrNull { x ->
|
||||
bitmap.getPixel(bounds.left.toInt() + x, bounds.center.y.toInt()) == WolfskinTokens.Color.accent.toArgb()
|
||||
}?.plus(1) ?: 0
|
||||
val initial = frame("E06", "$phase-initial", 0, ::edge)
|
||||
assertEquals(width * from, initial.toFloat(), 1f)
|
||||
rule.runOnIdle { state.update(state.data.copy(playTarget = target.copy(runtimeSeconds = 1000, resumeSeconds = 419))) }
|
||||
val values = List(23) { index ->
|
||||
frame("E06", phase, index, ::edge).also {
|
||||
if (index == 1) rule.onNodeWithText("10 min left").assertIsDisplayed()
|
||||
tick()
|
||||
}
|
||||
}
|
||||
assertEquals(width * .419f, values.last().toFloat(), 1f)
|
||||
if (reducedMotion) assertEquals(values.last(), values[1]) else {
|
||||
monotonic(values)
|
||||
assertEquals(width * (from + .419f) / 2f, values[11].toFloat(), width * .06f)
|
||||
}
|
||||
rule.onNodeWithText("10 min left").assertIsDisplayed()
|
||||
rule.runOnIdle { state.update(state.data.copy(playTarget = target.copy(id = "other", runtimeSeconds = 1000, resumeSeconds = 800))) }
|
||||
tick()
|
||||
assertEquals(width * .8f, frame("E06", "title-new-target", 0, ::edge).toFloat(), 1f)
|
||||
saveSamples("E06")
|
||||
}
|
||||
|
||||
@Test fun cardProgressFillsAfterPlaybackWithoutAnimatingItsFirstPaint() = cardProgress(false)
|
||||
@Test fun reducedCardProgressSnapsAfterPlayback() = cardProgress(true)
|
||||
private fun cardProgress(reducedMotion: Boolean) {
|
||||
reduced.value = reducedMotion
|
||||
val title = HomeTitle("card", "Card", "movie", runtimeSeconds = 1000, resumeSeconds = 134.0, held = true)
|
||||
val row = mutableStateOf(HomeRow("resume", HomeRowKind.Shelf, cards = listOf(title)))
|
||||
val memory = HomeViewMemory()
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { HomeShelf(row.value, memory, false, 0, emptyList(), {}, {}) }
|
||||
} } }
|
||||
rule.waitForIdle(); rule.mainClock.advanceTimeBy(600); rule.mainClock.autoAdvance = false
|
||||
val bounds = rule.onNodeWithTag("home-card:resume:0").fetchSemanticsNode().boundsInRoot
|
||||
val y = (bounds.bottom - (WolfskinTokens.CardMetadata.bottom.value + WolfskinTokens.CardMetadata.resumeLine.value / 2) * 2).toInt()
|
||||
fun edge(bitmap: android.graphics.Bitmap): Int = (0 until bounds.width.toInt()).lastOrNull { x ->
|
||||
bitmap.getPixel(bounds.left.toInt() + x, y) == WolfskinTokens.Color.accent.toArgb()
|
||||
}?.plus(1) ?: 0
|
||||
assertEquals(bounds.width * .134f, frame("E06", "card-progress-initial", 0, ::edge).toFloat(), 1f)
|
||||
rule.runOnIdle { row.value = row.value.copy(cards = listOf(title.copy(resumeSeconds = 419.0))) }
|
||||
val values = List(23) { index -> frame("E06", "card-progress", index, ::edge).also { tick() } }
|
||||
assertEquals(bounds.width * .419f, values.last().toFloat(), 1f)
|
||||
if (reducedMotion) assertEquals(values.last(), values[1]) else monotonic(values)
|
||||
saveSamples("E06")
|
||||
}
|
||||
|
||||
@Test fun seasonCapsuleGlidesAndCachedSeasonSkipsSkeleton() = seasons(false)
|
||||
@Test fun reducedSeasonCapsuleSnapsAndCachedSeasonSkipsSkeleton() = seasons(true)
|
||||
private fun seasons(reducedMotion: Boolean) {
|
||||
val facade = mockk<TitleReadFacade>()
|
||||
val series = TitleItem(id.toString(), "Series", "tv", held = true, seasons = 6)
|
||||
fun image(color: Int) = android.graphics.Bitmap.createBitmap(2, 2, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
.apply { eraseColor(color) }.asImageBitmap()
|
||||
bitmaps = mapOf("cache:fifth" to image(android.graphics.Color.RED), "cache:sixth" to image(android.graphics.Color.GREEN))
|
||||
val fifth = TitleItem("fifth", "Cached episode", "episode", season = 5, episode = 1, backdrop = "cache:fifth")
|
||||
val sixth = fifth.copy(id = "sixth", name = "New episode", season = 6, backdrop = "cache:sixth")
|
||||
val refresh = CompletableDeferred<TitlePageData>()
|
||||
val data = TitlePageData(series, selectedSeason = 5, episodes = listOf(fifth))
|
||||
val navigation = mountModel(facade, data, reducedMotion)
|
||||
coEvery { facade.read(id, 6, any()) } returns data.copy(selectedSeason = 6, episodes = listOf(sixth))
|
||||
coEvery { facade.read(id, 5, any()) } coAnswers { refresh.await() }
|
||||
rule.runOnIdle { state.focus("seasons", 4) }
|
||||
repeat(25) { tick() }
|
||||
rule.runOnIdle { state.click("seasons", 5); state.focus("seasons", 0) }
|
||||
val row = rule.onNode(hasText("Season 6") and hasClickAction()).fetchSemanticsNode().boundsInRoot
|
||||
fun left(bitmap: android.graphics.Bitmap): Int = (0 until bitmap.width).firstOrNull { x ->
|
||||
val pixel = bitmap.getPixel(x, (row.top + 4).toInt())
|
||||
android.graphics.Color.red(pixel) > 180 && android.graphics.Color.green(pixel) < 210 && android.graphics.Color.blue(pixel) > 230
|
||||
} ?: -1
|
||||
val values = List(23) { index -> frame("E07", "season", index, ::left).also { tick() } }
|
||||
assertTrue(values.all { it >= 0 })
|
||||
if (reducedMotion) assertEquals(values.last(), values[1]) else monotonic(values)
|
||||
values.forEach { assertTrue("No capsule overshoot", it <= values.last() + 1) }
|
||||
rule.runOnIdle { state.click("seasons", 4) }
|
||||
assertEquals("Cached episodes are installed synchronously", listOf(fifth), state.data.episodes)
|
||||
assertEquals(TitleReadStatus.Ready, state.data.episodesStatus)
|
||||
tick()
|
||||
// Frame 0 may retain the outgoing real cards; the first incoming subtree is due by frame 1.
|
||||
repeat(4) { index ->
|
||||
assertEquals(listOf(fifth), state.data.episodes)
|
||||
assertEquals(TitleReadStatus.Ready, state.data.episodesStatus)
|
||||
val slot = hasTestTag("title-episode-slot-0")
|
||||
rule.onAllNodes(slot, useUnmergedTree = true).assertCountEquals(1)
|
||||
rule.onNode(slot and hasAnyDescendant(hasClickAction()), useUnmergedTree = true).assertExists()
|
||||
rule.onAllNodes(hasText("Cached episode") or hasText("New episode"), useUnmergedTree = true).assertCountEquals(1)
|
||||
val bounds = rule.onNode(slot, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
val pixel = frame("E07", "cached-return", index) { bitmap ->
|
||||
val pixels = (1..3).map { part ->
|
||||
bitmap.getPixel((bounds.left + bounds.width * part / 4).toInt(), (bounds.top + 24).toInt())
|
||||
}
|
||||
assertTrue("Every frame has opaque real episode art, never a skeleton, blank row or partial alpha",
|
||||
pixels.all { it == android.graphics.Color.RED || it == android.graphics.Color.GREEN })
|
||||
assertEquals("The episode artwork stays uniformly opaque", 1, pixels.distinct().size)
|
||||
pixels.first()
|
||||
}
|
||||
if (index >= 1) {
|
||||
assertEquals("The first incoming frame paints cached Season 5", android.graphics.Color.RED, pixel)
|
||||
rule.onNode(hasText("Cached episode") and hasAnyAncestor(slot), useUnmergedTree = true).assertExists()
|
||||
}
|
||||
tick()
|
||||
}
|
||||
rule.onNode(hasClickAction() and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true)
|
||||
.performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() }
|
||||
assertEquals(Destination.Wolfskin(titleRoute(fifth.id)), navigation.backStack.last())
|
||||
rule.runOnIdle { navigation.goBack() }
|
||||
rule.runOnIdle { refresh.completeExceptionally(IllegalStateException("offline")) }
|
||||
rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.toast == RecoveryMessage.StillUnreachable }
|
||||
assertEquals(listOf(fifth), state.data.episodes)
|
||||
saveSamples("E07")
|
||||
}
|
||||
|
||||
@Test fun titleScrollKeepsTheCastPinnedWithoutChangingFocus() = titleScroll(false)
|
||||
@Test fun reducedTitleScrollSnapsWithoutChangingFocus() = titleScroll(true)
|
||||
private fun titleScroll(reducedMotion: Boolean) {
|
||||
val data = fixture().title("Andor", "justin")
|
||||
render(data.copy(episodes = data.episodes.distinctBy { it.id }), reducedMotion)
|
||||
rule.runOnIdle { state.focus("cast", 0) }
|
||||
val positions = mutableListOf<Int>()
|
||||
repeat(26) { index ->
|
||||
frame("E05", "title-scroll", index) { brightness(it, 400, 900) }
|
||||
val offset = shell.view.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "cast" }?.offset
|
||||
if (offset != null) positions.add(offset)
|
||||
tick()
|
||||
}
|
||||
rule.mainClock.advanceTimeUntil(2000) { !shell.view.listState.isScrollInProgress }
|
||||
rule.waitForIdle()
|
||||
val settled = shell.view.listState.layoutInfo.visibleItemsInfo.first { it.key == "cast" }.offset
|
||||
assertEquals((WolfskinTokens.Layout.pinnedLine.value * 2).toInt(), settled)
|
||||
if (reducedMotion) assertEquals(positions.last(), positions.first())
|
||||
else positions.zipWithNext().forEach { (before, after) -> assertTrue(after <= before + 1) }
|
||||
assertEquals("cast", state.row); assertEquals(0, state.column); assertFalse(shell.navFocused)
|
||||
saveSamples("E05")
|
||||
}
|
||||
|
||||
@Test fun personScrollKeepsTheSelectedCreditRowPinned() = personScroll(false)
|
||||
@Test fun reducedPersonScrollSnapsToTheSelectedCreditRow() = personScroll(true)
|
||||
private fun personScroll(reducedMotion: Boolean) {
|
||||
reduced.value = reducedMotion
|
||||
prepareShell()
|
||||
val person = PersonState(TitlePerson("actor", "Actor"), List(30) { TitleItem("credit-$it", "Credit $it", "movie") }, onAction = { })
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
|
||||
PersonSurface(person, shell, Modifier.requiredSize(960.dp, 540.dp))
|
||||
} } }
|
||||
rule.waitForIdle(); rule.mainClock.advanceTimeBy(600); rule.mainClock.autoAdvance = false
|
||||
rule.runOnIdle { person.index = 24 }
|
||||
val positions = mutableListOf<Int>()
|
||||
repeat(26) { index ->
|
||||
frame("E05", "person-scroll", index) { brightness(it, 400, 900) }
|
||||
val offset = shell.view.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "credit-24" }?.offset
|
||||
if (offset != null) positions.add(offset)
|
||||
tick()
|
||||
}
|
||||
rule.mainClock.advanceTimeUntil(2000) { !shell.view.listState.isScrollInProgress }
|
||||
rule.waitForIdle()
|
||||
val settled = shell.view.listState.layoutInfo.visibleItemsInfo.first { it.key == "credit-24" }.offset
|
||||
assertEquals((WolfskinTokens.Layout.pinnedLine.value * 2).toInt(), settled)
|
||||
if (reducedMotion) assertEquals(positions.last(), positions.first())
|
||||
else positions.zipWithNext().forEach { (before, after) -> assertTrue(after <= before + 1) }
|
||||
assertEquals(24, person.index); assertFalse(shell.navFocused)
|
||||
saveSamples("E05")
|
||||
}
|
||||
|
||||
@Test fun cardFirstPaintUsesTheCurrentFractionBeforeAnyAnimationFrame() {
|
||||
rule.mainClock.autoAdvance = false
|
||||
rule.setContent { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||
TitleCard(false, Modifier.testTag("first-card"), progress = .134f) { }
|
||||
}
|
||||
} }
|
||||
rule.waitForIdle()
|
||||
val bounds = rule.onNodeWithTag("first-card").fetchSemanticsNode().boundsInRoot
|
||||
val y = (bounds.bottom - (WolfskinTokens.CardMetadata.bottom.value + WolfskinTokens.CardMetadata.resumeLine.value / 2) * 2).toInt()
|
||||
val edge = frame("E06", "card-first-paint", 0) { bitmap ->
|
||||
(0 until bounds.width.toInt()).lastOrNull { bitmap.getPixel(bounds.left.toInt() + it, y) == WolfskinTokens.Color.accent.toArgb() }?.plus(1) ?: 0
|
||||
}
|
||||
assertEquals(bounds.width * .134f, edge.toFloat(), 1f)
|
||||
}
|
||||
|
||||
@Test fun seasonCapsuleRetargetsWithoutStoppingBetweenChoices() = retargetSeason(false)
|
||||
@Test fun reducedSeasonCapsuleRetargetsInOneFrame() = retargetSeason(true)
|
||||
private fun retargetSeason(reducedMotion: Boolean) {
|
||||
val series = TitleItem("repeat", "Series", "tv", held = true, seasons = 8)
|
||||
render(TitlePageData(series, selectedSeason = 3), reducedMotion)
|
||||
rule.runOnIdle { state.focus("seasons", 0) }
|
||||
repeat(25) { tick() }
|
||||
val row = rule.onNode(hasText("Season 5") and hasClickAction()).fetchSemanticsNode().boundsInRoot
|
||||
fun left(bitmap: android.graphics.Bitmap): Int = (0 until bitmap.width).firstOrNull { x ->
|
||||
val pixel = bitmap.getPixel(x, (row.top + 4).toInt())
|
||||
android.graphics.Color.red(pixel) > 180 && android.graphics.Color.green(pixel) < 210 && android.graphics.Color.blue(pixel) > 230
|
||||
} ?: -1
|
||||
rule.runOnIdle { state.click("seasons", 3); state.focus("seasons", 0) }
|
||||
val first = List(6) { index -> frame("E07", "season-repeat-first", index, ::left).also { tick() } }
|
||||
rule.runOnIdle { state.click("seasons", 4); state.focus("seasons", 0) }
|
||||
val second = List(23) { index -> frame("E07", "season-repeat-second", index, ::left).also { tick() } }
|
||||
assertTrue(first.all { it >= 0 } && second.all { it >= 0 })
|
||||
if (reducedMotion) assertEquals(second.last(), second[1]) else {
|
||||
val before = first.last() - first[first.lastIndex - 1]
|
||||
val after = second[2] - second[1]
|
||||
assertTrue("A moving capsule cannot restart from zero velocity: before=$before, after=$after", before > 0 && after >= before * .75f)
|
||||
second.zipWithNext().forEach { (previous, next) -> assertTrue(next >= previous - 1) }
|
||||
}
|
||||
saveSamples("E07")
|
||||
}
|
||||
|
||||
@Test fun backCancelsPreparationWithoutALateFailureToast() = cancelPreparation(false)
|
||||
@Test fun reducedBackCancelsPreparationWithoutALateFailureToast() = cancelPreparation(true)
|
||||
private fun cancelPreparation(reducedMotion: Boolean) {
|
||||
val (_, prepared, navigation) = pendingPlayback(reducedMotion)
|
||||
rule.runOnIdle { state.click("header", 0) }
|
||||
tick()
|
||||
rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "version" }) }
|
||||
tick()
|
||||
rule.runOnIdle { state.back(shell) }
|
||||
assertNull(state.playbackStart.starting)
|
||||
assertNull(state.sheet)
|
||||
rule.runOnIdle { prepared.completeExceptionally(IllegalStateException("late failure")) }
|
||||
repeat(24) { tick() }
|
||||
assertNull(state.toast)
|
||||
assertFalse(navigation.backStack.last() is Destination.Playback)
|
||||
rule.onNodeWithText("Starting…").assertDoesNotExist()
|
||||
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test fun leavingTheTitleEntryCancelsPreparationWithoutALatePlayer() = cancelOwnership(false)
|
||||
@Test fun reducedLeavingTheTitleEntryCancelsPreparationWithoutALatePlayer() = cancelOwnership(true)
|
||||
private fun cancelOwnership(reducedMotion: Boolean) {
|
||||
val (facade, initial, navigation) = pendingPlayback(reducedMotion)
|
||||
var prepared = initial
|
||||
coEvery { facade.preparePlayback(id) } coAnswers { prepared.await() }
|
||||
for (inactive in listOf(true, false)) {
|
||||
rule.runOnIdle { state.click("header", 0) }
|
||||
tick()
|
||||
rule.runOnIdle { if (inactive) entryActive.value = false else state.click("related", 0) }
|
||||
tick()
|
||||
rule.mainClock.advanceTimeUntil(timeoutMillis = 80) {
|
||||
!rule.onNodeWithTag("title-page").fetchSemanticsNode().config
|
||||
.contains(androidx.compose.ui.semantics.SemanticsProperties.Focused)
|
||||
}
|
||||
val destination = navigation.backStack.last()
|
||||
assertNull("The rendered entry has lost ownership (inactive=$inactive)", state.playbackStart.starting)
|
||||
rule.runOnIdle { prepared.complete(BaseItemDto(id = id, type = BaseItemKind.MOVIE)) }
|
||||
repeat(24) { tick() }
|
||||
assertEquals("A late prepared stream cannot take over the new page", destination, navigation.backStack.last())
|
||||
assertFalse(destination is Destination.Playback)
|
||||
assertNull(state.toast)
|
||||
rule.onNodeWithText("Starting…").assertDoesNotExist()
|
||||
rule.runOnIdle {
|
||||
if (inactive) { entryActive.value = true; prepared = CompletableDeferred() } else navigation.goBack()
|
||||
}
|
||||
tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -197,10 +197,10 @@ class TitleRound2Test {
|
|||
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
|
||||
menu()
|
||||
rule.mainClock.autoAdvance = false
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-info-motion").apply { mkdirs() }
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P06/menu-to-info").apply { mkdirs() }
|
||||
rule.runOnIdle { state.clickSheet(0) }
|
||||
var waited = 0
|
||||
while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
|
||||
while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.sheetIn + 16) {
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.waitForIdle()
|
||||
waited += 16
|
||||
|
|
@ -212,7 +212,7 @@ class TitleRound2Test {
|
|||
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
|
||||
fun brightness(x: Int, y: Int): Int {
|
||||
val pixel = bitmap.getPixel(x, y)
|
||||
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||
return (android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)) / 3
|
||||
}
|
||||
return try {
|
||||
(brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") }
|
||||
|
|
@ -222,7 +222,10 @@ class TitleRound2Test {
|
|||
rule.waitForIdle()
|
||||
frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
|
||||
}
|
||||
info.forEach { assertEquals("No extra scrim or disappearing overlay may darken or pop over Media info", info.last(), it) }
|
||||
info.forEach { assertEquals("Media info keeps one full scrim on every frame", info.last().second, it.second) }
|
||||
info.zipWithNext().forEach { (before, after) -> assertTrue("The panel fades monotonically instead of popping", after.first >= before.first - 1) }
|
||||
assertNotEquals("The Media info panel visibly fades", info.first().first, info.last().first)
|
||||
assertEquals("The panel settles by sheetIn plus one frame", info.last().first, info[(WolfskinTokens.Motion.sheetIn / 16 + 1).coerceAtMost(info.lastIndex)].first)
|
||||
rule.runOnIdle { state.back(shell) }
|
||||
repeat(14) { index ->
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import org.justinvisser.wolfskin.design.ShellState
|
|||
import org.justinvisser.wolfskin.design.TitleItem
|
||||
import org.justinvisser.wolfskin.design.TitlePageData
|
||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
|
@ -175,6 +176,8 @@ class TitleThemeMusicTest {
|
|||
assertEquals(listOf("play:$seriesId"), playback.events)
|
||||
playback.onStop = { if (playback.playing != null) assertEquals(destination, navigation.backStack.last()) }
|
||||
rule.runOnIdle { titleModel.title!!.click("header", 0) }
|
||||
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.base.toLong() + 48)
|
||||
rule.waitForIdle()
|
||||
rule.waitUntil(5_000) { navigation.backStack.last() is Destination.Playback }
|
||||
playback.onStop = {}
|
||||
rule.waitForIdle()
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -83,19 +84,38 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
private var personReadPending = false
|
||||
private var personReadFinished = false
|
||||
private var loadJob: Job? = null
|
||||
private var playbackJob: Job? = null
|
||||
private var playbackGeneration = 0
|
||||
private val seasonEpisodes = mutableMapOf<Int, List<TitleItem>>()
|
||||
private val saveMutex = Mutex()
|
||||
private var performerPage = 1
|
||||
var conductarrAction: ((TitleAction) -> Unit)? = null
|
||||
val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}"
|
||||
|
||||
fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route))
|
||||
fun popRoute() = navigation.goBack()
|
||||
fun openRoute(route: String) {
|
||||
cancelPlayback()
|
||||
navigation.navigateTo(Destination.Wolfskin(route))
|
||||
}
|
||||
fun popRoute() {
|
||||
cancelPlayback()
|
||||
navigation.goBack()
|
||||
}
|
||||
/** Preparation belongs to the active entry, including when Back closes an overlay. */
|
||||
internal fun cancelPlayback() {
|
||||
playbackJob?.cancel()
|
||||
playbackJob = null
|
||||
title?.playbackStart?.fail()
|
||||
}
|
||||
private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) }
|
||||
|
||||
/** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */
|
||||
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
||||
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
||||
val sameTitle = route == destination && ::viewer.isInitialized && viewer.key == selected.key && !destination.startsWith("person/")
|
||||
if (!sameTitle) {
|
||||
cancelPlayback()
|
||||
seasonEpisodes.clear()
|
||||
}
|
||||
val readyTitle = sameTitle && title?.data?.status == TitleReadStatus.Ready
|
||||
val fixtureRefresh = title?.takeIf { force && readyTitle && fixture != null && fixtures && BuildConfig.DEBUG }
|
||||
if (force && readyTitle && fixture == null &&
|
||||
|
|
@ -117,6 +137,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
preferences = userPreferences
|
||||
val id = Uri.decode(destination.substringAfter('/'))
|
||||
if (fixtureRefresh == null) {
|
||||
cancelPlayback()
|
||||
// Losing authorization clears the old data, but is not another first-load skeleton.
|
||||
val status = if (sameTitle && title?.data?.status != TitleReadStatus.Loading) TitleReadStatus.Unavailable else TitleReadStatus.Loading
|
||||
title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = status), viewer, ::handle)
|
||||
|
|
@ -140,13 +161,26 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden),
|
||||
)
|
||||
if (fixtureRefresh != null) {
|
||||
if (title === fixtureRefresh) fixtureRefresh.update(restored)
|
||||
} else title = TitleState(restored, viewer, ::handle)
|
||||
if (title === fixtureRefresh) updateTitle(fixtureRefresh, restored)
|
||||
} else {
|
||||
title = TitleState(restored, viewer, ::handle)
|
||||
rememberSeason(restored)
|
||||
}
|
||||
}
|
||||
} else if (destination.startsWith("person/")) readPerson(id) else readTitle(id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rememberSeason(data: TitlePageData) {
|
||||
if (data.episodesStatus == TitleReadStatus.Ready) data.selectedSeason?.let { seasonEpisodes[it] = data.episodes }
|
||||
}
|
||||
|
||||
private fun updateTitle(current: TitleState, data: TitlePageData) {
|
||||
if (title !== current) return
|
||||
current.update(data)
|
||||
rememberSeason(data)
|
||||
}
|
||||
|
||||
private suspend fun readTitle(id: String, retry: Boolean = false) {
|
||||
val current = title ?: return
|
||||
val uuid = id.toUuidOrNull()
|
||||
|
|
@ -157,9 +191,9 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) }
|
||||
if (title !== current) return
|
||||
if (heldId != null) {
|
||||
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) }
|
||||
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { updateTitle(current, it) }
|
||||
held = facade.read(heldId, current.requestedSeason, preferences)
|
||||
current.update(held)
|
||||
updateTitle(current, held)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
|
|
@ -174,7 +208,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
if (identity != null && tmdb != null) {
|
||||
val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV
|
||||
ConductarrTitlePages(identity).title(tmdb, media, held).collect { data ->
|
||||
if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) current.update(data)
|
||||
if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) updateTitle(current, data)
|
||||
}
|
||||
} else if (uuid == null) current.update(current.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut, requestsAllowed = false))
|
||||
}
|
||||
|
|
@ -253,9 +287,13 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
|
||||
private fun handle(action: TitleAction) {
|
||||
when (action) {
|
||||
TitleAction.Pop -> navigation.goBack()
|
||||
is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id)))
|
||||
TitleAction.Pop -> popRoute()
|
||||
is TitleAction.Person -> {
|
||||
cancelPlayback()
|
||||
navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id)))
|
||||
}
|
||||
is TitleAction.OpenTitle -> {
|
||||
cancelPlayback()
|
||||
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
||||
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
||||
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
||||
|
|
@ -267,7 +305,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
is TitleAction.Play -> {
|
||||
val opened = title?.data?.watchTarget
|
||||
play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(),
|
||||
restart = action.restart)
|
||||
restart = action.restart, control = if (action.restart) "restart" else "play")
|
||||
}
|
||||
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true)
|
||||
else {
|
||||
|
|
@ -276,16 +314,21 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
}
|
||||
is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
||||
is TitleAction.Season -> {
|
||||
val current = title ?: return
|
||||
loadJob?.cancel()
|
||||
val cached = seasonEpisodes[action.number]
|
||||
if (cached == null) current.showSeasonLoading() else current.showSeason(action.number, cached)
|
||||
loadJob = viewModelScope.launch {
|
||||
val current = title ?: return@launch
|
||||
current.showSeasonLoading()
|
||||
val source = fixture
|
||||
if (source == null) readTitle(Uri.decode(route.substringAfter('/')))
|
||||
else {
|
||||
else try {
|
||||
val data = withContext(Dispatchers.IO) { source.title(Uri.decode(route.substringAfter('/')), viewer.key, action.number) }
|
||||
current.update(data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex,
|
||||
updateTitle(current, data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex,
|
||||
selectedSubtitle = current.subtitleIndex, watchTarget = current.data.watchTarget.copy(watched = current.watched), hidden = current.data.hidden))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason()
|
||||
current.toast = RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -304,19 +347,33 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun play(id: UUID?, restart: Boolean) {
|
||||
val current = title
|
||||
private fun play(id: UUID?, restart: Boolean, control: String = "restart") {
|
||||
val current = title ?: return
|
||||
if (current.playbackStart.starting != null) return
|
||||
val generation = ++playbackGeneration
|
||||
playbackJob?.cancel()
|
||||
current.playbackStart.begin(control)
|
||||
val playbackScope = watchScope
|
||||
viewModelScope.launch {
|
||||
playbackJob = viewModelScope.launch {
|
||||
try {
|
||||
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
||||
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
||||
if (title !== current || watchScope != playbackScope) return@launch
|
||||
themeSongPlayer.stop()
|
||||
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
|
||||
if (generation != playbackGeneration) return@launch
|
||||
if (title !== current || watchScope != playbackScope || current.playbackStart.starting == null) {
|
||||
current.playbackStart.fail()
|
||||
return@launch
|
||||
}
|
||||
current.playbackStart.ready {
|
||||
if (generation == playbackGeneration && title === current && watchScope == playbackScope) {
|
||||
themeSongPlayer.stop()
|
||||
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
|
||||
}
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
val active = generation == playbackGeneration && current.playbackStart.starting != null
|
||||
if (active) current.playbackStart.fail()
|
||||
if (exception is CancellationException) throw exception
|
||||
title?.toast = RecoveryMessage.PlaybackUnavailable
|
||||
if (active && title === current && watchScope == playbackScope) current.toast = RecoveryMessage.PlaybackUnavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -358,20 +415,30 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen
|
|||
val watchRefresh = rememberWatchRefresh(viewModel.watchScope)
|
||||
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
||||
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
|
||||
val active = isScreenEntryActive()
|
||||
DisposableEffect(viewModel, title, active) {
|
||||
if (!active) viewModel.cancelPlayback()
|
||||
onDispose { viewModel.cancelPlayback() }
|
||||
}
|
||||
TitleThemeMusic(title?.data?.item, preferences, viewModel.themeSongPlayer,
|
||||
active = isScreenEntryActive() && route.startsWith("title/") && title?.data?.status == TitleReadStatus.Ready,
|
||||
active = active && route.startsWith("title/") && title?.data?.status == TitleReadStatus.Ready,
|
||||
owner = "${shell.person.key}:${viewModel.watchScope}")
|
||||
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
|
||||
LaunchedEffect(title?.data?.status) {
|
||||
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
||||
}
|
||||
val person = viewModel.person.takeIf { title != null }
|
||||
BackHandler(enabled = isScreenEntryActive() && title != null) {
|
||||
viewModel.handleBack(route, shell) {
|
||||
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
|
||||
BackHandler(enabled = active && title != null) {
|
||||
if (title?.playbackStart?.launch == null) {
|
||||
viewModel.cancelPlayback()
|
||||
viewModel.handleBack(route, shell) {
|
||||
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(modifier.onPreviewKeyEvent { event ->
|
||||
if (title?.playbackStart?.launch != null) return@onPreviewKeyEvent true
|
||||
if (readShellKey(event) == ShellKey.Back && event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) viewModel.cancelPlayback()
|
||||
if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let {
|
||||
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it)
|
||||
true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue