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
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import org.junit.Rule
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -35,6 +38,7 @@ import java.util.UUID
|
||||||
@Config(sdk = [34], application = Application::class)
|
@Config(sdk = [34], application = Application::class)
|
||||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
class TitlePlaybackNavigationTest {
|
class TitlePlaybackNavigationTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001")
|
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001")
|
||||||
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002")
|
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002")
|
||||||
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||||
|
|
@ -110,24 +114,36 @@ class TitlePlaybackNavigationTest {
|
||||||
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
|
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
|
||||||
val session = ConductarrSession(MemoryCacheStore())
|
val session = ConductarrSession(MemoryCacheStore())
|
||||||
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
|
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 {
|
try {
|
||||||
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
|
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
model.title!!.click("header", 0)
|
model.title!!.click("header", 0)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
finishHandoff()
|
||||||
assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last())
|
assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last())
|
||||||
navigation.goBack()
|
navigation.goBack()
|
||||||
model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" })
|
model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" })
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
finishHandoff()
|
||||||
assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last())
|
assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last())
|
||||||
navigation.goBack()
|
navigation.goBack()
|
||||||
model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false)
|
model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
model.title!!.click("header", 0)
|
model.title!!.click("header", 0)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
finishHandoff()
|
||||||
assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last())
|
assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last())
|
||||||
coVerify(exactly = 1) { facade.preparePlayback(seriesId) }
|
coVerify(exactly = 1) { facade.preparePlayback(seriesId) }
|
||||||
} finally {
|
} finally {
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
model.viewModelScope.cancel()
|
model.viewModelScope.cancel()
|
||||||
session.close()
|
session.close()
|
||||||
Dispatchers.resetMain()
|
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()) }
|
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
|
||||||
menu()
|
menu()
|
||||||
rule.mainClock.autoAdvance = false
|
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) }
|
rule.runOnIdle { state.clickSheet(0) }
|
||||||
var waited = 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.mainClock.advanceTimeByFrame()
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
waited += 16
|
waited += 16
|
||||||
|
|
@ -212,7 +212,7 @@ class TitleRound2Test {
|
||||||
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
|
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
|
||||||
fun brightness(x: Int, y: Int): Int {
|
fun brightness(x: Int, y: Int): Int {
|
||||||
val pixel = bitmap.getPixel(x, y)
|
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 {
|
return try {
|
||||||
(brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") }
|
(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()
|
rule.waitForIdle()
|
||||||
frame("info", index).also { rule.mainClock.advanceTimeBy(16); 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) }
|
rule.runOnIdle { state.back(shell) }
|
||||||
repeat(14) { index ->
|
repeat(14) { index ->
|
||||||
rule.mainClock.advanceTimeBy(16)
|
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.TitleItem
|
||||||
import org.justinvisser.wolfskin.design.TitlePageData
|
import org.justinvisser.wolfskin.design.TitlePageData
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
|
|
@ -175,6 +176,8 @@ class TitleThemeMusicTest {
|
||||||
assertEquals(listOf("play:$seriesId"), playback.events)
|
assertEquals(listOf("play:$seriesId"), playback.events)
|
||||||
playback.onStop = { if (playback.playing != null) assertEquals(destination, navigation.backStack.last()) }
|
playback.onStop = { if (playback.playing != null) assertEquals(destination, navigation.backStack.last()) }
|
||||||
rule.runOnIdle { titleModel.title!!.click("header", 0) }
|
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 }
|
rule.waitUntil(5_000) { navigation.backStack.last() is Destination.Playback }
|
||||||
playback.onStop = {}
|
playback.onStop = {}
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -83,19 +84,38 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
private var personReadPending = false
|
private var personReadPending = false
|
||||||
private var personReadFinished = false
|
private var personReadFinished = false
|
||||||
private var loadJob: Job? = null
|
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 val saveMutex = Mutex()
|
||||||
private var performerPage = 1
|
private var performerPage = 1
|
||||||
var conductarrAction: ((TitleAction) -> Unit)? = null
|
var conductarrAction: ((TitleAction) -> Unit)? = null
|
||||||
val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}"
|
val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}"
|
||||||
|
|
||||||
fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route))
|
fun openRoute(route: String) {
|
||||||
fun popRoute() = navigation.goBack()
|
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) }
|
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. */
|
/** 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) {
|
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
|
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
||||||
val sameTitle = route == destination && ::viewer.isInitialized && viewer.key == selected.key && !destination.startsWith("person/")
|
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 readyTitle = sameTitle && title?.data?.status == TitleReadStatus.Ready
|
||||||
val fixtureRefresh = title?.takeIf { force && readyTitle && fixture != null && fixtures && BuildConfig.DEBUG }
|
val fixtureRefresh = title?.takeIf { force && readyTitle && fixture != null && fixtures && BuildConfig.DEBUG }
|
||||||
if (force && readyTitle && fixture == null &&
|
if (force && readyTitle && fixture == null &&
|
||||||
|
|
@ -117,6 +137,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
preferences = userPreferences
|
preferences = userPreferences
|
||||||
val id = Uri.decode(destination.substringAfter('/'))
|
val id = Uri.decode(destination.substringAfter('/'))
|
||||||
if (fixtureRefresh == null) {
|
if (fixtureRefresh == null) {
|
||||||
|
cancelPlayback()
|
||||||
// Losing authorization clears the old data, but is not another first-load skeleton.
|
// 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
|
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)
|
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),
|
hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden),
|
||||||
)
|
)
|
||||||
if (fixtureRefresh != null) {
|
if (fixtureRefresh != null) {
|
||||||
if (title === fixtureRefresh) fixtureRefresh.update(restored)
|
if (title === fixtureRefresh) updateTitle(fixtureRefresh, restored)
|
||||||
} else title = TitleState(restored, viewer, ::handle)
|
} else {
|
||||||
|
title = TitleState(restored, viewer, ::handle)
|
||||||
|
rememberSeason(restored)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (destination.startsWith("person/")) readPerson(id) else readTitle(id)
|
} 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) {
|
private suspend fun readTitle(id: String, retry: Boolean = false) {
|
||||||
val current = title ?: return
|
val current = title ?: return
|
||||||
val uuid = id.toUuidOrNull()
|
val uuid = id.toUuidOrNull()
|
||||||
|
|
@ -157,9 +191,9 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) }
|
val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) }
|
||||||
if (title !== current) return
|
if (title !== current) return
|
||||||
if (heldId != null) {
|
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)
|
held = facade.read(heldId, current.requestedSeason, preferences)
|
||||||
current.update(held)
|
updateTitle(current, held)
|
||||||
}
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
|
|
@ -174,7 +208,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
if (identity != null && tmdb != null) {
|
if (identity != null && tmdb != null) {
|
||||||
val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV
|
val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV
|
||||||
ConductarrTitlePages(identity).title(tmdb, media, held).collect { data ->
|
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))
|
} 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) {
|
private fun handle(action: TitleAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
TitleAction.Pop -> navigation.goBack()
|
TitleAction.Pop -> popRoute()
|
||||||
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)))
|
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 -> {
|
is TitleAction.OpenTitle -> {
|
||||||
|
cancelPlayback()
|
||||||
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
||||||
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
||||||
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
||||||
|
|
@ -267,7 +305,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
is TitleAction.Play -> {
|
is TitleAction.Play -> {
|
||||||
val opened = title?.data?.watchTarget
|
val opened = title?.data?.watchTarget
|
||||||
play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(),
|
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)
|
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true)
|
||||||
else {
|
else {
|
||||||
|
|
@ -276,16 +314,21 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
||||||
is TitleAction.Season -> {
|
is TitleAction.Season -> {
|
||||||
|
val current = title ?: return
|
||||||
loadJob?.cancel()
|
loadJob?.cancel()
|
||||||
|
val cached = seasonEpisodes[action.number]
|
||||||
|
if (cached == null) current.showSeasonLoading() else current.showSeason(action.number, cached)
|
||||||
loadJob = viewModelScope.launch {
|
loadJob = viewModelScope.launch {
|
||||||
val current = title ?: return@launch
|
|
||||||
current.showSeasonLoading()
|
|
||||||
val source = fixture
|
val source = fixture
|
||||||
if (source == null) readTitle(Uri.decode(route.substringAfter('/')))
|
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) }
|
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))
|
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) {
|
private fun play(id: UUID?, restart: Boolean, control: String = "restart") {
|
||||||
val current = title
|
val current = title ?: return
|
||||||
|
if (current.playbackStart.starting != null) return
|
||||||
|
val generation = ++playbackGeneration
|
||||||
|
playbackJob?.cancel()
|
||||||
|
current.playbackStart.begin(control)
|
||||||
val playbackScope = watchScope
|
val playbackScope = watchScope
|
||||||
viewModelScope.launch {
|
playbackJob = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
||||||
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
||||||
if (title !== current || watchScope != playbackScope) return@launch
|
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()
|
themeSongPlayer.stop()
|
||||||
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
|
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
|
val active = generation == playbackGeneration && current.playbackStart.starting != null
|
||||||
|
if (active) current.playbackStart.fail()
|
||||||
if (exception is CancellationException) throw exception
|
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)
|
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) }
|
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 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,
|
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}")
|
owner = "${shell.person.key}:${viewModel.watchScope}")
|
||||||
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
|
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
|
||||||
LaunchedEffect(title?.data?.status) {
|
LaunchedEffect(title?.data?.status) {
|
||||||
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
||||||
}
|
}
|
||||||
val person = viewModel.person.takeIf { title != null }
|
val person = viewModel.person.takeIf { title != null }
|
||||||
BackHandler(enabled = isScreenEntryActive() && title != null) {
|
BackHandler(enabled = active && title != null) {
|
||||||
|
if (title?.playbackStart?.launch == null) {
|
||||||
|
viewModel.cancelPlayback()
|
||||||
viewModel.handleBack(route, shell) {
|
viewModel.handleBack(route, shell) {
|
||||||
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
|
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Box(modifier.onPreviewKeyEvent { event ->
|
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 (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let {
|
||||||
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it)
|
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it)
|
||||||
true
|
true
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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)
|
else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2)
|
||||||
}
|
}
|
||||||
val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset
|
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}" }
|
Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" }
|
||||||
.titleWheel { wheelPending = true; shell.navFocused = false }
|
.titleWheel { wheelPending = true; shell.navFocused = false }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
package org.justinvisser.wolfskin.design
|
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.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
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,
|
height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f,
|
||||||
content: @Composable BoxScope.() -> Unit) {
|
content: @Composable BoxScope.() -> Unit) {
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
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)
|
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
|
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
|
||||||
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2)
|
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)
|
val top = Offset(0f, size.height - edge - fill)
|
||||||
if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill))
|
if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill))
|
||||||
drawRect(WolfskinTokens.Color.accent, top,
|
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)
|
}, 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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
|
|
@ -25,25 +26,36 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
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. */
|
/** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */
|
||||||
@Composable
|
@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 focusOwner = LocalTitleFocusOwner.current
|
||||||
|
val enabled = LocalTitleControlsEnabled.current
|
||||||
val background = if (option.primary) C.fg else if (overArtwork) {
|
val background = if (option.primary) C.fg else if (overArtwork) {
|
||||||
if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary
|
if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary
|
||||||
} else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
} else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||||
val foreground = if (option.primary) C.onFocus else C.fg
|
val foreground = if (option.primary) C.onFocus else C.fg
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
|
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
|
||||||
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
|
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 }
|
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
||||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||||
.background(background, shape)
|
.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) {
|
horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
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.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 (!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.animation.core.tween
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
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.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
import androidx.compose.ui.platform.testTag
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
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 ->
|
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) {
|
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) {
|
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
|
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 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)
|
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)
|
} 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)) {
|
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,
|
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index,
|
||||||
Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) },
|
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 }) {
|
if (state.options.any { it.icon }) {
|
||||||
val option = state.options.getOrNull(state.column)?.takeIf { active && 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.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
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.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -41,15 +44,21 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) {
|
||||||
LaunchedEffect(visible, reduced) {
|
LaunchedEffect(visible, reduced) {
|
||||||
if (visible) focus.requestFocus()
|
if (visible) focus.requestFocus()
|
||||||
opacity.animateTo(if (visible) 1f else 0f,
|
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)) {
|
if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) {
|
||||||
Box(Modifier.fillMaxSize().background(C.sheetScrim)
|
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 ->
|
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||||
val key = readShellKey(event)
|
val key = readShellKey(event)
|
||||||
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
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)
|
Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth)
|
||||||
.graphicsLayer { alpha = opacity.value }
|
.graphicsLayer { alpha = opacity.value }
|
||||||
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||||
|
|
@ -66,4 +75,5 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
package org.justinvisser.wolfskin.design
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
|
|
@ -44,28 +50,68 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
/** Sheets sit above navigation and consume pointer and key input across the entire scrim. */
|
/** Sheets sit above navigation and consume pointer and key input across the entire scrim. */
|
||||||
@Composable
|
@Composable
|
||||||
fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
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() }
|
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)) }
|
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 { }
|
Box(Modifier.fillMaxSize().sheetScrim { if (!ownsScrim) 0f else if (fromMenu) 1f else scrim.value }
|
||||||
.testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" }
|
.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 ->
|
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||||
val key = readShellKey(event)
|
val key = readShellKey(event)
|
||||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||||
}.focusable()) {
|
}.focusable(enabled = visible)) {
|
||||||
CssColumn(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface)
|
CompositionLocalProvider(LocalTitleControlsEnabled provides visible, LocalTitleFocusOwner provides null) {
|
||||||
.verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
|
CssColumn(Modifier.sheetPanelFade { fade.value }.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight()
|
||||||
gap = if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) {
|
.dropShadow(RectangleShape, shadow).background(C.surface)
|
||||||
when (state.sheet) {
|
.verticalScroll(rememberScrollState(), enabled = visible).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
|
||||||
TitleSheet.Version -> VersionChoices(state)
|
gap = if (sheet == TitleSheet.Tracks) D.trackGap else if (sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) {
|
||||||
TitleSheet.Tracks -> TrackChoices(state)
|
when (sheet) {
|
||||||
TitleSheet.MediaInfo -> MediaInformation(state)
|
TitleSheet.Version -> VersionChoices(content)
|
||||||
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
|
TitleSheet.Tracks -> TrackChoices(content)
|
||||||
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
|
TitleSheet.MediaInfo -> MediaInformation(content)
|
||||||
|
TitleSheet.ConfirmWatched -> WatchedConfirmation(content)
|
||||||
|
TitleSheet.HiddenReceipt -> HiddenReceipt(content)
|
||||||
TitleSheet.Menu, null -> Unit
|
TitleSheet.Menu, null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -100,11 +146,12 @@ private fun TrackChoices(state: TitleState) {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
|
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 foreground = if (focused) C.onFocus else C.fg
|
||||||
val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical)
|
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))
|
.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) {
|
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
CssColumn(Modifier.weight(1f)) {
|
CssColumn(Modifier.weight(1f)) {
|
||||||
Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle))
|
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.remember
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.tween
|
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.drawBehind
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
@ -54,6 +68,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
||||||
val view = screenView(shell)
|
val view = screenView(shell)
|
||||||
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
|
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
|
||||||
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
|
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.
|
// 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
|
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
|
||||||
else when (readShellKey(event)) {
|
else when (readShellKey(event)) {
|
||||||
|
|
@ -69,9 +84,10 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
||||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
||||||
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
||||||
}
|
}
|
||||||
if (!shell.pickerVisible && state.sheet != null && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell)
|
|
||||||
TitleMenu(state, shell)
|
TitleMenu(state, shell)
|
||||||
|
if (!shell.pickerVisible) TitleOptionsSheet(state, shell)
|
||||||
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
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) {
|
LaunchedEffect(message) {
|
||||||
if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() }
|
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. */
|
/** 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
|
val entryActive = LocalScreenActive.current
|
||||||
var wheelPending by remember { mutableStateOf(false) }
|
var wheelPending by remember { mutableStateOf(false) }
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
|
val sheetWasOpen = remember { booleanArrayOf(state.sheet != null) }
|
||||||
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
||||||
val compactHeader = state.data.status == TitleReadStatus.Ready
|
val compactHeader = state.data.status == TitleReadStatus.Ready
|
||||||
val item = state.data.item
|
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
|
D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero
|
||||||
val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead
|
val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead
|
||||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible, entryActive) {
|
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) {
|
val displayedRows = remember(state.data) {
|
||||||
buildList {
|
buildList {
|
||||||
|
|
@ -138,7 +160,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
|
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
|
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() }
|
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 dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
|
||||||
val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } }
|
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) {
|
private fun TitleSeasons(state: TitleState, active: Boolean) {
|
||||||
val focusOwner = LocalTitleFocusOwner.current
|
val focusOwner = LocalTitleFocusOwner.current
|
||||||
val list = androidx.compose.foundation.lazy.rememberLazyListState()
|
val list = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||||
val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season)
|
val reduced = reducedMotion()
|
||||||
LaunchedEffect(visibleChip, state.data.seasonNumbers) {
|
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 (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 seasonNumbers = state.data.seasonNumbers
|
||||||
val keys = seasonNumbers.map { "season-$it" }.uniqueKeys()
|
val keys = seasonNumbers.map { "season-$it" }.uniqueKeys()
|
||||||
items(seasonNumbers.size, key = { keys[it] }, contentType = { "chip" }) { index ->
|
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 focused = active && state.column == index
|
||||||
val selected = state.season == number
|
val selected = state.season == number
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.chip)
|
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)
|
Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||||
.onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) }
|
.onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) }
|
||||||
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape)
|
.background(if (focused) C.fg else if (selected) androidx.compose.ui.graphics.Color.Transparent else C.chipRest, shape)
|
||||||
.then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier)
|
|
||||||
.clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
|
.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>>()
|
private val centers = HashMap<String, MutableMap<Int, Float>>()
|
||||||
fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x }
|
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) }
|
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) }
|
fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) }
|
||||||
var sheet by mutableStateOf<TitleSheet?>(null)
|
var sheet by mutableStateOf<TitleSheet?>(null)
|
||||||
private set
|
private set
|
||||||
|
|
@ -37,6 +42,7 @@ class TitleState(
|
||||||
var subtitleIndex by mutableStateOf(initial.selectedSubtitle)
|
var subtitleIndex by mutableStateOf(initial.selectedSubtitle)
|
||||||
var watched by mutableStateOf(initial.watchTarget.watched)
|
var watched by mutableStateOf(initial.watchTarget.watched)
|
||||||
var toast by mutableStateOf<String?>(null)
|
var toast by mutableStateOf<String?>(null)
|
||||||
|
val playbackStart = PlaybackStart()
|
||||||
private var armedCard: String? = null
|
private var armedCard: String? = null
|
||||||
private var seasonChosen = false
|
private var seasonChosen = false
|
||||||
val requestedSeason: Int? get() = season.takeIf { seasonChosen }
|
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. */
|
/** 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 {
|
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
|
||||||
|
if (playbackStart.launch != null) return true
|
||||||
if (key == ShellKey.Ok) {
|
if (key == ShellKey.Ok) {
|
||||||
if (!down) {
|
if (!down) {
|
||||||
okHeld = false
|
okHeld = false
|
||||||
|
|
@ -147,6 +154,8 @@ class TitleState(
|
||||||
|
|
||||||
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
||||||
fun back(shell: ShellState) {
|
fun back(shell: ShellState) {
|
||||||
|
if (playbackStart.launch != null) return
|
||||||
|
playbackStart.fail()
|
||||||
when {
|
when {
|
||||||
sheet != null -> closeSheet()
|
sheet != null -> closeSheet()
|
||||||
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
||||||
|
|
@ -187,7 +196,7 @@ class TitleState(
|
||||||
|
|
||||||
private fun activateOption(id: String) {
|
private fun activateOption(id: String) {
|
||||||
when (id) {
|
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 }
|
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
||||||
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
||||||
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
|
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue