Polish title sheets, playback handoff and season motion
This commit is contained in:
parent
74b737fb52
commit
f05b0de156
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
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -116,7 +115,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
|
|||
else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2)
|
||||
}
|
||||
val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset
|
||||
if (target != current) list.animateScrollBy(target - current, titleFrameScrollSpec)
|
||||
if (target != current) animateFrameScroll(list, target - current)
|
||||
}
|
||||
Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" }
|
||||
.titleWheel { wheelPending = true; shell.navFocused = false }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
|
|
@ -33,6 +39,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs
|
|||
height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f,
|
||||
content: @Composable BoxScope.() -> Unit) {
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
val animatedProgress = rememberTitleProgress(null, progress.coerceIn(0f, 1f), hasResumeLine = progress > 0f)
|
||||
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
|
||||
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2)
|
||||
|
|
@ -51,9 +58,21 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs
|
|||
val top = Offset(0f, size.height - edge - fill)
|
||||
if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill))
|
||||
drawRect(WolfskinTokens.Color.accent, top,
|
||||
Size(size.width * if (progress > 0f) progress.coerceIn(0f, 1f) else 1f, fill))
|
||||
Size(size.width * if (progress > 0f) animatedProgress.value else 1f, fill))
|
||||
}
|
||||
}
|
||||
}, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/** Target and line-kind changes cut; a held resume line interpolates even when runtime arrives late. */
|
||||
@Composable
|
||||
internal fun rememberTitleProgress(identity: String?, fraction: Float, hasResumeLine: Boolean): State<Float> {
|
||||
val reduced = reducedMotion()
|
||||
val progress = remember(identity, hasResumeLine) { Animatable(fraction) }
|
||||
LaunchedEffect(progress, fraction, reduced) {
|
||||
if (reduced) progress.snapTo(fraction)
|
||||
else progress.animateTo(fraction, tween(WolfskinTokens.Motion.heroFade, easing = WolfskinTokens.Motion.easeInOut))
|
||||
}
|
||||
return if (reduced) rememberUpdatedState(fraction) else progress.asState()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import androidx.compose.foundation.shape.CircleShape
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.Role
|
||||
|
|
@ -25,24 +26,35 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
|||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** Retained sheet controls are painted but cannot receive focus, hover or clicks. */
|
||||
internal val LocalTitleControlsEnabled = compositionLocalOf { true }
|
||||
|
||||
/** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */
|
||||
@Composable
|
||||
fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, onClick: () -> Unit) {
|
||||
fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, busy: Boolean = false, busyLabel: String? = null, onClick: () -> Unit) {
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
val enabled = LocalTitleControlsEnabled.current
|
||||
val background = if (option.primary) C.fg else if (overArtwork) {
|
||||
if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary
|
||||
} else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||
val foreground = if (option.primary) C.onFocus else C.fg
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
|
||||
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
|
||||
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus.takeIf { enabled }).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||
.background(background, shape)
|
||||
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
|
||||
.clickable(enabled = enabled && !busy) { focusOwner?.invoke(); onClick() }.then(dimensions),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
|
||||
if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
|
||||
if (busyLabel != null) BusyLabelSwap(busy, busyLabel, foreground, WolfskinTokens.Type.button) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(L.buttonIcon))
|
||||
Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
|
||||
}
|
||||
} else {
|
||||
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
|
||||
if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import androidx.compose.animation.core.animateDpAsState
|
|||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.platform.testTag
|
||||
|
|
@ -13,7 +15,6 @@ 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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
|
|
@ -86,10 +87,10 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
state.data.playTarget?.takeIf { it.resumeSeconds > 0 || it.kind == "episode" }?.let { target ->
|
||||
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (target.resumeSeconds > 0) {
|
||||
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
|
||||
val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f
|
||||
Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(C.accent))
|
||||
}
|
||||
val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f
|
||||
val progress = rememberTitleProgress(target.id, fraction, hasResumeLine = true)
|
||||
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))
|
||||
.drawBehind { drawRect(C.accent, size = Size(size.width * progress.value, size.height)) })
|
||||
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress)
|
||||
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress)
|
||||
}
|
||||
|
|
@ -97,7 +98,8 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index,
|
||||
Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) },
|
||||
onFocus = { state.focus("header", index) }) { state.click("header", index) } }
|
||||
onFocus = { state.focus("header", index) }, busy = option.id == "play" && state.playbackStart.starting == "play",
|
||||
busyLabel = if (option.id == "play") "Starting…" else null) { state.click("header", index) } }
|
||||
}
|
||||
if (state.options.any { it.icon }) {
|
||||
val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon }
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -41,15 +44,21 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) {
|
|||
LaunchedEffect(visible, reduced) {
|
||||
if (visible) focus.requestFocus()
|
||||
opacity.animateTo(if (visible) 1f else 0f,
|
||||
tween(motionDuration(if (visible) WolfskinTokens.Motion.base else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||
tween(motionDuration(if (visible) WolfskinTokens.Motion.sheetIn else WolfskinTokens.Motion.sheetOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||
}
|
||||
if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) {
|
||||
Box(Modifier.fillMaxSize().background(C.sheetScrim)
|
||||
.clickable { if (visible) state.back(shell) }
|
||||
.clickable(enabled = visible) { state.back(shell) }
|
||||
.pointerInput(visible) {
|
||||
if (!visible) awaitPointerEventScope {
|
||||
while (true) awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||
val key = readShellKey(event)
|
||||
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable()) {
|
||||
}.focusable(enabled = visible)) {
|
||||
CompositionLocalProvider(LocalTitleControlsEnabled provides visible) {
|
||||
Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth)
|
||||
.graphicsLayer { alpha = opacity.value }
|
||||
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
|
|
@ -64,6 +73,7 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
|
|
@ -44,25 +50,65 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
/** Sheets sit above navigation and consume pointer and key input across the entire scrim. */
|
||||
@Composable
|
||||
fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
||||
val visible = state.sheet != null && state.sheet != TitleSheet.Menu
|
||||
val menu = state.sheet == TitleSheet.Menu
|
||||
val previous = remember { arrayOf<TitleSheet?>(null) }
|
||||
val retainedSheet = remember { arrayOf<TitleSheet?>(null) }
|
||||
val retained = remember(state) { TitleState(state.data, state.person) { } }
|
||||
val panelScrim = remember { booleanArrayOf(false) }
|
||||
val ownsScrim = visible || (panelScrim[0] && !menu)
|
||||
val fromMenu = visible && previous[0] == TitleSheet.Menu
|
||||
val fade = rememberSheetFade(visible)
|
||||
val scrim = remember { Animatable(0f) }
|
||||
val reduced = reducedMotion()
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(state.sheet) { focus.requestFocus() }
|
||||
SideEffect {
|
||||
previous[0] = state.sheet
|
||||
if (visible || menu) panelScrim[0] = visible
|
||||
if (visible) {
|
||||
retainedSheet[0] = state.sheet
|
||||
retained.update(state.data)
|
||||
retained.sheetIndex = state.sheetIndex
|
||||
retained.versionId = state.versionId
|
||||
retained.audioIndex = state.audioIndex
|
||||
retained.subtitleIndex = state.subtitleIndex
|
||||
retained.watched = state.watched
|
||||
}
|
||||
}
|
||||
LaunchedEffect(visible, menu, reduced) {
|
||||
if (visible) focus.requestFocus()
|
||||
if (menu || fromMenu) scrim.snapTo(1f)
|
||||
if (!menu) scrim.animateTo(if (visible) 1f else 0f,
|
||||
tween(motionDuration(if (visible) WolfskinTokens.Motion.sheetIn else WolfskinTokens.Motion.sheetOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||
}
|
||||
if (!visible && fade.value == 0f && (!ownsScrim || scrim.value == 0f)) return
|
||||
val sheet = if (visible) state.sheet else retainedSheet[0]
|
||||
val content = if (visible) state else retained
|
||||
val shadow = remember { Shadow(radius = D.sheetShadowRadius, offset = DpOffset.Zero.copy(x = D.sheetShadowX), color = Color.Black.copy(alpha = D.sheetShadowOpacity)) }
|
||||
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { }
|
||||
.testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" }
|
||||
Box(Modifier.fillMaxSize().sheetScrim { if (!ownsScrim) 0f else if (fromMenu) 1f else scrim.value }
|
||||
.pointerInput(visible) {
|
||||
awaitPointerEventScope {
|
||||
while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
.testTag("title-sheet").semantics { stateDescription = "$sheet:${content.sheetIndex}" }
|
||||
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||
val key = readShellKey(event)
|
||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable()) {
|
||||
CssColumn(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface)
|
||||
.verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
|
||||
gap = if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) {
|
||||
when (state.sheet) {
|
||||
TitleSheet.Version -> VersionChoices(state)
|
||||
TitleSheet.Tracks -> TrackChoices(state)
|
||||
TitleSheet.MediaInfo -> MediaInformation(state)
|
||||
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
|
||||
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
|
||||
TitleSheet.Menu, null -> Unit
|
||||
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable(enabled = visible)) {
|
||||
CompositionLocalProvider(LocalTitleControlsEnabled provides visible, LocalTitleFocusOwner provides null) {
|
||||
CssColumn(Modifier.sheetPanelFade { fade.value }.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight()
|
||||
.dropShadow(RectangleShape, shadow).background(C.surface)
|
||||
.verticalScroll(rememberScrollState(), enabled = visible).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
|
||||
gap = if (sheet == TitleSheet.Tracks) D.trackGap else if (sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) {
|
||||
when (sheet) {
|
||||
TitleSheet.Version -> VersionChoices(content)
|
||||
TitleSheet.Tracks -> TrackChoices(content)
|
||||
TitleSheet.MediaInfo -> MediaInformation(content)
|
||||
TitleSheet.ConfirmWatched -> WatchedConfirmation(content)
|
||||
TitleSheet.HiddenReceipt -> HiddenReceipt(content)
|
||||
TitleSheet.Menu, null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -100,11 +146,12 @@ private fun TrackChoices(state: TitleState) {
|
|||
|
||||
@Composable
|
||||
private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
|
||||
val enabled = LocalTitleControlsEnabled.current
|
||||
val foreground = if (focused) C.onFocus else C.fg
|
||||
val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical)
|
||||
Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus.takeIf { enabled }).focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
||||
.clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
|
||||
.clickable(enabled = enabled, onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
CssColumn(Modifier.weight(1f)) {
|
||||
Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle))
|
||||
|
|
|
|||
|
|
@ -28,9 +28,23 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
|
@ -54,6 +68,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
|||
val view = screenView(shell)
|
||||
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
|
||||
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
|
||||
if (state.playbackStart.launch != null) return@onPreviewKeyEvent true
|
||||
// No content target exists during an initial load; Down must not enter a skeleton.
|
||||
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
|
||||
else when (readShellKey(event)) {
|
||||
|
|
@ -69,9 +84,10 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
|||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
||||
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
||||
}
|
||||
if (!shell.pickerVisible && state.sheet != null && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell)
|
||||
TitleMenu(state, shell)
|
||||
if (!shell.pickerVisible) TitleOptionsSheet(state, shell)
|
||||
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
||||
PlaybackHandoffVeil(state.playbackStart)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +97,7 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism
|
|||
LaunchedEffect(message) {
|
||||
if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() }
|
||||
}
|
||||
if (message != null) RecoveryToast(message, modifier)
|
||||
RecoveryToastHost(message, modifier)
|
||||
}
|
||||
|
||||
/** One keyed vertical frame renders the exact header, season, cast and related order. */
|
||||
|
|
@ -92,6 +108,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
val entryActive = LocalScreenActive.current
|
||||
var wheelPending by remember { mutableStateOf(false) }
|
||||
val focus = remember { FocusRequester() }
|
||||
val sheetWasOpen = remember { booleanArrayOf(state.sheet != null) }
|
||||
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
||||
val compactHeader = state.data.status == TitleReadStatus.Ready
|
||||
val item = state.data.item
|
||||
|
|
@ -99,7 +116,12 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero
|
||||
val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead
|
||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible, entryActive) {
|
||||
if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
||||
val restoring = sheetWasOpen[0] && state.sheet == null
|
||||
sheetWasOpen[0] = state.sheet != null
|
||||
if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) {
|
||||
if (restoring) withFrameNanos { }
|
||||
focus.requestFocus()
|
||||
}
|
||||
}
|
||||
val displayedRows = remember(state.data) {
|
||||
buildList {
|
||||
|
|
@ -138,7 +160,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
|
||||
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
|
||||
else y - with(density) { L.pinnedLine.toPx() }
|
||||
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
|
||||
if (target != position) animateFrameScroll(list, target - position)
|
||||
}
|
||||
val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
|
||||
val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } }
|
||||
|
|
@ -197,13 +219,66 @@ private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) {
|
|||
private fun TitleSeasons(state: TitleState, active: Boolean) {
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
val list = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season)
|
||||
LaunchedEffect(visibleChip, state.data.seasonNumbers) {
|
||||
val reduced = reducedMotion()
|
||||
val selectedIndex = state.data.seasonNumbers.indexOf(state.season)
|
||||
val visibleChip = if (active) state.column else selectedIndex
|
||||
LaunchedEffect(visibleChip, state.data.seasonNumbers, reduced) {
|
||||
if (visibleChip >= 0) {
|
||||
if (NetworkArtTransition.reduced) list.scrollToItem(visibleChip) else list.animateScrollToItem(visibleChip)
|
||||
val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == visibleChip }
|
||||
if (visible == null) list.scrollToItem(visibleChip)
|
||||
else animateFrameScroll(list, visible.offset.toFloat())
|
||||
}
|
||||
}
|
||||
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
||||
var capsuleIndex by remember { mutableIntStateOf(selectedIndex) }
|
||||
val capsulePosition = remember { Animatable(0f) }
|
||||
val capsuleWidth = remember { Animatable(0f) }
|
||||
// A stable virtual row coordinate preserves the spring's frame time and velocity on retarget.
|
||||
var capsuleTarget by remember { mutableFloatStateOf(0f) }
|
||||
val selection = rememberUpdatedState(selectedIndex)
|
||||
LaunchedEffect(list, reduced) {
|
||||
snapshotFlow { selection.value }.collectLatest { index ->
|
||||
val target = snapshotFlow { list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } }.filterNotNull().first()
|
||||
val previous = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == capsuleIndex }
|
||||
if (reduced || capsuleWidth.value == 0f || previous == null) {
|
||||
capsulePosition.snapTo(0f)
|
||||
capsuleWidth.snapTo(target.size.toFloat())
|
||||
capsuleTarget = 0f
|
||||
capsuleIndex = index
|
||||
} else {
|
||||
val targetPosition = capsuleTarget + target.offset - previous.offset
|
||||
capsuleTarget = targetPosition
|
||||
capsuleIndex = index
|
||||
launch { capsulePosition.animateTo(targetPosition, spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness)) }
|
||||
if (capsuleWidth.targetValue != target.size.toFloat()) launch {
|
||||
capsuleWidth.animateTo(target.size.toFloat(), spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop).drawWithCache {
|
||||
val height = D.chipHeight.toPx()
|
||||
val radius = WolfskinTokens.Radius.chip.toPx()
|
||||
val border = D.chipBorder.toPx()
|
||||
val stroke = Stroke(border)
|
||||
val inset = Offset(border / 2, border / 2)
|
||||
val outerCorner = CornerRadius(radius)
|
||||
val innerCorner = CornerRadius(radius - border / 2)
|
||||
onDrawBehind {
|
||||
val layout = list.layoutInfo
|
||||
val visible = layout.visibleItemsInfo
|
||||
val selected = capsuleIndex
|
||||
var index = 0
|
||||
while (index < visible.size) {
|
||||
val chip = visible[index++]
|
||||
if (chip.index != selected) continue
|
||||
val topLeft = Offset(chip.offset + layout.beforeContentPadding + capsulePosition.value - capsuleTarget, 0f)
|
||||
val bounds = Size(capsuleWidth.value, height)
|
||||
drawRoundRect(C.accentSoft, topLeft, bounds, outerCorner)
|
||||
drawRoundRect(C.accent, topLeft + inset, Size(bounds.width - border, bounds.height - border), innerCorner, style = stroke)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
||||
val seasonNumbers = state.data.seasonNumbers
|
||||
val keys = seasonNumbers.map { "season-$it" }.uniqueKeys()
|
||||
items(seasonNumbers.size, key = { keys[it] }, contentType = { "chip" }) { index ->
|
||||
|
|
@ -211,12 +286,12 @@ private fun TitleSeasons(state: TitleState, active: Boolean) {
|
|||
val focused = active && state.column == index
|
||||
val selected = state.season == number
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.chip)
|
||||
val label = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg)
|
||||
Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) }
|
||||
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape)
|
||||
.then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier)
|
||||
.background(if (focused) C.fg else if (selected) androidx.compose.ui.graphics.Color.Transparent else C.chipRest, shape)
|
||||
.clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
|
||||
Text("Season $number", color = if (focused) C.onFocus else if (selected) C.accentText else C.fg, style = WolfskinTokens.Type.label)
|
||||
Text("Season $number", color = label, style = WolfskinTokens.Type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -238,4 +313,3 @@ private fun TitleReadError(state: TitleState) {
|
|||
}
|
||||
}
|
||||
|
||||
internal val titleFrameScrollSpec get() = tween<Float>(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ class TitleState(
|
|||
private val centers = HashMap<String, MutableMap<Int, Float>>()
|
||||
fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x }
|
||||
fun showSeasonLoading() { if (data.episodesStatus != TitleReadStatus.Loading) data = data.copy(episodesStatus = TitleReadStatus.Loading) }
|
||||
/** Cached episodes replace the loading state on the season-selection frame. */
|
||||
fun showSeason(number: Int, episodes: List<TitleItem>) {
|
||||
season = number
|
||||
data = data.copy(selectedSeason = number, episodes = episodes, episodesStatus = TitleReadStatus.Ready)
|
||||
}
|
||||
fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) }
|
||||
var sheet by mutableStateOf<TitleSheet?>(null)
|
||||
private set
|
||||
|
|
@ -37,6 +42,7 @@ class TitleState(
|
|||
var subtitleIndex by mutableStateOf(initial.selectedSubtitle)
|
||||
var watched by mutableStateOf(initial.watchTarget.watched)
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
val playbackStart = PlaybackStart()
|
||||
private var armedCard: String? = null
|
||||
private var seasonChosen = false
|
||||
val requestedSeason: Int? get() = season.takeIf { seasonChosen }
|
||||
|
|
@ -115,6 +121,7 @@ class TitleState(
|
|||
|
||||
/** Key-up can only activate the same card that armed the first key-down. */
|
||||
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
|
||||
if (playbackStart.launch != null) return true
|
||||
if (key == ShellKey.Ok) {
|
||||
if (!down) {
|
||||
okHeld = false
|
||||
|
|
@ -147,6 +154,8 @@ class TitleState(
|
|||
|
||||
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
||||
fun back(shell: ShellState) {
|
||||
if (playbackStart.launch != null) return
|
||||
playbackStart.fail()
|
||||
when {
|
||||
sheet != null -> closeSheet()
|
||||
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
||||
|
|
@ -187,7 +196,7 @@ class TitleState(
|
|||
|
||||
private fun activateOption(id: String) {
|
||||
when (id) {
|
||||
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
||||
"play", "restart" -> if (data.watchReady && playbackStart.starting == null) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
||||
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
||||
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
||||
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue