diff --git a/Makefile b/Makefile index dc06bb0c..df6bd824 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ WOLFSKIN_GRADLE_VOLUME ?= wolfskin-gradle WOLFSKIN_SECRETS_DIR ?= /opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin export WOLFSKIN_BUILD_IMAGE WOLFSKIN_GRADLE_VOLUME WOLFSKIN_SECRETS_DIR -.PHONY: build-image apk apk-debug test release-tag tv-release tv-publish tv-tooling-test +.PHONY: build-image apk apk-debug test release-tag build-image: docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker @@ -19,13 +19,3 @@ test: build-image release-tag: @bash docker/release-tag.sh - -TV_RELEASE_DIR ?= /var/tmp/wolfskin-tv-release-$(VERSION) -tv-release: build-image - @python3 docker/tv_release.py --version "$(VERSION)" --version-code "$(VERSION_CODE)" --output "$(TV_RELEASE_DIR)" $(TV_RELEASE_FLAGS) - -tv-publish: - @python3 docker/tv_publish.py --directory "$(TV_RELEASE_DIR)" $(TV_PUBLISH_FLAGS) - -tv-tooling-test: - @python3 -m unittest discover -s docker -p 'test_tv_*.py' -v diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2da11b20..98f03a3a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,10 +56,6 @@ val gitDescribe = .standardOutput.asText .getOrElse("v0.0.0") -val tvVersion = providers.gradleProperty("tvVersion").orElse("1.1.1") -val tvVersionCode = providers.gradleProperty("tvVersionCode") -apply(from = "household-version.gradle.kts") - kotlin { compilerOptions { languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3 @@ -232,13 +228,6 @@ configure { androidComponents { onVariants(selector().all()) { variant -> - if (variant.name == "defaultOptimized") { - variant.outputs.forEach { - it.versionName.set(tvVersion) - // The sentinel is configuration-only: optimized tasks require the explicit input above. - it.versionCode.set(tvVersionCode.map(String::toInt).orElse(1)) - } - } variant.outputs .map { it as com.android.build.api.variant.impl.VariantOutputImpl } .forEach { output -> diff --git a/app/household-version.gradle.kts b/app/household-version.gradle.kts deleted file mode 100644 index c9ca35d0..00000000 --- a/app/household-version.gradle.kts +++ /dev/null @@ -1,18 +0,0 @@ -// Household assembly inputs remain mandatory even through aggregate Gradle tasks. -val tvVersion = providers.gradleProperty("tvVersion").orElse("1.1.1") -val tvVersionCode = providers.gradleProperty("tvVersionCode") - -fun requireHouseholdVersion() { - require(tvVersionCode.isPresent) { "Household builds require -PtvVersionCode=" } - require(tvVersionCode.get().toInt() in 1..2100000000) { "Invalid household versionCode" } - require(Regex("""\d+\.\d+\.\d+""").matches(tvVersion.get())) { "Household versionName must be plain major.minor.patch" } -} - -if (gradle.startParameter.taskNames.any { it.contains("Optimized", ignoreCase = true) }) { - requireHouseholdVersion() -} -tasks.configureEach { - if (name.contains("DefaultOptimized", ignoreCase = true)) { - doFirst { requireHouseholdVersion() } - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 198782d5..aad5c5b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,18 +36,10 @@ - - - - - - - - - { val UpdateUrl = AppStringPreference( title = R.string.update_url, - defaultValue = UpdateChannel.defaultUrl(BuildConfig.APPLICATION_ID), + defaultValue = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest", getter = { it.updateUrl }, setter = { prefs, value -> prefs.update { updateUrl = value } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt index fa702609..706285f2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -36,9 +36,6 @@ import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import org.justinvisser.wolfskin.tv.update.AndroidUpdateService -import org.justinvisser.wolfskin.tv.update.UpdateChannel -import org.justinvisser.wolfskin.tv.update.UpdateTransport import timber.log.Timber import java.io.File import java.io.InputStream @@ -46,7 +43,6 @@ import java.io.OutputStream import java.util.Date import javax.inject.Inject import javax.inject.Singleton -import javax.inject.Provider import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds @@ -59,7 +55,6 @@ class UpdateChecker constructor( @param:ApplicationContext private val context: Context, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, - private val household: Provider, ) { companion object { const val ASSET_NAME = "Wolfskin" @@ -128,10 +123,6 @@ class UpdateChecker updateUrl: String, showNegativeToast: Boolean = false, ) { - if (context.packageName == UpdateChannel.PACKAGE) { - household.get().controller.check(manual = showNegativeToast) - return - } val pref = PreferenceManager.getDefaultSharedPreferences(context) val now = Date() val lastUpdateCheckThreshold = @@ -190,12 +181,6 @@ class UpdateChecker * so this searches the release list instead of fetching a tag. */ suspend fun getRelease(version: Version): Release? { - if (context.packageName == UpdateChannel.PACKAGE) { - return withContext(WholphinDispatchers.IO) { - val releases = UpdateTransport().releasesForLatest(household.get().channelUrl()) - findReleaseByVersion(releases, version)?.let { parseRelease(it, false) } - } - } val request = Request .Builder() @@ -248,9 +233,6 @@ class UpdateChecker release: Release, callback: DownloadCallback, ) { - check(context.packageName != UpdateChannel.PACKAGE) { - "Household updates require the verified update controller" - } withContext(WholphinDispatchers.IO) { cleanup() val request = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt index 3c32c8ab..703182ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt @@ -15,8 +15,7 @@ import coil3.network.NetworkResponse import coil3.network.cachecontrol.CacheControlCacheStrategy import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.Options -import coil3.request.transitionFactory -import org.justinvisser.wolfskin.design.NetworkArtTransition +import coil3.request.crossfade import coil3.util.DebugLogger import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences @@ -90,7 +89,7 @@ fun CoilConfig( memoryCache(null) diskCache(null) } - }.transitionFactory(NetworkArtTransition) + }.crossfade(false) .logger(if (debugLogging) DebugLogger() else null) .components { add( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index cd9afc3e..0e388213 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -47,7 +47,6 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import coil3.SingletonImageLoader import coil3.imageLoader -import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences @@ -90,7 +89,6 @@ import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch -import org.justinvisser.wolfskin.tv.update.UpdateChannel import timber.log.Timber import java.util.Locale @@ -357,9 +355,7 @@ fun PreferencesContent( AppPreference.Update -> { ClickPreference( title = - if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) { - stringResource(R.string.check_for_updates) - } else if (release != null && updateAvailable) { + if (release != null && updateAvailable) { stringResource(R.string.install_update) } else if (!preferences.autoCheckForUpdates && release == null) { stringResource(R.string.check_for_updates) @@ -368,9 +364,7 @@ fun PreferencesContent( }, onClick = { if (movementSounds) playOnClickSound(context) - if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) { - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - } else if (release != null && updateAvailable) { + if (release != null && updateAvailable) { release?.let { viewModel.navigationManager.navigateTo( Destination.UpdateApp, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt index 67366b9f..a3bd52e4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt @@ -44,7 +44,6 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.DownloadCallback import com.github.damontecres.wholphin.services.NavigationManager @@ -74,9 +73,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber -import org.justinvisser.wolfskin.tv.update.ClassicUpdatePage -import org.justinvisser.wolfskin.tv.update.UpdateChannel -import org.justinvisser.wolfskin.tv.update.UpdateController import javax.inject.Inject @HiltViewModel @@ -86,7 +82,6 @@ class UpdateViewModel private val userPreferencesService: UserPreferencesService, val updater: UpdateChecker, val navigationManager: NavigationManager, - val householdController: UpdateController, ) : ViewModel(), DownloadCallback { private val _state = MutableStateFlow(InstallUpdateState()) @@ -98,10 +93,6 @@ class UpdateViewModel val currentVersion = updater.getInstalledVersion() fun init() { - if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) { - householdController.check(true) - return - } _state.update { it.copy(loading = LoadingState.Loading) } viewModelScope.launchIO { val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl @@ -175,10 +166,6 @@ fun InstallUpdatePage( modifier: Modifier = Modifier, viewModel: UpdateViewModel = hiltViewModel(), ) { - if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) { - ClassicUpdatePage(viewModel.householdController, modifier) - return - } OneTimeLaunchedEffect { viewModel.init() } val state by viewModel.state.collectAsState() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index e7ec173a..2fde3eae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -52,9 +52,6 @@ class SwitchSeerrViewModel _state.update { it.copy(loading = LoadingState.Success) } } } - } else { - // Nothing to check so set success - _state.update { it.copy(loading = LoadingState.Success) } } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt index 925f5701..2e84e2ca 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt @@ -18,16 +18,6 @@ import java.nio.file.Paths import kotlin.io.path.readText class TestUpdateChecker { - @Test - fun householdChannelMigrationDoesNotChangeCustomOrOldProductionFeeds() { - val channel = org.justinvisser.wolfskin.tv.update.UpdateChannel - Assert.assertEquals(channel.LATEST_URL, channel.migrateUrl(channel.PACKAGE, channel.LEGACY_URL)) - Assert.assertEquals("https://custom.test/releases/latest", channel.migrateUrl(channel.PACKAGE, "https://custom.test/releases/latest")) - Assert.assertEquals(channel.LEGACY_URL, channel.migrateUrl("org.justinvisser.wolfskin.tv", channel.LEGACY_URL)) - Assert.assertEquals(channel.LEGACY_URL, channel.defaultUrl("org.justinvisser.wolfskin.tv")) - Assert.assertEquals(channel.LATEST_URL, channel.defaultUrl(channel.PACKAGE)) - } - lateinit var releaseJson: JsonObject val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt b/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt index 93842728..fdd45e6a 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt @@ -22,14 +22,7 @@ class VersionCompareTests { @Test fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() { val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName"))) - Assert.assertTrue(optimizedVersion.isGreaterThan(Version.fromString("1.1.0-140-g5162c299"))) - } - - @Test - fun householdAvailabilityOrdersAndroidCodesNotDisplayVersions() { - Assert.assertTrue(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 65)) - Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(65, 100001)) - Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 100001)) + Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion) } @Test diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSkeletonKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSkeletonKeysTest.kt deleted file mode 100644 index 9dc1786b..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSkeletonKeysTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import io.mockk.mockk -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.* -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** A pending shelf cannot capture the D-pad or break the shell's Back ladder. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class DiscoverSkeletonKeysTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun downAndUpSkipPendingLanesAndBackReturnsToNavWhenOnlySkeletonsRemain() { - val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin").apply { - pickerVisible = false; pickerLaunch = false; enterDestination("discover") - } - val pending = DiscoverLane("pending", "Pending", emptyList(), pending = true) - val ready = DiscoverLane("ready", "Available", listOf(DiscoverTitle(1, "movie", "A real title"))) - val snapshot = DiscoverSnapshot(status = DiscoverStatus.Ready, lanes = listOf(pending, ready)) - val state = DiscoverState(shell, snapshot, mockk(relaxed = true), CoroutineScope(Dispatchers.Main.immediate), {}, {}) - state.focus(1) - rule.setContent { WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } } - press(Key.DirectionDown) - rule.runOnIdle { assertEquals("ready", state.focusedLane?.id); assertEquals("ready:0", state.cardKey) } - press(Key.DirectionUp) - rule.runOnIdle { - assertEquals(1, state.focusedRow) - state.updateSnapshot(snapshot.copy(lanes = listOf(pending))) - } - press(Key.DirectionDown) - rule.runOnIdle { assertEquals(1, state.focusedRow); assertNull(state.cardKey) } - press(Key.Back) - rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible); assertEquals("discover", shell.route) } - } - - private fun press(key: Key) { rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt index 07cd1e37..aa1faf95 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -84,7 +84,7 @@ class FirstSignInNavigationTest { coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows)) for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) { coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true, - includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED), + includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult( List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) }) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt index a2fb8b0d..92afbe07 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt @@ -51,7 +51,7 @@ class LibraryReadFacadeTest { val offsets = mutableListOf() coEvery { items.getItems(userId = user, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE), - fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED), + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers { if (offsets.last() == 0) successQueryResult(firstChunk, 202) else { assertEquals(200, offsets.last()) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt index 95fcbb52..8e13dca4 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt @@ -128,8 +128,6 @@ class PersonPickerSessionTest { private fun awaitLoaded(user: JellyfinUser) { try { rule.waitUntil(10_000) { - // Setup navigation writes Compose state asynchronously; publish it before pumping replacement composition. - androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() Shadows.shadowOf(Looper.getMainLooper()).idle() models.lastOrNull()?.signedInPerson == user.name!!.lowercase() && models.last().conductarr.identity.value?.signedIn == true && diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt index 7ea534e6..fb7bf06e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -33,8 +33,6 @@ import com.github.damontecres.wholphin.test.TestTracks import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.ItemFields -import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.junit.Assert.* import org.junit.After import org.junit.Before @@ -221,43 +219,6 @@ class TitleReadFacadeTest { assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season) } - @Test fun leanEpisodeListsKeepSingleItemPlaybackTracksEvenWithoutNextUp() = runTest { - val series = BaseItemDto(id = seriesId, name = "Series", type = BaseItemKind.SERIES, people = emptyList(), providerIds = mapOf("Tmdb" to "42")) - val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 3, indexNumber = 1) - coEvery { library.getItem(seriesId) } returns successResponse(series) - coEvery { library.getItem(firstId) } returns successResponse(episode) - coEvery { shows.getNextUp(any()) } coAnswers { - assertTrue(firstArg().fields.orEmpty().isEmpty()) - successQueryResult(emptyList()) - } - coEvery { items.getItems(any()) } returns successQueryResult( - listOf(BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 3))) - coEvery { shows.getEpisodes(any()) } coAnswers { - val fields = firstArg().fields.orEmpty() - assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PEOPLE, ItemFields.PARENT_ID) }) - successQueryResult(listOf(episode.copy(mediaSources = null, overview = "Episode synopsis"))) - } - val page = facade.read(seriesId, 3, preferences) - assertEquals(firstId.toString(), page.playTarget?.id) - assertEquals("MKV", page.versions.single().container) - assertEquals(listOf(3), page.versions.single().audio.map { it.index }) - val saved = requireNotNull(facade.readSaved(seriesId, preferences)) - assertEquals(page.versions, saved.versions) - } - - @Test fun performerCreditListsOmitStreamsButKeepCharacterRoles() = runTest { - coEvery { library.getItem(firstId) } returns successResponse( - BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor")) - coEvery { items.getItems(any()) } coAnswers { - val fields = firstArg().fields.orEmpty() - assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PARENT_ID) }) - assertTrue(ItemFields.PEOPLE in fields) - successQueryResult(listOf(file(secondId).copy(people = listOf( - org.jellyfin.sdk.model.api.BaseItemPerson(id = firstId, name = "Actor", role = "Chef"))))) - } - assertEquals("Chef", facade.readPerson(firstId, 0).credits.single().character) - } - @Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest { coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) coEvery { playback.savePlayVersion(any(), any()) } returns null diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt deleted file mode 100644 index fc9c246b..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt +++ /dev/null @@ -1,455 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import com.sun.net.httpserver.HttpServer -import java.io.File -import java.net.InetSocketAddress -import java.security.MessageDigest -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder - -/** Exercises the production transport, verification store and state machine against HTTP bytes. */ -@OptIn(ExperimentalCoroutinesApi::class) -class HouseholdUpdateControllerTest { - @get:Rule val temporary = TemporaryFolder() - private lateinit var server: HttpServer - private lateinit var directory: File - private lateinit var base: String - private val apk = ByteArray(128 * 1024) { (it % 251).toByte() } - private var code = 100001L - private var releaseStatus = 200 - private var assetStatus = 200 - private var wireTruncated = false - private var metadataTruncated = false - private var listStatus = 200 - private var listBody = "[]" - private var invalidJson = false - private var badHash = false - private var truncated = false - private var chunked = false - private var unsupported = false - private var manifestSigner = UpdateChannel.SIGNER - private var redirect = false - private var redirectTarget: String? = null - private val requests = mutableListOf>>() - private val platform = Platform() - - private inner class Platform : UpdatePlatform { - var archive = ApkIdentity(UpdateChannel.PACKAGE, InstalledVersion("1.1.1", 100001), setOf(UpdateChannel.SIGNER)) - var installed = ApkIdentity(UpdateChannel.PACKAGE, InstalledVersion("1.1.0", 65), setOf(UpdateChannel.SIGNER)) - var permission = true - var settingsAvailable = true - var installs = 0 - var settingsOpens = 0 - var inspectAction: (() -> Unit)? = null - var outcome = InstallOutcome.Cancelled - override fun installed() = installed - override fun inspect(file: File): ApkIdentity { inspectAction?.invoke(); return archive } - override fun hasInstallPermission() = permission - override fun openPermissionSettings(): Boolean { settingsOpens++; return settingsAvailable } - override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) } - } - private val preferences = object : UpdatePreferences { - override val autoCheck = MutableStateFlow(true) - override suspend fun url() = "$base/releases/latest" - override suspend fun enabled() = autoCheck.value - override suspend fun setAutoCheck(enabled: Boolean) { autoCheck.value = enabled } - } - - @Before fun start() { - directory = temporary.newFolder() - server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) - base = "http://127.0.0.1:${server.address.port}" - server.createContext("/") { exchange -> - synchronized(requests) { requests += exchange.requestHeaders.mapValues { it.value.toList() } } - val path = exchange.requestURI.path - val status = when (path) { - "/releases/latest" -> releaseStatus - "/releases" -> listStatus - "/apk" -> assetStatus - else -> 200 - } - val content = when (path) { - "/releases/latest", "/redirected" -> if (invalidJson) "not json" else """{"name":"v1.1.1","body":"Household notes","published_at":"2026-11-07T12:00:00Z","assets":[{"name":"wolfskin-update.json","browser_download_url":"$base/manifest"},{"name":"Wolfskin-armeabi-v7a.apk","browser_download_url":"$base/apk"}]}""" - "/manifest" -> UpdateManifest.encode(manifest()) - "/releases" -> listBody - else -> "" - } - if (redirect && path == "/releases/latest") { - exchange.responseHeaders.add("Location", redirectTarget ?: "$base/redirected") - exchange.responseHeaders.add("Set-Cookie", "private-session=never-forward") - exchange.sendResponseHeaders(302, -1) - } else { - val bytes = if (path == "/apk") (if (truncated) apk.copyOf(apk.size / 2) else apk) else content.toByteArray() - val length = when { - chunked && path == "/apk" -> 0L - wireTruncated && path == "/apk" -> apk.size.toLong() - metadataTruncated && path == "/releases/latest" -> bytes.size + 1L - else -> bytes.size.toLong() - } - exchange.sendResponseHeaders(status, length) - exchange.responseBody.use { it.write(bytes) } - } - exchange.close() - } - server.start() - } - @After fun stop() { server.stop(0) } - - private fun manifest() = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", code, - "2026-11-07T12:00:00Z", "a".repeat(40), "pwolfskin-opt-v1.1.1", manifestSigner, - listOf(UpdateAsset(if (unsupported) "x86_64" else "armeabi-v7a", if (unsupported) "Wolfskin-x86_64.apk" else "Wolfskin-armeabi-v7a.apk", apk.size.toLong(), - if (badHash) "0".repeat(64) else MessageDigest.getInstance("SHA-256").digest(apk).joinToString("") { "%02x".format(it) }, 23))) - - private fun controller(scope: kotlinx.coroutines.CoroutineScope) = HouseholdUpdateController(scope, preferences, platform, - UpdateTransport(allowLocalTestHttp = true), VerifiedUpdateStore(directory, platform), listOf("armeabi-v7a"), 30) - - @Test fun newerReleaseDownloadsVerifiesAndRestoresWithoutInstalling() = runTest { - val controller = controller(this) - controller.check(true); advanceUntilIdle() - assertTrue(controller.state.value is UpdateUiState.Available) - val metadata = controller.release.value - assertEquals(controller.state.value, metadata) - controller.download(); advanceUntilIdle() - assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) - assertEquals(metadata, controller.release.value) - assertEquals(0, platform.installs) - val restored = controller(this); advanceUntilIdle() - assertEquals(UpdateUiState.Ready("1.1.1"), restored.state.value) - assertEquals(metadata, restored.release.value) - restored.install(); advanceUntilIdle() - assertEquals(UpdateUiState.InstallCancelled, restored.state.value) - assertEquals(1, platform.installs) - assertTrue(File(directory, "verified.apk").exists()) - } - - @Test fun existingManifestOnlyReadyRecordRemainsReadable() = runTest { - val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle() - val metadata = old.release.value!! - assertEquals(manifest(), UpdateManifest.parse(File(directory, "ready.json").readText())) - File(directory, "ready.json").writeText(UpdateManifest.encode(manifest())) - val recreated = controller(this); advanceUntilIdle() - assertEquals(UpdateUiState.Ready("1.1.1"), recreated.state.value) - assertEquals(metadata.copy(notes = ""), recreated.release.value) - assertEquals(0, platform.installs) - } - - @Test fun equalCodeIsUpToDateAndOlderCodeIsNotASuccess() = runTest { - code = 65 - val equal = controller(this); equal.check(true); advanceUntilIdle() - assertEquals(UpdateUiState.UpToDate, equal.state.value) - code = 64 - val older = controller(this); older.check(true); advanceUntilIdle() - assertEquals(UpdateFailure.LowerVersionCode, (older.state.value as UpdateUiState.Failed).kind) - } - - @Test fun emptyChannelIsDistinctFromMissingOrRefusedEndpoint() = runTest { - releaseStatus = 404 - val empty = controller(this); empty.check(true); advanceUntilIdle() - assertEquals(UpdateUiState.NoReleasePublished, empty.state.value) - for (status in listOf(401, 403, 404, 500, 503)) { - listStatus = status - val failure = controller(this); failure.check(true); advanceUntilIdle() - assertEquals(UpdateFailure.HttpRefused, (failure.state.value as UpdateUiState.Failed).kind) - } - } - - @Test fun refusedLatestNeverBecomesUpToDate() = runTest { - for (status in listOf(401, 403, 500, 503)) { - releaseStatus = status - val controller = controller(this); controller.check(true); advanceUntilIdle() - assertEquals(UpdateFailure.HttpRefused, (controller.state.value as UpdateUiState.Failed).kind) - } - } - - @Test fun invalidJsonAndUnavailableAbiAreTypedFailures() = runTest { - invalidJson = true - val invalid = controller(this); invalid.check(true); advanceUntilIdle() - assertEquals(UpdateFailure.InvalidMetadata, (invalid.state.value as UpdateUiState.Failed).kind) - invalidJson = false; unsupported = true - val unsupported = controller(this); unsupported.check(true); advanceUntilIdle() - assertEquals(UpdateFailure.UnsupportedAbi, (unsupported.state.value as UpdateUiState.Failed).kind) - } - - @Test fun manifestSignerDifferentFromInstallationFailsBeforeDownload() = runTest { - manifestSigner = "b".repeat(64) - val controller = controller(this) - controller.check(true); advanceUntilIdle() - assertEquals(UpdateFailure.WrongSigner, (controller.state.value as UpdateUiState.Failed).kind) - assertFalse(File(directory, "verified.apk").exists()) - assertEquals(0, platform.installs) - } - - @Test fun checksumAndLengthFailuresNeverCommitReady() = runTest { - for (badChecksum in listOf(true, false)) { - badHash = badChecksum; truncated = !badChecksum - val controller = controller(this); controller.check(true); advanceUntilIdle() - controller.download(); advanceUntilIdle() - assertEquals(UpdateFailure.ChecksumMismatch, (controller.state.value as UpdateUiState.Failed).kind) - assertFalse(File(directory, "ready.json").exists()) - assertFalse(File(directory, "download.part").exists()) - } - } - - @Test fun wrongPackageSignerAndArchiveCodeCannotBecomeReady() = runTest { - val original = platform.archive - for ((archive, expected) in listOf( - original.copy(packageId = "other.package") to UpdateFailure.WrongPackage, - original.copy(signers = setOf("b".repeat(64))) to UpdateFailure.WrongSigner, - original.copy(version = InstalledVersion("1.1.1", 65)) to UpdateFailure.LowerVersionCode, - )) { - platform.archive = archive - val controller = controller(this); controller.check(true); advanceUntilIdle() - controller.download(); advanceUntilIdle() - assertEquals(expected, (controller.state.value as UpdateUiState.Failed).kind) - assertFalse(File(directory, "ready.json").exists()) - } - } - - @Test fun chunkedTransferReportsUnknownTotalAndCancelDeletesPartial() = runTest { - chunked = true - val controller = controller(this) - var observedUnknownLength = false - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - controller.state.collect { state -> - if (state is UpdateUiState.Downloading && state.bytesDone > 0) { - observedUnknownLength = state.bytesTotal == null - controller.cancel() - } - } - } - controller.check(true); advanceUntilIdle() - controller.download(); advanceUntilIdle() - assertTrue(observedUnknownLength) - assertTrue(controller.state.value is UpdateUiState.Available) - assertFalse(File(directory, "download.part").exists()) - assertFalse(File(directory, "ready.json").exists()) - } - - @Test fun redirectRequestsContainNoCredentialsAndRejectUrlUserInfo() = runTest { - redirect = true - redirectTarget = "http://localhost:${server.address.port}/redirected" - val controller = controller(this); controller.check(true); advanceUntilIdle() - assertTrue(controller.state.value is UpdateUiState.Available) - assertTrue(requests.size >= 3) - requests.forEach { headers -> - assertFalse(headers.keys.any { it.equals("Authorization", true) || it.equals("Cookie", true) || - it.contains("Jellyfin", true) || it.contains("Emby", true) || it.contains("MediaBrowser", true) }) - } - redirectTarget = "http://user:password@127.0.0.1:${server.address.port}/redirected" - val rejected = controller(this); rejected.check(true); advanceUntilIdle() - assertEquals(UpdateFailure.InvalidMetadata, (rejected.state.value as UpdateUiState.Failed).kind) - } - - @Test fun manualCheckWorksWithAutoOffAndPermissionDenialKeepsReady() = runTest { - preferences.autoCheck.value = false - val controller = controller(this) - controller.check(false); advanceUntilIdle() - assertEquals(UpdateUiState.Idle, controller.state.value) - controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle() - platform.permission = false - controller.install(); advanceUntilIdle() - assertEquals(UpdateUiState.PermissionNeeded, controller.state.value) - controller.check(true); advanceUntilIdle() - assertEquals(UpdateUiState.PermissionNeeded, controller.state.value) - controller.onResume(); advanceUntilIdle() - assertEquals(UpdateUiState.InstallCancelled, controller.state.value) - assertEquals(0, platform.installs) - platform.settingsAvailable = false - controller.install(); advanceUntilIdle() - assertEquals(UpdateUiState.NoInstaller, controller.state.value) - } - - @Test fun permissionDismissalRetainsReadyAndInstallReopensSettings() = runTest { - val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle() - val metadata = controller.release.value - platform.permission = false - controller.install(); advanceUntilIdle() - assertEquals(UpdateUiState.PermissionNeeded, controller.state.value) - controller.install(); advanceUntilIdle() - assertEquals(2, platform.settingsOpens) - controller.cancel(); advanceUntilIdle() - assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) - assertEquals(metadata, controller.release.value) - assertArrayEquals(apk, File(directory, "verified.apk").readBytes()) - controller.install(); advanceUntilIdle() - assertEquals(UpdateUiState.PermissionNeeded, controller.state.value) - assertEquals(3, platform.settingsOpens) - assertEquals(0, platform.installs) - } - - @Test fun handoffDismissalKeepsReadyWithoutAllowingAnotherOwnedSession() = runTest { - val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle() - platform.outcome = InstallOutcome.Pending - controller.install(); advanceUntilIdle() - controller.cancel(); advanceUntilIdle() - assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) - controller.handleInstallOutcome(InstallOutcome.Pending); advanceUntilIdle() - assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) - val before = requests.size - controller.install(); advanceUntilIdle() - controller.check(true); advanceUntilIdle() - controller.download(); advanceUntilIdle() - assertEquals(1, platform.installs) - assertEquals(before, requests.size) - assertArrayEquals(apk, File(directory, "verified.apk").readBytes()) - controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle() - controller.install(); advanceUntilIdle() - assertEquals(2, platform.installs) - } - - @Test fun dismissalDuringInstallVerificationDoesNotDiscardVerifiedBytes() = runTest { - val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle() - platform.inspectAction = { controller.cancel() } - controller.install(); advanceUntilIdle() - assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) - assertArrayEquals(apk, File(directory, "verified.apk").readBytes()) - assertEquals(0, platform.installs) - } - - @Test fun corruptPersistedBytesAndUnverifiedPartialsAreDiscarded() = runTest { - val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle() - File(directory, "verified.apk").appendText("corrupt") - File(directory, "download.part").writeText("incomplete") - val recreated = controller(this); advanceUntilIdle() - assertEquals(UpdateUiState.Idle, recreated.state.value) - assertFalse(File(directory, "verified.apk").exists()) - assertFalse(File(directory, "download.part").exists()) - } - - @Test fun downloadHttpFailureRetriesWithoutFalseSuccess() = runTest { - val controller = controller(this); controller.check(true); advanceUntilIdle() - assetStatus = 403 - controller.download(); advanceUntilIdle() - assertEquals(UpdateFailure.HttpRefused, (controller.state.value as UpdateUiState.Failed).kind) - assetStatus = 200 - controller.retry(); advanceUntilIdle() - assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) - } - - @Test fun failuresIdentifyCheckDownloadAndInstallWithoutParsingMessages() = runTest { - val controller = controller(this) - releaseStatus = 403 - controller.check(true); advanceUntilIdle() - assertEquals(UpdateOperation.Check, (controller.state.value as UpdateUiState.Failed).operation) - releaseStatus = 200 - controller.retry(); advanceUntilIdle() - assetStatus = 403 - controller.download(); advanceUntilIdle() - assertEquals(UpdateOperation.Download, (controller.state.value as UpdateUiState.Failed).operation) - assetStatus = 200 - controller.retry(); advanceUntilIdle() - File(directory, "verified.apk").writeBytes(ByteArray(apk.size)) - controller.install(); advanceUntilIdle() - val failed = controller.state.value as UpdateUiState.Failed - assertEquals(UpdateOperation.Install, failed.operation) - assertEquals(UpdateFailure.ChecksumMismatch, failed.kind) - assertFalse(File(directory, "ready.json").exists()) - } - - @Test fun interruptedWireDownloadDoesNotPersistReady() = runTest { - truncated = true; wireTruncated = true - val controller = controller(this); controller.check(true); advanceUntilIdle() - controller.download(); advanceUntilIdle() - assertTrue(controller.state.value is UpdateUiState.Failed) - assertFalse(File(directory, "ready.json").exists()) - assertFalse(File(directory, "download.part").exists()) - } - - @Test fun exhaustedStorageFailsBeforeDownloading() = runTest { - val fullDirectory = object : File(directory.absolutePath) { override fun getUsableSpace(): Long = 0 } - val controller = HouseholdUpdateController(this, preferences, platform, UpdateTransport(true), - VerifiedUpdateStore(fullDirectory, platform), listOf("armeabi-v7a"), 30) - controller.check(true); advanceUntilIdle() - val before = requests.size - controller.download(); advanceUntilIdle() - assertEquals(UpdateFailure.InsufficientStorage, (controller.state.value as UpdateUiState.Failed).kind) - assertEquals(before, requests.size) - } - - @Test fun successCallbackRefreshesPackageManagerInsteadOfClaimingAnUpgrade() = runTest { - val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle() - platform.outcome = InstallOutcome.Success - controller.install(); advanceUntilIdle() - assertEquals(65L, controller.installedVersion.value.versionCode) - assertTrue(File(directory, "verified.apk").exists()) - controller.cancel(); advanceUntilIdle(); controller.install(); advanceUntilIdle() - assertEquals(1, platform.installs) - platform.installed = platform.archive - controller.onResume(); advanceUntilIdle() - assertEquals(100001L, controller.installedVersion.value.versionCode) - assertEquals(UpdateUiState.Idle, controller.state.value) - assertFalse(File(directory, "verified.apk").exists()) - } - - @Test fun successCallbackDeliveredToTheReplacedProcessDoesNotLeaveAFalseHandoff() = runTest { - val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle() - platform.installed = platform.archive - val replaced = controller(this); advanceUntilIdle() - replaced.handleInstallOutcome(InstallOutcome.Success); advanceUntilIdle() - assertEquals(100001L, replaced.installedVersion.value.versionCode) - assertEquals(UpdateUiState.Idle, replaced.state.value) - replaced.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle() - assertEquals(UpdateUiState.Idle, replaced.state.value) - assertFalse(File(directory, "verified.apk").exists()) - replaced.check(true); advanceUntilIdle() - assertEquals(UpdateUiState.UpToDate, replaced.state.value) - } - - @Test fun reentryCheckCannotReplacePendingConfirmationOrStartAnotherSession() = runTest { - val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle() - platform.outcome = InstallOutcome.Pending - controller.install(); advanceUntilIdle() - val before = requests.size - controller.check(true); advanceUntilIdle() - assertEquals(UpdateUiState.HandingOff, controller.state.value) - controller.install(); advanceUntilIdle() - assertEquals(1, platform.installs) - assertEquals(before, requests.size) - controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle() - assertEquals(UpdateUiState.InstallCancelled, controller.state.value) - assertTrue(File(directory, "verified.apk").exists()) - } - - @Test fun productionTransportRefusesCleartextBeforeRequesting() = runTest { - try { - UpdateTransport().latest("$base/releases/latest", listOf("armeabi-v7a"), 30) - fail("Cleartext should be refused") - } catch (error: UpdateException) { assertEquals(UpdateFailure.InvalidMetadata, error.kind) } - assertTrue(requests.isEmpty()) - } - - @Test fun unavailableNetworkIsOfflineNotUpToDate() = runTest { - server.stop(0) - val controller = controller(this); controller.check(true); advanceUntilIdle() - assertEquals(UpdateUiState.Offline, controller.state.value) - } - - @Test fun interruptedFirstCheckIsNetworkFailureNotMissingDownloadStorage() = runTest { - directory = File(directory, "not-created") - metadataTruncated = true - val controller = controller(this); controller.check(true); advanceUntilIdle() - assertEquals(UpdateFailure.Network, (controller.state.value as UpdateUiState.Failed).kind) - assertFalse(directory.exists()) - } - - @Test fun compatibleArm32IsPreferredAndUniversalRespectsMinimumSdk() { - val arm32 = manifest().assets.single() - val arm64 = arm32.copy(abi = "arm64-v8a", name = "Wolfskin-arm64-v8a.apk") - val universal = arm32.copy(abi = "universal", name = "Wolfskin.apk", minSdk = 31) - val manifest = manifest().copy(assets = listOf(arm64, arm32, universal)) - assertEquals(arm32, manifest.selectAsset(listOf("arm64-v8a", "armeabi-v7a"), 30)) - try { manifest.selectAsset(listOf("unknown"), 30); fail("Minimum SDK must be enforced") } - catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateManifestTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateManifestTest.kt deleted file mode 100644 index 947aace9..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateManifestTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import org.junit.Assert.assertEquals -import org.junit.Assert.fail -import org.junit.Test - -/** Malformed channel metadata must never become a compatible version comparison. */ -class UpdateManifestTest { - private val asset = UpdateAsset("armeabi-v7a", "Wolfskin-armeabi-v7a.apk", 128, "a".repeat(64), 23) - private val manifest = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", 100001, - "2026-11-07T12:00:00Z", "b".repeat(40), "pwolfskin-opt-v1.1.1", UpdateChannel.SIGNER, listOf(asset)) - - @Test fun malformedManifestFieldsAreRejectedBeforeVersionComparison() { - val invalid = listOf( - manifest.copy(schemaVersion = 2), - manifest.copy(versionName = "1.1.1-opt"), - manifest.copy(versionCode = 0), - manifest.copy(publishedAt = "yesterday"), - manifest.copy(sourceCommit = "main"), - manifest.copy(sourceTag = ""), - manifest.copy(signerSha256 = "not-a-certificate-fingerprint"), - manifest.copy(assets = emptyList()), - manifest.copy(assets = listOf(asset.copy(bytes = 0))), - manifest.copy(assets = listOf(asset.copy(sha256 = "not-a-checksum"))), - manifest.copy(assets = listOf(asset.copy(abi = "arm64-v8a"))), - manifest.copy(assets = listOf(asset, asset)), - ) - invalid.forEach { candidate -> - try { UpdateManifest.parse(UpdateManifest.encode(candidate)); fail("Invalid manifest was accepted: $candidate") } - catch (error: UpdateException) { assertEquals(UpdateFailure.InvalidMetadata, error.kind) } - } - } - - @Test fun fallbackRequiresACompatibleStandaloneAsset() { - val universal = asset.copy(abi = "universal", name = "Wolfskin.apk", minSdk = 35) - val parsed = UpdateManifest.parse(UpdateManifest.encode(manifest.copy(assets = listOf(universal)))) - assertEquals(universal, parsed.selectAsset(listOf("arm64-v8a"), 35)) - try { parsed.selectAsset(listOf("arm64-v8a"), 34); fail("Unsupported minimum SDK was accepted") } - catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) } - } - - @Test fun universalDoesNotMakeAnUnsupportedProcessorCompatible() { - val universal = asset.copy(abi = "universal", name = "Wolfskin.apk") - val parsed = UpdateManifest.parse(UpdateManifest.encode(manifest.copy(assets = listOf(universal)))) - try { parsed.selectAsset(listOf("riscv64"), 34); fail("Unsupported processor was accepted") } - catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateSessionStreamTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateSessionStreamTest.kt deleted file mode 100644 index 4f5f3159..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateSessionStreamTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import java.io.ByteArrayOutputStream -import java.security.MessageDigest -import org.junit.Assert.assertEquals -import org.junit.Assert.fail -import org.junit.Test - -/** A replacement or incomplete stream must never reach the session commit boundary. */ -class UpdateSessionStreamTest { - private val original = ByteArray(128 * 1024) { (it % 251).toByte() } - private val asset = UpdateAsset("armeabi-v7a", "Wolfskin-armeabi-v7a.apk", original.size.toLong(), - MessageDigest.getInstance("SHA-256").digest(original).toHexString(), 23) - - @Test fun sameLengthReplacementAfterVerificationIsRejected() { - val replaced = original.copyOf().also { it[it.lastIndex] = (it.last() + 1).toByte() } - expectMismatch(replaced) - } - - @Test fun truncatedAndOversizedSessionStreamsAreRejected() { - expectMismatch(original.copyOf(original.size - 1)) - expectMismatch(original + byteArrayOf(1)) - } - - private fun expectMismatch(bytes: ByteArray) { - try { - bytes.inputStream().use { copyVerifiedUpdate(it, ByteArrayOutputStream(), asset) } - fail("Changed installation bytes were accepted") - } catch (error: UpdateException) { - assertEquals(UpdateFailure.ChecksumMismatch, error.kind) - } - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt index 3c1d01ce..775235f2 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt @@ -72,7 +72,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { val response = api.itemsApi.getItems( userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(if (destination == "movies") BaseItemKind.MOVIE else BaseItemKind.SERIES), - fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED), + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset, limit = WolfskinTokens.Behaviour.libraryChunk.toInt(), ).content diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index 1530b8df..ff727717 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -53,8 +53,7 @@ class TitleReadFacade @Inject constructor( @param:ApplicationContext private val context: Context, ) { private val json = Json { ignoreUnknownKeys = true } - private val episodeFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW) - private val creditFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.PEOPLE) + private val fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID) /** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */ suspend fun findHeld(tmdb: Long, media: String): UUID? { @@ -89,12 +88,12 @@ class TitleReadFacade @Inject constructor( val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList() val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else { - api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, enableResumable = true)).content.items.firstOrNull()?.let { readItem(it.id) } + api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content } } val selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull() - val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = episodeFields)).content.items.orEmpty() }.orEmpty() - val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }?.let { readItem(it.id) } + val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = fields)).content.items.orEmpty() }.orEmpty() + val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE } val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id } val numbers = seasons.mapNotNull { it.indexNumber } val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences) @@ -166,7 +165,7 @@ class TitleReadFacade @Inject constructor( } else emptyList() val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = creditFields, startIndex = start, limit = 200, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200, sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty() val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id } if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed") diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdatePlatform.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdatePlatform.kt deleted file mode 100644 index aa30e022..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdatePlatform.kt +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.pm.PackageInfo -import android.content.pm.PackageInstaller -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.provider.Settings -import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File -import java.io.InputStream -import java.io.OutputStream -import java.security.MessageDigest -import javax.inject.Inject -import javax.inject.Singleton - -/** PackageManager inspection and confirmation-required PackageInstaller sessions. */ -@Singleton -class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext private val context: Context) : UpdatePlatform { - private val manager = context.packageManager - private val sessions = context.getSharedPreferences("update-install-session", Context.MODE_PRIVATE) - override fun installed(): ApkIdentity = identity(manager.getPackageInfo(context.packageName, signatureFlags())) - override fun inspect(file: File): ApkIdentity? = manager.getPackageArchiveInfo(file.absolutePath, signatureFlags())?.let(::identity) - - @Suppress("DEPRECATION") - private fun signatureFlags(): Int = if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES else PackageManager.GET_SIGNATURES - - @Suppress("DEPRECATION") - private fun identity(info: PackageInfo): ApkIdentity { - val certificates = if (Build.VERSION.SDK_INT >= 28) info.signingInfo?.apkContentsSigners else info.signatures - val hashes = certificates.orEmpty().map { - MessageDigest.getInstance("SHA-256").digest(it.toByteArray()).toHexString() - }.toSet() - val code = if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else info.versionCode.toLong() - return ApkIdentity(info.packageName, InstalledVersion(info.versionName.orEmpty(), code), hashes) - } - - override fun hasInstallPermission(): Boolean = if (Build.VERSION.SDK_INT >= 26) manager.canRequestPackageInstalls() - else Settings.Secure.getInt(context.contentResolver, Settings.Secure.INSTALL_NON_MARKET_APPS, 0) == 1 - - override fun openPermissionSettings(): Boolean { - val intent = if (Build.VERSION.SDK_INT >= 26) Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}")) - else Intent(Settings.ACTION_SECURITY_SETTINGS) - if (intent.resolveActivity(manager) == null) return false - return try { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); true } catch (_: RuntimeException) { false } - } - - override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) { - if (context.noBackupFilesDir.usableSpace < file.length()) { - outcome(InstallOutcome.InsufficientStorage) - return - } - val installer = manager.packageInstaller - val parameters = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { - setAppPackageName(context.packageName) - setSize(asset.bytes) - if (Build.VERSION.SDK_INT >= 31) setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED) - } - var sessionId: Int? = null - try { - sessionId = installer.createSession(parameters) - check(sessions.edit().putInt("id", sessionId).commit()) - installer.openSession(sessionId).use { session -> - session.openWrite("Wolfskin.apk", 0, asset.bytes).use { output -> - file.inputStream().use { copyVerifiedUpdate(it, output, asset) } - session.fsync(output) - } - val intent = Intent(context, UpdateInstallReceiver::class.java).setAction("${context.packageName}.UPDATE_RESULT") - val flags = PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0 - val result = PendingIntent.getBroadcast(context, sessionId, intent, flags) - session.commit(result.intentSender) - } - } catch (error: Exception) { - sessionId?.let { runCatching { installer.abandonSession(it) } } - if (error is UpdateException) throw error - outcome(InstallOutcome.Unavailable) - } - } - - fun ownsSession(intent: Intent): Boolean = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1) == sessions.getInt("id", -2) - - fun launchConfirmation(intent: Intent): Boolean { - val activity = Intent(context, UpdateConfirmationActivity::class.java).putExtra("confirmation", intent) - return try { - context.startActivity(activity.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) - true - } catch (_: RuntimeException) { false } - } -} - -/** Check the bytes actually staged into Android, not a prior read of the private path. */ -internal fun copyVerifiedUpdate(input: InputStream, output: OutputStream, asset: UpdateAsset) { - val digest = MessageDigest.getInstance("SHA-256") - val buffer = ByteArray(64 * 1024) - var count = 0L - while (true) { - val length = input.read(buffer) - if (length < 0) break - count += length - if (count > asset.bytes) throw UpdateException(UpdateFailure.ChecksumMismatch, "APK changed while staging installation") - digest.update(buffer, 0, length) - output.write(buffer, 0, length) - } - if (count != asset.bytes || digest.digest().toHexString() != asset.sha256) { - throw UpdateException(UpdateFailure.ChecksumMismatch, "APK changed while staging installation") - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdateService.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdateService.kt deleted file mode 100644 index 0410e22e..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdateService.kt +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import android.content.Context -import android.os.Build -import androidx.datastore.core.DataStore -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.update -import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn - -/** Hilt entry point shared by app startup, Wolfskin UI and classic update controls. */ -@Singleton -class AndroidUpdateService @Inject constructor( - @param:ApplicationContext context: Context, - @param:IoCoroutineScope scope: CoroutineScope, - dataStore: DataStore, - val platform: AndroidUpdatePlatform, -) { - private val preferences = object : UpdatePreferences { - override val autoCheck = dataStore.data.map { it.autoCheckForUpdates }.stateIn(scope, SharingStarted.Eagerly, true) - override suspend fun enabled() = dataStore.data.first().autoCheckForUpdates - override suspend fun url(): String { - val prefs = dataStore.updateData { current -> - val url = UpdateChannel.migrateUrl(context.packageName, current.updateUrl) - if (url == current.updateUrl) current else current.update { updateUrl = url } - } - return prefs.updateUrl - } - override suspend fun setAutoCheck(enabled: Boolean) { - dataStore.updateData { it.update { autoCheckForUpdates = enabled } } - } - } - val controller = HouseholdUpdateController(scope, preferences, platform, UpdateTransport(), - VerifiedUpdateStore(File(context.noBackupFilesDir, "updates"), platform), Build.SUPPORTED_ABIS.toList(), Build.VERSION.SDK_INT) - suspend fun channelUrl(): String = preferences.url() -} - -/** Single process-scoped controller binding for every update entry point. */ -@Module -@InstallIn(SingletonComponent::class) -object UpdateModule { - @Provides - @Singleton - fun provideUpdateController(service: AndroidUpdateService): UpdateController = service.controller -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/ClassicUpdatePage.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/ClassicUpdatePage.kt deleted file mode 100644 index 1c61e943..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/ClassicUpdatePage.kt +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.tv.material3.Button -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.ui.components.ScrollableDialog -import com.mikepenz.markdown.m3.Markdown -import org.justinvisser.wolfskin.design.WolfskinTokens - -/** Trial entry point uses the same verified service, never the legacy download-and-install path. */ -@Composable -fun ClassicUpdatePage(controller: UpdateController, modifier: Modifier = Modifier) { - val state by controller.state.collectAsState() - val installed by controller.installedVersion.collectAsState() - val lifecycle = LocalLifecycleOwner.current.lifecycle - var notes by remember { mutableStateOf(null) } - LaunchedEffect(controller) { controller.check(true) } - DisposableEffect(lifecycle, controller) { - val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) controller.onResume() } - lifecycle.addObserver(observer) - onDispose { lifecycle.removeObserver(observer) } - } - Column(modifier, verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsRowGap)) { - Text("Installed: ${installed.versionName}") - when (val current = state) { - is UpdateUiState.Available -> { - Text("${current.versionName} · ${current.bytes} bytes · ${current.dateText}") - Button(onClick = controller::download) { Text("Download") } - if (current.notes.isNotBlank()) { - Button(onClick = { notes = current.notes }) { Text("Release notes") } - } - } - is UpdateUiState.Downloading -> { - Text("${current.bytesDone} / ${current.bytesTotal ?: "—"} bytes") - Button(onClick = controller::cancel) { Text("Cancel download") } - } - is UpdateUiState.Ready, UpdateUiState.InstallCancelled, UpdateUiState.PermissionNeeded, UpdateUiState.NoInstaller -> { - Text(when (current) { - UpdateUiState.InstallCancelled -> "Installation cancelled; verified download retained" - UpdateUiState.PermissionNeeded -> "Allow updates from this app in Android settings" - UpdateUiState.NoInstaller -> "Android installer or permission settings unavailable" - else -> "Verified update ready" - }) - Button(onClick = controller::install) { Text("Install update") } - } - is UpdateUiState.Failed -> { - Text(current.message) - Button(onClick = controller::retry) { Text("Retry") } - } - UpdateUiState.HandingOff -> Text("Confirm installation in Android") - else -> { - Text(when (current) { - UpdateUiState.UpToDate -> "Up to date" - UpdateUiState.NoReleasePublished -> "No release published" - UpdateUiState.Offline -> "No network" - UpdateUiState.Checking -> "Checking" - else -> "Check for an update" - }) - Button(onClick = { controller.check(true) }) { Text("Check again") } - } - } - } - notes?.let { body -> - ScrollableDialog(onDismissRequest = { notes = null }) { - item { Markdown(content = body) } - } - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/FakeUpdateController.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/FakeUpdateController.kt deleted file mode 100644 index ebac04ed..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/FakeUpdateController.kt +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** Deterministic capture/test controller; no network, storage or Android hand-off. */ -class FakeUpdateController( - initialState: UpdateUiState = UpdateUiState.Idle, - initialAutoCheck: Boolean = true, - initialVersion: InstalledVersion = InstalledVersion("1.1.1", 100001), - initialRelease: UpdateUiState.Available? = initialState as? UpdateUiState.Available, -) : UpdateController { - private val mutableState = MutableStateFlow(initialState) - private val mutableAutoCheck = MutableStateFlow(initialAutoCheck) - private val mutableVersion = MutableStateFlow(initialVersion) - private val mutableRelease = MutableStateFlow(initialRelease) - override val state = mutableState.asStateFlow() - override val autoCheck = mutableAutoCheck.asStateFlow() - override val installedVersion = mutableVersion.asStateFlow() - override val release = mutableRelease.asStateFlow() - val actions = mutableListOf() - - fun show(state: UpdateUiState) { - if (state is UpdateUiState.Available) mutableRelease.value = state - mutableState.value = state - } - fun showInstalled(version: InstalledVersion) { mutableVersion.value = version } - override fun check(manual: Boolean) { actions += "check:$manual" } - override fun download() { actions += "download" } - override fun cancel() { actions += "cancel" } - override fun install() { actions += "install" } - override fun retry() { actions += "retry" } - override fun onResume() { actions += "resume" } - override fun setAutoCheck(enabled: Boolean) { mutableAutoCheck.value = enabled } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt deleted file mode 100644 index 091be7a9..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt +++ /dev/null @@ -1,223 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import java.io.IOException -import java.net.ConnectException -import java.net.UnknownHostException -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.Locale -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -/** Existing app preferences, not a separate updater preference store. */ -interface UpdatePreferences { - val autoCheck: StateFlow - suspend fun url(): String - suspend fun enabled(): Boolean - suspend fun setAutoCheck(enabled: Boolean) -} - -/** Process-owned operations outlive screens; only explicit install() crosses into Android. */ -class HouseholdUpdateController( - private val scope: CoroutineScope, - private val preferences: UpdatePreferences, - private val platform: UpdatePlatform, - private val transport: UpdateTransport, - private val store: VerifiedUpdateStore, - private val abis: List, - private val sdk: Int, -) : UpdateController { - private val mutableState = MutableStateFlow(UpdateUiState.Idle) - private val mutableVersion = MutableStateFlow(platform.installed().version) - private val mutableRelease = MutableStateFlow(null) - override val state = mutableState.asStateFlow() - override val installedVersion = mutableVersion.asStateFlow() - override val autoCheck = preferences.autoCheck - override val release = mutableRelease.asStateFlow() - private var candidate: UpdateCandidate? = null - private var ready: UpdateManifest? = null - private var operation: Job? = null - @Volatile private var installerActive = false - private var retryOperation: () -> Unit = { check(true) } - private val restored = scope.async { - store.restore(abis, sdk)?.let { - ready = it.manifest - mutableRelease.value = available(it.manifest, it.asset, it.notes) - mutableState.value = UpdateUiState.Ready(it.manifest.versionName) - } - } - - @Synchronized - private fun runOperation(operationKind: UpdateOperation, action: suspend () -> Unit): Unit { - if (operation?.isActive == true) return - operation = scope.launch { - restored.await() - try { action() } - catch (error: CancellationException) { throw error } - catch (error: UpdateException) { mutableState.value = UpdateUiState.Failed(error.kind, error.message, operationKind) } - catch (_: UnknownHostException) { mutableState.value = UpdateUiState.Offline } - catch (_: ConnectException) { mutableState.value = UpdateUiState.Offline } - catch (error: IOException) { - val release = candidate - val full = state.value is UpdateUiState.Downloading && release != null && - store.directory.usableSpace < release.asset.bytes - mutableState.value = UpdateUiState.Failed(if (full) UpdateFailure.InsufficientStorage else UpdateFailure.Network, - if (full) "Not enough storage for the update" else "Update transfer failed; try again", operationKind) - } - catch (_: Exception) { mutableState.value = UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Update could not be processed", operationKind) } - } - } - - private fun systemOwnsFlow(current: UpdateUiState): Boolean = - installerActive || current == UpdateUiState.HandingOff || current == UpdateUiState.PermissionNeeded - - override fun check(manual: Boolean): Unit = runOperation(UpdateOperation.Check) { - if (systemOwnsFlow(state.value)) return@runOperation - if (!manual && !preferences.enabled()) return@runOperation - ready?.let { manifest -> - mutableState.update { if (systemOwnsFlow(it)) it else UpdateUiState.Ready(manifest.versionName) } - return@runOperation - } - retryOperation = { check(true) } - mutableState.value = UpdateUiState.Checking - val release = transport.latest(preferences.url(), abis, sdk) - if (release == null) { - candidate = null; mutableRelease.value = null - mutableState.value = UpdateUiState.NoReleasePublished - return@runOperation - } - val installed = platform.installed() - mutableVersion.value = installed.version - if (release.manifest.packageId != installed.packageId) throw UpdateException(UpdateFailure.WrongPackage, "Release belongs to a different application") - if (installed.signers != setOf(release.manifest.signerSha256)) throw UpdateException(UpdateFailure.WrongSigner, "Release signer differs from this installation") - if (release.manifest.versionCode < installed.version.versionCode) throw UpdateException(UpdateFailure.LowerVersionCode, "Channel release is older than this installation") - if (release.manifest.versionCode == installed.version.versionCode) { - candidate = null; mutableRelease.value = null - mutableState.value = UpdateUiState.UpToDate - } else { - candidate = release - val metadata = available(release.manifest, release.asset, release.notes) - mutableRelease.value = metadata - mutableState.value = metadata - } - } - - private fun available(manifest: UpdateManifest, asset: UpdateAsset, notes: String): UpdateUiState.Available { - val date = DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.MEDIUM).withLocale(Locale.getDefault()) - .withZone(ZoneId.systemDefault()).format(Instant.parse(manifest.publishedAt)) - return UpdateUiState.Available(manifest.versionName, asset.bytes, date, notes) - } - - override fun download(): Unit = runOperation(UpdateOperation.Download) { - if (systemOwnsFlow(state.value)) return@runOperation - if (ready != null) return@runOperation - val release = candidate ?: return@runOperation - retryOperation = { download() } - store.prepare() - if (store.directory.usableSpace < release.asset.bytes) throw UpdateException(UpdateFailure.InsufficientStorage, "Not enough storage for the update") - mutableState.value = UpdateUiState.Downloading(0, null) - try { - transport.download(release, store.partial) { done, total -> mutableState.value = UpdateUiState.Downloading(done, total) } - currentCoroutineContext().ensureActive() - store.commit(release.manifest, release.asset, release.notes) - currentCoroutineContext().ensureActive() - ready = release.manifest - mutableState.value = UpdateUiState.Ready(release.manifest.versionName) - } catch (error: Exception) { - store.clear() - throw error - } finally { store.partial.delete() } - } - - @Synchronized - override fun cancel() { - transport.cancel() - val previous = operation - if (!installerActive) previous?.cancel() - operation = scope.launch { - previous?.join() - store.partial.delete() - mutableState.value = ready?.let { UpdateUiState.Ready(it.versionName) } - ?: candidate?.let { mutableRelease.value } ?: UpdateUiState.Idle - } - } - - override fun install(): Unit = runOperation(UpdateOperation.Install) { - if (installerActive || state.value == UpdateUiState.HandingOff) return@runOperation - val manifest = ready ?: return@runOperation - retryOperation = { install() } - val asset = manifest.selectAsset(abis, sdk) - try { - store.verify(store.apk, manifest, asset) - if (!platform.hasInstallPermission()) { - mutableState.value = if (platform.openPermissionSettings()) UpdateUiState.PermissionNeeded else UpdateUiState.NoInstaller - return@runOperation - } - currentCoroutineContext().ensureActive() - installerActive = true - mutableState.value = UpdateUiState.HandingOff - platform.install(store.apk, asset, ::handleInstallOutcome) - } catch (error: CancellationException) { - installerActive = false - throw error - } catch (error: Exception) { - installerActive = false - store.clear(); ready = null - retryOperation = { if (candidate != null) download() else check(true) } - throw error - } - } - - fun handleInstallOutcome(outcome: InstallOutcome) { - scope.launch { - restored.await() - installerActive = outcome == InstallOutcome.Pending || outcome == InstallOutcome.Success - if (ready == null && outcome != InstallOutcome.Pending) { - installerActive = false - retryOperation = { check(true) } - mutableState.value = UpdateUiState.Idle - onResume() - return@launch - } - mutableState.value = when (outcome) { - InstallOutcome.Pending -> if (state.value is UpdateUiState.Ready) state.value else UpdateUiState.HandingOff - InstallOutcome.Cancelled -> UpdateUiState.InstallCancelled - InstallOutcome.Unavailable -> UpdateUiState.NoInstaller - InstallOutcome.InsufficientStorage -> UpdateUiState.Failed(UpdateFailure.InsufficientStorage, "Android needs more storage to install the retained update", UpdateOperation.Install) - InstallOutcome.Failed -> UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Android could not install the update; the verified download is retained", UpdateOperation.Install) - InstallOutcome.Success -> UpdateUiState.HandingOff - } - if (outcome != InstallOutcome.Pending) onResume() - } - } - - override fun retry() { retryOperation() } - override fun setAutoCheck(enabled: Boolean) { scope.launch { preferences.setAutoCheck(enabled) } } - override fun onResume() { - scope.launch { - operation?.join() - restored.await() - mutableVersion.value = platform.installed().version - val manifest = ready ?: return@launch - if (mutableVersion.value.versionCode >= manifest.versionCode) { - installerActive = false - store.clear(); ready = null; candidate = null; mutableRelease.value = null; mutableState.value = UpdateUiState.Idle - } else if (state.value == UpdateUiState.PermissionNeeded) { - // Returning from settings requires another explicit Install press. - mutableState.value = if (platform.hasInstallPermission()) UpdateUiState.Ready(manifest.versionName) - else UpdateUiState.InstallCancelled - } - } - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateChannel.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateChannel.kt deleted file mode 100644 index 6b0c12ab..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateChannel.kt +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -/** Package-scoped channel policy; the old production sandbox retains its original feed. */ -object UpdateChannel { - const val PACKAGE = "org.justinvisser.wolfskin.tv.opt" - const val SIGNER = "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d" - const val LEGACY_URL = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest" - const val LATEST_URL = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin-tv-updates/releases/latest" - fun defaultUrl(packageId: String): String = if (packageId == PACKAGE) LATEST_URL else LEGACY_URL - fun migrateUrl(packageId: String, saved: String): String = - if (packageId == PACKAGE && (saved == LEGACY_URL || saved.isBlank())) LATEST_URL else saved - fun isNewer(candidateCode: Long, installedCode: Long): Boolean = candidateCode > installedCode -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt deleted file mode 100644 index b46bb0e2..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import kotlinx.coroutines.flow.StateFlow - -/** Shared process-scoped updater for Wolfskin and classic settings. */ -interface UpdateController { - val state: StateFlow - val autoCheck: StateFlow - val installedVersion: StateFlow - val release: StateFlow - - fun check(manual: Boolean) - fun download() - /** Discard partial bytes; retain verified bytes and restore Ready without duplicating a system session. */ - fun cancel() - fun install() - fun retry() - fun setAutoCheck(enabled: Boolean) - - /** Refresh installed identity and unknown-source permission after Android returns. */ - fun onResume() -} - -/** PackageManager identity, refreshed after returning from the system installer. */ -data class InstalledVersion(val versionName: String, val versionCode: Long) - -/** Only verified, app-private bytes may produce Ready. Checking never initiates download. */ -sealed interface UpdateUiState { - data object Idle : UpdateUiState - data object Checking : UpdateUiState - data object UpToDate : UpdateUiState - data object NoReleasePublished : UpdateUiState - data class Available(val versionName: String, val bytes: Long, val dateText: String, val notes: String) : UpdateUiState - data class Downloading(val bytesDone: Long, val bytesTotal: Long?) : UpdateUiState - data class Ready(val versionName: String) : UpdateUiState - data object PermissionNeeded : UpdateUiState - data object HandingOff : UpdateUiState - data object InstallCancelled : UpdateUiState - data class Failed(val kind: UpdateFailure, val message: String, val operation: UpdateOperation) : UpdateUiState - data object Offline : UpdateUiState - data object NoInstaller : UpdateUiState -} - -/** The failed action is structured; UI must not infer it from a reason string. */ -enum class UpdateOperation { Check, Download, Install } - -/** Failures are distinct from a successful version comparison or an empty channel. */ -enum class UpdateFailure { - Network, HttpRefused, InvalidMetadata, UnsupportedAbi, InsufficientStorage, - ChecksumMismatch, WrongPackage, WrongSigner, LowerVersionCode, -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateInstallReceiver.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateInstallReceiver.kt deleted file mode 100644 index 497060af..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateInstallReceiver.kt +++ /dev/null @@ -1,61 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import androidx.activity.ComponentActivity -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.pm.PackageInstaller -import android.os.Bundle -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -/** Explicit, non-exported callback target accepts only the locally recorded install session. */ -@AndroidEntryPoint -class UpdateInstallReceiver : BroadcastReceiver() { - @Inject lateinit var service: AndroidUpdateService - @Suppress("DEPRECATION") - override fun onReceive(context: Context, intent: Intent) { - if (!service.platform.ownsSession(intent)) return - val outcome = when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) { - PackageInstaller.STATUS_PENDING_USER_ACTION -> { - val confirmation = intent.getParcelableExtra(Intent.EXTRA_INTENT) - if (confirmation != null && service.platform.launchConfirmation(confirmation)) InstallOutcome.Pending else InstallOutcome.Unavailable - } - PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Success - PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Cancelled - PackageInstaller.STATUS_FAILURE_STORAGE -> InstallOutcome.InsufficientStorage - else -> InstallOutcome.Failed - } - service.controller.handleInstallOutcome(outcome) - } -} - -/** System confirmation is launched for result so cancelling retains the already-verified APK. */ -@AndroidEntryPoint -class UpdateConfirmationActivity : ComponentActivity() { - @Inject lateinit var service: AndroidUpdateService - @Suppress("DEPRECATION") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (savedInstanceState != null) return - val confirmation = intent.getParcelableExtra("confirmation") - if (confirmation == null || confirmation.resolveActivity(packageManager) == null) { - service.controller.handleInstallOutcome(InstallOutcome.Unavailable) - finish() - return - } - try { startActivityForResult(confirmation, 1) } catch (_: RuntimeException) { - service.controller.handleInstallOutcome(InstallOutcome.Unavailable) - finish() - } - } - - @Deprecated("System installer uses the activity result contract") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == 1 && resultCode == RESULT_CANCELED) service.controller.handleInstallOutcome(InstallOutcome.Cancelled) - service.controller.onResume() - finish() - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateManifest.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateManifest.kt deleted file mode 100644 index 66a0240a..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateManifest.kt +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import java.time.Instant -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json - -/** Signed APK metadata published only after the final APKs have been inspected. */ -@Serializable -data class UpdateManifest( - val schemaVersion: Int, - val packageId: String, - val versionName: String, - val versionCode: Long, - val publishedAt: String, - val sourceCommit: String, - val sourceTag: String, - val signerSha256: String, - val assets: List, -) { - fun selectAsset(abis: List, sdk: Int): UpdateAsset { - fun compatible(abi: String) = assets.firstOrNull { it.abi == abi && it.minSdk <= sdk } - if ("armeabi-v7a" in abis) compatible("armeabi-v7a")?.let { return it } - for (abi in abis) { - if (abi != "armeabi-v7a" && abi != "universal") compatible(abi)?.let { return it } - } - if (abis.any { it != "universal" && it in supportedAbis }) { - compatible("universal")?.let { return it } - } - throw UpdateException(UpdateFailure.UnsupportedAbi, "No compatible APK for this device") - } - - companion object { - private val json = Json { ignoreUnknownKeys = true } - private val versionPattern = Regex("[0-9]+\\.[0-9]+\\.[0-9]+") - private val commitPattern = Regex("[0-9a-f]{40}") - private val hashPattern = Regex("[0-9a-f]{64}") - private val supportedAbis = setOf("armeabi-v7a", "arm64-v8a", "x86_64", "universal") - fun parse(text: String): UpdateManifest { - try { - val manifest = json.decodeFromString(text) - require(manifest.schemaVersion == 1) - require(manifest.packageId.isNotBlank()) - require(versionPattern.matches(manifest.versionName)) - require(manifest.versionCode in 1..2100000000) - Instant.parse(manifest.publishedAt) - require(commitPattern.matches(manifest.sourceCommit)) - require(manifest.sourceTag.isNotBlank()) - require(isHash(manifest.signerSha256)) - require(manifest.assets.isNotEmpty()) - require(manifest.assets.map { it.abi }.distinct().size == manifest.assets.size) - require(manifest.assets.map { it.name }.distinct().size == manifest.assets.size) - manifest.assets.forEach { - require(it.abi in supportedAbis) - require(it.name == if (it.abi == "universal") "Wolfskin.apk" else "Wolfskin-${it.abi}.apk") - require(it.bytes > 0 && it.minSdk > 0 && isHash(it.sha256)) - } - return manifest - } catch (error: Exception) { - throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid update manifest", error) - } - } - private fun isHash(value: String) = hashPattern.matches(value) - fun encode(manifest: UpdateManifest): String = json.encodeToString(manifest) - } -} - -/** A standalone APK, not a split installation dependency. */ -@Serializable -data class UpdateAsset(val abi: String, val name: String, val bytes: Long, val sha256: String, val minSdk: Int) - -/** Selected immutable release metadata and its anonymous asset URL. */ -data class UpdateCandidate(val manifest: UpdateManifest, val asset: UpdateAsset, val url: String, val notes: String) - -/** Typed boundary error, never interpreted as an up-to-date result. */ -class UpdateException(val kind: UpdateFailure, override val message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateTransport.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateTransport.kt deleted file mode 100644 index b6c83467..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateTransport.kt +++ /dev/null @@ -1,149 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import com.github.damontecres.wholphin.BuildConfig -import java.io.File -import java.util.concurrent.atomic.AtomicReference -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resumeWithException -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import okhttp3.Call -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response - -/** Dedicated unauthenticated client; redirects are individually validated before any request. */ -class UpdateTransport(private val allowLocalTestHttp: Boolean = false) { - private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false).build() - private val activeCall = AtomicReference() - - fun cancel() { activeCall.get()?.cancel() } - - private fun validateUrl(value: String): okhttp3.HttpUrl { - val url = value.toHttpUrlOrNull() - ?: throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid update URL") - val fixture = BuildConfig.DEBUG && allowLocalTestHttp && url.host in listOf("localhost", "127.0.0.1", "::1") - if ((!url.isHttps && !fixture) || url.username.isNotEmpty() || url.password.isNotEmpty()) { - throw UpdateException(UpdateFailure.InvalidMetadata, "Update URLs must use credential-free HTTPS") - } - return url - } - - private suspend fun request(value: String): Response { - var url = validateUrl(value) - repeat(6) { - val call = client.newCall(Request.Builder().url(url).get().build()) - activeCall.set(call) - val response = suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { call.cancel() } - try { - continuation.resume(call.execute()) { _, response, _ -> response.close() } - } catch (error: Exception) { continuation.resumeWithException(error) } - } - if (response.code !in listOf(301, 302, 303, 307, 308)) return response - response.use { - val location = it.header("Location") - ?: throw UpdateException(UpdateFailure.InvalidMetadata, "Redirect has no location") - url = validateUrl(url.resolve(location)?.toString() - ?: throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid redirect")) - } - } - throw UpdateException(UpdateFailure.HttpRefused, "Too many update redirects") - } - - private suspend fun readJson(url: String): kotlinx.serialization.json.JsonElement = request(url).use { - requireSuccess(it) - val source = it.body.source() - if (source.request(MAX_METADATA_BYTES + 1) && source.buffer.size > MAX_METADATA_BYTES) { - throw UpdateException(UpdateFailure.InvalidMetadata, "Update metadata is too large") - } - try { Json.parseToJsonElement(source.readUtf8()) } catch (error: java.io.IOException) { throw error } catch (error: Exception) { - throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid release JSON", error) - } - } - - /** Installed-release notes share the same anonymous redirect policy as checks and assets. */ - suspend fun releasesForLatest(url: String, limit: Int = 50): JsonArray { - val latest = validateUrl(url) - val list = latest.newBuilder() - var last = latest.pathSegments.lastIndex - while (last > 0 && latest.pathSegments[last].isEmpty()) { - list.removePathSegment(last) - last-- - } - return readJson(list.removePathSegment(last).setQueryParameter("limit", limit.toString()).build().toString()).jsonArray - } - - /** A latest 404 is empty only when the release-list endpoint successfully returns an empty array. */ - suspend fun latest(url: String, abis: List, sdk: Int): UpdateCandidate? { - try { - val release = request(url).use { - if (it.code == 404) { - if (releasesForLatest(url, 1).isEmpty()) return null - } - requireSuccess(it) - val source = it.body.source() - if (source.request(MAX_METADATA_BYTES + 1) && source.buffer.size > MAX_METADATA_BYTES) { - throw UpdateException(UpdateFailure.InvalidMetadata, "Release metadata is too large") - } - Json.parseToJsonElement(source.readUtf8()).jsonObject - } - return candidate(release, abis, sdk) - } catch (error: UpdateException) { throw error } catch (error: java.io.IOException) { throw error } - catch (error: kotlinx.coroutines.CancellationException) { throw error } - catch (error: Exception) { throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid release metadata", error) } - } - - private suspend fun candidate(release: JsonObject, abis: List, sdk: Int): UpdateCandidate { - val assets = release.getValue("assets").jsonArray.map { it.jsonObject } - val manifestAsset = assets.single { it["name"]?.jsonPrimitive?.content == "wolfskin-update.json" } - val manifest = UpdateManifest.parse(readJson(manifestAsset.getValue("browser_download_url").jsonPrimitive.content).toString()) - val selected = manifest.selectAsset(abis, sdk) - val asset = assets.single { it["name"]?.jsonPrimitive?.content == selected.name } - return UpdateCandidate(manifest, selected, validateUrl(asset.getValue("browser_download_url").jsonPrimitive.content).toString(), - release["body"]?.jsonPrimitive?.contentOrNull.orEmpty()) - } - - /** A fresh transfer always truncates partials; no unvalidated HTTP range concatenation. */ - suspend fun download(candidate: UpdateCandidate, file: File, progress: (Long, Long?) -> Unit) { - currentCoroutineContext().ensureActive() - request(candidate.url).use { response -> - requireSuccess(response) - val length = response.body.contentLength().takeIf { it >= 0 } - if (length != null && length != candidate.asset.bytes) { - throw UpdateException(UpdateFailure.ChecksumMismatch, "APK length differs from manifest") - } - response.body.byteStream().use { input -> - file.outputStream().use { output -> - val buffer = ByteArray(64 * 1024) - var done = 0L - while (true) { - currentCoroutineContext().ensureActive() - val count = input.read(buffer) - if (count == -1) break - done += count - if (done > candidate.asset.bytes) throw UpdateException(UpdateFailure.ChecksumMismatch, "APK exceeds manifest length") - output.write(buffer, 0, count) - progress(done, length) - } - output.fd.sync() - } - } - } - } - - private fun requireSuccess(response: Response) { - if (!response.isSuccessful) throw UpdateException(UpdateFailure.HttpRefused, "Update server refused request (HTTP ${response.code})") - } - - companion object { private const val MAX_METADATA_BYTES = 1024L * 1024 } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt deleted file mode 100644 index a28e80c1..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import java.io.File -import java.security.MessageDigest -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive - -/** Actual APK identity, read independently of channel metadata. */ -data class ApkIdentity(val packageId: String, val version: InstalledVersion, val signers: Set) - -/** Android boundary replaced by an in-process fixture in controller tests. */ -interface UpdatePlatform { - fun installed(): ApkIdentity - fun inspect(file: File): ApkIdentity? - fun hasInstallPermission(): Boolean - fun openPermissionSettings(): Boolean - fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) -} - -/** Installer results do not imply the running process has been replaced. */ -enum class InstallOutcome { Pending, Success, Cancelled, Failed, Unavailable, InsufficientStorage } - -/** Metadata returned only after verifying the persisted APK against this release. */ -data class VerifiedRelease(val manifest: UpdateManifest, val asset: UpdateAsset, val notes: String) - -/** Private staging; a metadata commit occurs only after independently verifying the APK. */ -class VerifiedUpdateStore(val directory: File, private val platform: UpdatePlatform) { - val partial = File(directory, "download.part") - val apk = File(directory, "verified.apk") - private val record = File(directory, "ready.json") - - fun prepare() { - if (!directory.isDirectory && !directory.mkdirs()) { - throw UpdateException(UpdateFailure.InsufficientStorage, "Cannot create private update storage") - } - } - - fun verify(file: File, manifest: UpdateManifest, asset: UpdateAsset) { - if (file.length() != asset.bytes || sha256(file) != asset.sha256) { - throw UpdateException(UpdateFailure.ChecksumMismatch, "APK length or checksum does not match") - } - val identity = platform.inspect(file) - ?: throw UpdateException(UpdateFailure.InvalidMetadata, "Android could not inspect the APK") - val installed = platform.installed() - if (identity.packageId != installed.packageId || identity.packageId != manifest.packageId) { - throw UpdateException(UpdateFailure.WrongPackage, "APK belongs to a different application") - } - if (identity.signers != installed.signers || identity.signers != setOf(manifest.signerSha256)) { - throw UpdateException(UpdateFailure.WrongSigner, "APK signer does not match this installation") - } - if (!UpdateChannel.isNewer(identity.version.versionCode, installed.version.versionCode)) { - throw UpdateException(UpdateFailure.LowerVersionCode, "APK is not newer than the installed application") - } - if (identity.version.versionCode != manifest.versionCode || identity.version.versionName != manifest.versionName) { - throw UpdateException(UpdateFailure.InvalidMetadata, "APK version differs from manifest") - } - } - - fun commit(manifest: UpdateManifest, asset: UpdateAsset, notes: String) { - verify(partial, manifest, asset) - check(partial.renameTo(apk)) { "Could not commit verified APK" } - val temporary = File(directory, "ready.pending") - val metadata = Json.parseToJsonElement(UpdateManifest.encode(manifest)).jsonObject - val stored = JsonObject(metadata + ("releaseNotes" to JsonPrimitive(notes))) - temporary.outputStream().use { it.write(stored.toString().toByteArray()); it.fd.sync() } - check(temporary.renameTo(record)) { "Could not commit verified metadata" } - } - - /** Re-hash and re-inspect after process death; a marker alone never establishes trust. */ - fun restore(abis: List, sdk: Int): VerifiedRelease? { - partial.delete() - File(directory, "ready.pending").delete() - if (!record.exists()) { apk.delete(); return null } - return try { - val text = record.readText() - val manifest = UpdateManifest.parse(text) - val asset = manifest.selectAsset(abis, sdk) - verify(apk, manifest, asset) - val notes = Json.parseToJsonElement(text).jsonObject["releaseNotes"]?.jsonPrimitive?.contentOrNull.orEmpty() - VerifiedRelease(manifest, asset, notes) - } catch (_: Exception) { clear(); null } - } - - fun clear() { record.delete(); apk.delete(); partial.delete() } - companion object { - fun sha256(file: File): String { - val digest = MessageDigest.getInstance("SHA-256") - file.inputStream().use { input -> - val buffer = ByteArray(64 * 1024) - while (true) { val count = input.read(buffer); if (count < 0) break; digest.update(buffer, 0, count) } - } - return digest.digest().toHexString() - } - } -} diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 913f31f5..4fb76581 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -1,6 +1,6 @@ -# Wolfskin TV specification — revision 6 +# Wolfskin TV specification — revision 5 -2026-11-07. Revision 6 adds the approved direction-A motion and loading states from [the motion reference](https://pages.justinvisser.org/wolfskin/tv-motion-speed.html), frozen with SHA-256 checksums at `data/tv-v2/motion-2026-11-06/`, and the subsequently approved **option B** [card metadata frames](https://pages.justinvisser.org/wolfskin/tv-card-meta-frames.html). Revision-5 feedback below still applies except where the card amendment explicitly supersedes it. Unchanged navigation, typography and colours remain governed by the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The revision-5 prototype delta remains `data/tv-v2/feedback-2026-11-06/`. +2026-11-06. This feedback amendment applies to the approved revision-4 specification and frozen frames at `data/tv-v2/focus-2026-11-06/` (prototype SHA-256 `1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d`). Unchanged geometry, navigation, typography, colours and motion remain governed by those frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The amendment's prototype delta is `data/tv-v2/feedback-2026-11-06/`. ## Who’s watching @@ -54,62 +54,8 @@ Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. Wi Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged. -## Motion and loading — revision 6 - -Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. All loading shelves under the screen's theme share one clock, so late arrivals join the current phase. The clock stops when the last loading shelf leaves. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur. - -| Transition | Duration and curve | Transform | -|---|---|---| -| Focus scale/ring | 180 ms, ease-out (`.33,1,.68,1`) | Existing locked focus scale; no geometry change | -| Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted | -| Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant | -| Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise | -| Initial hero/title copy | 180 ms, ease-out | 16 px rise | -| Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready | -| Hero copy hand-off | Old copy out 120 ms ease (`.25,.1,.25,1`); new copy starts at 140 ms and enters over 200 ms ease-out | 14 px rise on entry | -| Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade | -| Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry | - -Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the lower backdrop changes without an animated screen-sized layer. Hero copy is a separate bounded block. Image reveal uses Coil's painter, not a new full-screen compositing layer. Existing poster-only blur is static. - -The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen. - -The series readiness band reserves **56 px (28 dp)** from its first content paint, including when empty or when readiness later disappears. Ratings remain inside the fixed-height facts band. The series header's measured height therefore cannot change when late ratings/readiness arrive; overview, actions, season and episode anchors retain their coordinates. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route. - -Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range. - -`Settings.Global.ANIMATOR_DURATION_SCALE == 0`, exposed by Compose's window `MotionDurationScale`, or the host's `LocalReducedMotion` flag disables pulse and translation/scroll motion. Focus scale and nav geometry snap; permitted fades are capped at **80 ms**. The window context supplies live scale state without settings or disk reads on the main thread. Fetch/decode dispatchers and image URL logic are unchanged. - -Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target. - - -## Card metadata and bottom state line — revision 6, option B - -Film cards show year, duration and a **24 px watched check**. Episodes show their own duration and check, without a year; series show year and check, never an aggregate runtime. Resuming and unwatched cards have no check. Compact metadata omits media-type words, season counts, character roles, ownership and remaining-time prose. Duration rounds total minutes before formatting hours/minutes; narrow layouts tighten metadata gaps from **12 → 8 → 4 px**, never shrink text or ellipsise duration. - -Movies, Shows, Anime, Home See all and person filmography always show their card-width title/subtitle. Titles are one-line ellipses: **28 px / 32 px line**, medium muted at rest, bold foreground when focused. Metadata is **24 px / 26 px line**. The **96 px** poster caption band starts its text **38 px** below unscaled art. Home poster captions remain focus-only; Home landscape titles stay on the art with focus-only metadata below it. Landscape shelves reserve **64 px** below art. Loading placeholders reserve identical **blank** caption bands: only art-sized blocks are painted, with no title/subtitle pills. Each shelf still shares the motion lane's single pulse layer. - -| Geometry at 1920×1080 | Pixels | -|---|---:| -| Library row pitch | 524 | -| Home poster shelf pitch | 632 | -| Home wide / tile shelf pitch | 522 / 441 | -| Initial library art row tops | 372 / 896 | -| Pinned library art row tops / second-row bottom | 168 / 692 / 1076 | -| Focus ring to caption / caption to next focused row ring | 20.48 / 26.48 | - -One bottom state line replaces the independent resume bar. Unwatched and Discover have no state line; watched has a full-width **3 px accent** line; resume has proportional **6 px accent** fill on a full-width `color.progressTrack` track. Left/right are zero, with **2 px bottom backing** and **2 px opaque separator** above the fill, clipped by the art's **16 px** radius. Focus scales the art/line by **1.08**, with its **4 px** straddle ring drawn independently above them. - -Completed Home progress (`resumeSeconds / runtimeSeconds >= 1`) follows the same state precedence as library/title cards: it is no longer resuming, so a played title uses the 3 px watched line and an unplayed title has no state line. - -Justin's additional correction is binding even where a reference frame leaks: **the lowest card row must contain card/edge/fill colour, never art**. Clip artwork out of the backing band before painting it; merely overdrawing after a rounded clip can retain poster colours in antialiased corners. The backing is opaque `color.bg` (**#07070a**). Precompose and quantise the track over that backing once to preserve the approved **#3d3d3f** browser pixels instead of native alpha-rounding differences; fill is **#cc9fff**. The approved Annie Hall and Locke treatment frames themselves expose artwork below their inset state line; the native build deliberately makes that row solid. - -Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. It also checks Home/library parity when progress reaches or exceeds completion. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances to 0.1 px, and asserts every pixel in reserved loading caption bands remains background-coloured. `CardCaptionMotionTest` compares native glyph pixels after changing a focused title and moving across a focused Home shelf. Reference comparisons retain documented renderer/fixture artifacts; unapproved visible design changes remain merge blockers. - ## Sources and verification Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`. -`MotionSurfaceTest`, `HeroMotionTest`, `NetworkArtTransitionTest` and `DiscoverSkeletonKeysTest` cover late-line geometry, loading/ready row eligibility, Back/nav ownership, rendered pulse suppression under both motion controls, strict main-thread I/O policy, network-versus-cache reveal, bounded hero blending, delayed art and interrupted hand-offs. Native follow-up cases compare the pulse pixels of a late-joining shelf, hold caption translation through both settled and interrupted exits, and observe late ratings/genres/overview fading without moving their slots or actions. Manually clocked captures explicitly deliver snapshot changes before advancing animation time; interrupted-exit assertions sample after the hide has committed. The frozen prototype includes `index.html`, its local media, the original CSS/JS/builder and a complete SHA-256 manifest; replay the HTML, not the preserved builder's live-publishing entry point. - The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation. diff --git a/data/tv-v2/motion-2026-11-06/SHA256SUMS b/data/tv-v2/motion-2026-11-06/SHA256SUMS deleted file mode 100644 index f1cb8847..00000000 --- a/data/tv-v2/motion-2026-11-06/SHA256SUMS +++ /dev/null @@ -1,47 +0,0 @@ -8cca2384b2ed67b28626a316fb5b9c421c3fe0b48de4438d8c1683f6061db81d build_page.py -924b013c685c8eefe98ba985ec61d45d14a38ddc2c3e4269895ed148cee2e9e5 delta.json -e480b84afed8fbae72df846cc980b309ef3ca9342b89df74eeff8cf031050813 index.html -64ec89bd409e79737e615344ffb19eea2e358c5f61b571b2e8ad989a349cab5c proto.css -bba2e150d5ca24a0a7765cc684a7e39bb5742756e19ff855f1743e3a62950684 proto.js -7cf975a048ca1b9c2f8b2627ee034012c325246d4b6e56048c864f1ced7de09f tv-motion-speed/b1t.webp -c53fd37b2946bf54386883b321925a4d006a5e79d4e07bcb0a02755e0b2f19ec tv-motion-speed/b1.webp -6dd4a715be2361ecaee06b825559fec32432363d302d3b06b1a4d95b1dedf757 tv-motion-speed/b2t.webp -8ec6be1c5d143b5786ed4b811e42585ac92a456cf0e88795f6a2ebcaa734e75d tv-motion-speed/b2.webp -674cb1302d66e930504fcc4dff0f8c9d267c43d5a26f97ace8517dd776100cc9 tv-motion-speed/b3t.webp -261dd7de5e6a204e5b4a0433a9210a3883720be09bab890afbde37d51acf3e06 tv-motion-speed/b3.webp -3a6128506526ce54eb7c9e018880be57a1f2fbe7198f2d0fc47efa9ef82fc700 tv-motion-speed/b4t.webp -71427d171d31b499d406410b51a61a683e5fb1f664e8b483d2a95acd0f69b88c tv-motion-speed/b4.webp -825ef8387d3d035d69dcfdf618b6030dabf560e7bdb4b9f3b473990f3d5eaa48 tv-motion-speed/before-discover-tab.webm -c82bf1e41fa2866ff94369173ffd5af83efe6a6fa8499d0498f6be25682451bf tv-motion-speed/before-home-enter.webm -15d166f0741ae7b5422f466fc8e2001de167deecb81c8b9eff33e9e7ac139ff8 tv-motion-speed/before-launch-fast.webm -bacdbb57858f7f28e7af9f50d70bb3cfeac878f0204ee02b8c94e2abb686a011 tv-motion-speed/before-launch-spinner.webm -d492f500ce464b0a529dce81ed0be09d53c868d554973d61a69097b0a1f6fef5 tv-motion-speed/before-movies-grid.webm -1cfc443f8f67bbdf5acfa1341e2616fd5e0895035c390709b3e11600ea7a37e6 tv-motion-speed/before-person.webm -7d9b7acf81981f2058ef85aaff18aa6f1668dfdceca0991da1480099757baa1a tv-motion-speed/before-title-discover.webm -1a934ff576601de973103145fb00db3059b5bf114b8d36aa49fe2f2a421b324f tv-motion-speed/before-title-movie.webm -6366d9b410b5460eba7348bc9e6356f906fa2b8771931232f9a55ea4e4272c46 tv-motion-speed/before-title-series.webm -65577079db9f4a40e62bd5c676d1c4d51a1ca0c9802e364b314a5b162b9dc90b tv-motion-speed/c1.webp -91a325404fa9e6f925bab3a4370f80065f24efbb314fa75252307cf3c642af1f tv-motion-speed/c2.webp -ff018c6db960483f8a2eb73242b087a41b866e05a5bde3f6f4c76eeb1abec21b tv-motion-speed/c3.webp -75ab6defe098a403999a7a267061346afb29748bd2651791d62eb9ca9cdb1acb tv-motion-speed/c4.webp -b01e96165bc33ff5cf47da086ea966b24d58ef2e995cb16ef4af00375a54efca tv-motion-speed/c5.webp -faa1e271643dc3585c70382988fd609f6522d622887a8595658e96831226837d tv-motion-speed/c6.webp -92c1e9bea498f957e5eda418c9c74698cf693f7e7ca406b8e90cd55357a46499 tv-motion-speed/c7.webp -2a62c58b79b0592ba74498f8b7a0a996cb12b7430d5fe00bf8e8550400c3f619 tv-motion-speed/c8.webp -0366fa40daa0328bca55183b656ffc94dfb7fb1b7433732a2effce093e8e4ab0 tv-motion-speed/e1.webp -9a84c464aa98dba31e1aa0d97665e273305d08fbe33fd579136c8964073f42bb tv-motion-speed/home-before-tab.webp -8903c80b69479a5d8ffc6361628f8117a601fe5b88897f3dcc0a62f9b8a533b7 tv-motion-speed/launcher.webp -1026e09bdbd9a6a7570ebd041c098d04b2bb2e738e7047217dae479efd944120 tv-motion-speed/p10.webp -d862e8a85b49b5128837a58bad4051f460c736702d0ce2befb057ad29448b63e tv-motion-speed/p11.webp -b6f7a0c96949d690124413e7e9df249730d0b5f95cebdb1345e0f91f784577c1 tv-motion-speed/p12.webp -eebb8bec042819923bddedd2abee4841b62c0e1c10f05dc0af4569c98d5a3a54 tv-motion-speed/p1.webp -e5f6079132aa33e0a1741a335cfa00d1a476d7da38a556ac1cbadb0f410df407 tv-motion-speed/p2.webp -af3c62f1acb37a7675594179ae285b3092ca6fd3e40216bbcc442190d87c216f tv-motion-speed/p3.webp -7b303cc200c7f3932b077fcae3908a3c6ec0b75922d2bf048d876177d6c4bf4d tv-motion-speed/p4.webp -8650904e68d4085f1ef1c58a8312ed501bddcca5f5565180861339efa6cf4a92 tv-motion-speed/p5.webp -4c102e76ef046c979f728990cb9ed96a87a67ca570cafd2ead8cc3c3c7fb7900 tv-motion-speed/p6.webp -3a30007a21825461d99732063ce3e9a6dc256cb46937bfd1f29236f95480fb5b tv-motion-speed/p7.webp -9fa546b84609ef91ca43ff943b245eda53ff06715cf75f800d556fd3ba2abb12 tv-motion-speed/p8.webp -6e859f5ce40cfa21702b9458586913affafda1454a295dd6099ebc3b462f7aa4 tv-motion-speed/p9.webp -3ab8a7da1d0c2532933eb1803a3408d23e42cbed5923575deb1db62d7cbbb477 tv-motion-speed/s1t.webp -40fb5827a46bcd9d6c9e2f10190c58e0670e29e0ba908ae28600a2f8cae297cc tv-motion-speed/s1.webp diff --git a/data/tv-v2/motion-2026-11-06/build_page.py b/data/tv-v2/motion-2026-11-06/build_page.py deleted file mode 100755 index 5cb786ca..00000000 --- a/data/tv-v2/motion-2026-11-06/build_page.py +++ /dev/null @@ -1,298 +0,0 @@ -#!/usr/bin/env python3 -"""Build the Wolfskin TV load-states / motion / speed spike page (dark only). - -Inputs : page/proto.css, page/proto.js, page/tv-motion-speed/* (webm clips from the box, fixture art), numbers below - (all from data/tv-spike-motion-speed/raw/ and report.md). -Output : /opt/stacks/pages/store/wolfskin/tv-motion-speed.html + tv-motion-speed/ assets (mode 660). -""" -import os, shutil - -HERE = os.path.dirname(os.path.abspath(__file__)) -STORE = '/opt/stacks/pages/store/wolfskin/' -OUT = STORE + 'tv-motion-speed.html' -ASSETS = STORE + 'tv-motion-speed/' - -CSS = open(os.path.join(HERE, 'proto.css')).read() -JS = open(os.path.join(HERE, 'proto.js')).read() - - -def table(head, rows, cls=''): - h = ''.join(f'{c}' for c in head) - b = ''.join('' + ''.join(f'{c}' for c in r) + '' for r in rows) - return f'
{h}{b}
' - - -def pair(pid, scene, before, after_title, lead, note='', variants=False, before2=None): - vids = ''.join(f'

{lab}

' - for src, lab in ([before] + ([before2] if before2 else []))) - var = f'
' if variants else '' - return f'''
{f'{note}' if note else ''}
-
{vids}

{after_title}

{var}
''' - - -def opt(title, gain, effort, risk, see, where='TV'): - return [title, gain, effort, risk, see, where] - - -OPT_HEAD = ['Option', 'Expected gain (measured basis)', 'Effort', 'Risk', 'What you would see', 'Ships in'] - -page = f'''Wolfskin TV: faster, quieter, smoother
-

Wolfskin TV: faster, quieter, smoother

-

Measured on your Xiaomi box with Wolfskin (Opt) 1.1.0-133 and from tank, on 2026-11-06. This page has the options, the plan, and live prototypes of the loading and motion. Each prototype sits next to a recording of the real app doing the same thing today.

- -
The short version
    -
  • The biggest wait isn't the app. Two of four cold launches showed a spinner for 1.5 s, and the other two showed none. The box looks up jelly.justinvisser.org through AdGuard Private DNS, and an uncached lookup takes 0.45–1.35 s. The address only stays cached for 60 s, so the first launch after a minute's pause pays it, and so does the first title page that fetches TMDB art. A small TV-side DNS cache (or one box setting) removes it.
  • -
  • The second biggest is Conductarr. On a title page, the detail, season and "Ready for you" reads take 1.1 s, 1.1 s and 2.9 s at the median. Each one re-lists the whole Radarr/Sonarr library on every request, with no cache. A title page can't settle until the slowest arrives (3.4 s on a series). This needs a Conductarr release.
  • -
  • Screens are already quick, but they pop. Home, the grids, Discover and Settings settle in 0.2–0.75 s. What makes the wait visible is art and fields arriving in steps, and the layout jumping when a late line lands. That's fixable on the TV alone: fade art in, reserve space for late lines, keep last-known screens, and prefetch.
  • -
  • Frames are slow for two reasons. At the median a frame takes 30–46 ms during moves, where 60 fps needs 16.7 ms. On grid and row scrolls, 30–44 % of frames miss. The GPU alone takes 14–21 ms per frame, so motion here has to be cheap: fades, small rises and scale on one layer. No full-screen blur or shimmer sweeps.
  • -
- -
Prototypes loop. "Replay together" restarts the recording and the prototype in step. Counters are ms after the screen first reacts to the key.
- -

1. Where the time goes today

-

Per screen, from the first frame that reacts to the key until the screen stops changing

-

Method: on-device screenrecord (it only emits frames on change), scene-change detection with ffmpeg, logcat (OkHttp and ActivityManager) and Conductarr's own request log for the same seconds. "Settled" is the last visible change. Raw clips and sheets are in data/tv-spike-motion-speed/.

-{table(['Screen', 'First content', 'Settled', 'What arrives late', 'Repeat visit'], [ - ['Cold start (am start -W TotalTime, 4 runs)', '583 / 538 / 438 / 453 ms to first frame', 'picker at +0.3–0.4 s (DNS warm) or +1.65 s (DNS cold)', 'Spinner while Users/Me then System/Info/Public run one after the other (ServerRepository.kt:104,107)', 'same, bimodal on the 60 s DNS cache'], - ['Who\'s watching → Home', 'hero text 135 ms', '430 ms', 'shelf tiles flat grey until 400 ms, then all images pop', '370 ms'], - ['Home → Discover tab', 'content 270 ms (dims first)', '700 ms', 'posters pop in over 0.4 s', '600 ms'], - ['Movies grid', '230 ms (blank tiles)', '750 ms', 'posters fill row by row', '500 ms'], - ['Shows grid', '280 ms', '640 ms', 'posters', '—'], - ['Title, movie in library (American Gangster)', '430 ms (text)', '2.69 s', 'backdrop 470 ms; cast portraits 2.7 s (initials until then)', '450 ms total'], - ['Title, Discover pick (Donnie Brasco)', '980 ms (blank skeleton until then)', '1.53 s', 'backdrop 1.37 s, cast 1.5 s', '—'], - ['Title, series (Adults)', '620 ms', '3.42 s', 'ratings and "Ready for you · by Mindel" land at 3.4 s and push the overview down', '—'], - ['Person page (Martin Sheen)', '620 ms', '720 ms', 'filmography posters', '—'], - ['Back from title / series / person', '260 / 300 / 450 ms', '', '', ''], - ['Settings', '220 ms', '220 ms', '—', ''], - ['Coming in sheet', '74 ms', '74 ms (from cache)', 'posters refresh silently', ''], -], 'num')} - -

Frame timing per interaction (dumpsys gfxinfo, ART status speed)

-{table(['Interaction', 'Frames', 'Janky', 'p50', 'p90', 'p99', 'Main thread slow / GPU slow'], [ - ['Home rows scroll (down 7, up 6, right 5), run 1 / 2', '149 / 115', '38.9 % / 34.8 %', '44 / 46 ms', '69 / 65', '89 / 101', '43 / 52 · 27 / 35'], - ['Movies grid scroll (down 8, up 8), run 1 / 2', '180 / 159', '43.9 % / 32.1 %', '30 / 30 ms', '53 / 46', '93 / 73', '30 / 69 · 20 / 48'], - ['Discover scroll', '146', '30.1 %', '34 ms', '57', '73', '34 / 35'], - ['Tab switch ×4', '158', '18.4 %', '28 ms', '65', '150', '15 / 29'], - ['Title open + back ×3', '103', '33.0 %', '48 ms', '113', '150', '29 / 34'], - ['Who\'s watching → Home', '15', '53.3 %', '61 ms', '117', '133', '7 / 8'], - ['Open title (movie, first time)', '20', '60.0 %', '57 ms', '150', '200', '9 / 12'], - ['Back from person', '12', '75.0 %', '93 ms', '150', '150', '8 / 9'], -], 'num')} -

Frame phases on the Shows grid (framestats, 68 frames): record p50 0.5 / p90 10.3 ms, GPU issue p50 4.3 / p90 8.9 ms, GPU complete p50 14.3 / p90 21.2 ms, total p50 25.6 / p90 41.4 ms. The app renders at 1920×1080 and the box outputs 3840×2160 at 59.94 Hz. Today's grid is worse than the earlier Opt measurement (17.7 % janky), which was taken before the spacing and focus-ring builds.

- -

Network, from tank (10 runs each; cold = new connection, warm = kept-alive)

-{table(['Call', 'Bytes', 'Cold p50 / p95', 'Warm p50 / p95', 'Cache headers'], [ - ['Jellyfin UserViews (called 4–5× per launch)', '2.3 KB', '65 / 166 ms', '46 / 51 ms', 'none'], - ['Home Resume (20)', '1.4 KB', '58 / 66', '30 / 41', 'none'], - ['Home NextUp (20)', '6.2 KB', '71 / 91', '47 / 52', 'none'], - ['Home Items/{{id}} per series (sequential)', '4.0 KB', '43 / 60', '24 / 28', 'none'], - ['Home library shelf (20)', '18.7 KB', '85 / 116', '74 / 84', 'none'], - ['Movies grid page (200)', '51 KB', '68 / 148', '61 / 64', 'none'], - ['Title episodes with MediaSources + People', '96.8 KB', '142 / 185', '122 / 169', 'none'], - ['Title episodes, lean fields', '6.5 KB', '26 / 65', '11 / 12', 'none'], - ['Grid poster, no size (today, LibraryReadFacade.kt:86)', '626 KB', '26 / 30', '8 / 9', 'max-age 1 y, immutable, ETag'], - ['Grid poster fillWidth 256×384 q96', '31.6 KB', '19 / 131', '2 / 4', 'same'], - ['Grid poster 256×384 q80 WebP', '11.2 KB', '19 / 118', '1 / 2', 'same'], - ['Backdrop 1280 q96 / q80 WebP', '91 / 31 KB', '19 / 25', '2 / 2', 'same'], - ['TMDB poster w780 (today, ConductarrProjection.kt:9)', '131 KB', '128 / 144', '49 / 70', 'max-age 1 y'], - ['TMDB poster w342 / w185', '41 / 15 KB', '100 / 76', '31 / 30', 'max-age 1 y'], -], 'num')} -

Box-side DNS (ping timing, 6 samples 70 s apart): uncached jelly.justinvisser.org 490–1390 ms, wolfskin-tv 460–490 ms, image.tmdb.org 60–1170 ms; cached 40–50 ms. The box uses Private DNS dns.adguard.com. The LAN resolver 192.168.1.2 answers uncached names in 136–160 ms and cached in 0 ms. The A record behind both hosts has a 60 s TTL. Cold launches 1 and 3 were more than 60 s after the previous lookup, and 2 and 4 were within it.

-

Conductarr TV routes, server-side (Conductarr request log, 22 h, 200/304)

-{table(['Route', 'n', 'p50', 'p95', 'Body p50', 'Why'], [ - ['activity/titles/{{tmdb}}/{{type}} (Ready for you)', '45', '2856 ms', '5002 ms', '1.7 KB', 'Re-reads the held title twice, the Seerr requests and a Jellyfin GET per episode; no cache (activity/detail.go:21, request_evidence.go:45, users.go:262). The 5002 ms p95 is most likely the TV giving up: its read timeout is 5 s (ConductarrSession.kt:53), so on the slowest titles the line never appears'], - ['catalogue/titles/{{tmdb}}/{{type}} (detail)', '51', '1198 ms', '3217 ms', '11.9 KB', 'Held-title read lists every Arr library unpaged, then Seerr details, then ratings; sequential (catalogue/detail.go:14-17, library_detail.go:81)'], - ['catalogue/tv/{{tmdb}}/seasons/{{n}}', '76 / 17 (304)', '1084 / 2282 ms', '2664 ms', '1 KB', 'Same held-title read; a 304 still renders and hashes the full body first (read_validators.go)'], - ['…/ratings', '265', '147 ms', '449 ms', '0.5 KB', '6 h server cache, 300 titles'], - ['…/enrichment · …/related · …/cast', '46 · 42 · 26', '103 · 124 · 33 ms', '465 · 261 · 78', '3 · 14 · 12.5 KB', 'enrichment uncached'], - ['recommendations feed', '42', '16 ms', '40 ms', '204 KB, uncompressed', 'no gzip/br on the TV door (Caddyfile.base:316-333, serve.go:140)'], - ['activity (polled every 30 s)', '713 × 304', '52 ms', '83 ms', '62.5 KB when 200', ''], - ['operator · profiles · dismissals · options · display', '', '12–25 ms', '30–93 ms', '', 'cheap; display is called ≈1 400×/day, once per poster'], -], 'num')} -

Every TV request also costs Conductarr an uncached Jellyfin /Users/Me to check the token (clients/jellyfin/token.go:42). Every authenticated response is Cache-Control: no-store (authority.go:32). The TV client revalidates with its own ETag store instead.

- -

Caches the app has today

-{table(['Cache', 'Where', 'Behaviour', 'Observed'], [ - ['Wolfskin image loader (Coil 3)', 'WolfskinImages.kt:46-61', '64 MiB memory, 128 MiB disk, 4 parallel reads, crossfade off', 'repeat visits paint art from memory; first grid visit downloads 626 KB per poster (no size)'], - ['Upstream Wholphin image loader', 'CoilConfig.kt:80-101', 'separate memory + 200 MB disk', 'a second copy of the same art in memory'], - ['Conductarr JSON', 'FileCacheStore, noBackupFilesDir/conductarr', 'saved copy shown first, then revalidated with ETag; no memory layer', 'title re-open: every ON_OPEN route re-fetched (cast 304, detail 875 ms again)'], - ['Home, library, title, person snapshots', 'filesDir/wolfskin-home, -library-cache, -title', 'disk JSON, painted first', 'repeat visits 370–500 ms instead of 430–750'], - ['HTTP cache (OkHttp)', 'none', '—', 'Jellyfin JSON never cached; images rely on Coil only'], - ['DNS', 'system only', '60 s TTL through Private DNS', '1.5 s launch spinner when expired'], -])} - -

2. Load states

-

Today: a spinner on launch, flat grey blocks on Home, the grids and title pages, and a blank title page until Conductarr answers on Discover picks. Images snap in because crossfade is off in both loaders. Nothing shows a "loading" sentence; that rule already holds.

-{table(OPT_HEAD, [ - opt('Picker from the saved people list, verify in the background', 'Removes the 1.5 s spinner on every DNS-cold launch (2 of 4 measured)', 'S', 'Low. A revoked session must still land on the recovery picker', 'The launcher fades straight into Who\'s watching'), - opt('Shaped skeletons that breathe (opacity only, staggered)', 'No speed change; replaces the flat grey and spinner states', 'S', 'Low. One alpha layer per row', 'Card-shaped tiles gently pulsing until the art lands'), - opt('Art fades in (120–160 ms) instead of popping', 'Removes the pop on every first visit', 'XS', 'Low. Keep it off for memory-cache hits so repeat visits stay instant', 'Posters settle instead of flickering in'), - opt('Reserve space for late fields (ratings, Ready for you, genres, overview)', 'No layout jump at 3.4 s on series pages', 'S', 'Low', 'The line fades into a gap that was already there'), - opt('Title page paints what the card already knew', 'First content 430–980 ms → about 60 ms', 'M', 'Medium. Card data must match detail (year, runtime, ratings)', 'Title, year, ratings and Play at once, over a colour wash of the poster'), - opt('Shimmer sweep skeletons', 'None', 'S', 'High on this GPU: a moving gradient over every card, every frame', 'Light sweeps across the tiles. Not recommended'), -])} -

See it: launch

-{pair('p-launch', 'launch', ('before-launch-spinner', 'Today, DNS cold (2 of 4 launches): 1.5 s spinner'), 'After: no spinner, people from the saved list', 180, '', False, ('before-launch-fast', 'Today, DNS warm'))} -

See it: skeleton styles

-

Pick a style. Content lands at 900 ms in each, about the Movies grid's first visit.

-{pair('p-skel', 'skeleton', ('before-movies-grid', 'Today: Movies grid, first visit'), 'Options', 140, '', True)} - -

3. Motion and transitions

-

Today's motion tokens: focus scale 1.08 over 140 ms, ring and caption fades of 140 ms, a nav blob spring, and 160–180 ms row scrolls. There are no screen transitions or hero cross-fades, and reduced motion is defined but nothing reads it (WolfskinTokens.kt:218). At 30–46 ms per frame, a 140 ms tween is drawn in 3–4 frames. Tick box frame rate at the top to see that.

-{table(OPT_HEAD, [ - opt('Staged reveal on screen entry (hero copy rises 16 px, rows rise 24 px, staggered 30–40 ms, 180 ms ease-out)', 'Perceived: art arrives into motion instead of popping. Cost: translate + alpha on one layer per row', 'S', 'Low. Stagger must not delay focus', 'Home and Discover settle like a curtain rather than tiles snapping'), - opt('Hero cross-fade (backdrop 320 ms, copy hands off)', 'Removes the hard cut when the hero changes', 'S', 'Medium. Two 1080p bitmaps for 320 ms; the GPU is already at 14–21 ms/frame, so fade the backdrop only and keep the scrim static', 'The next pick dissolves in'), - opt('Focus: 180 ms ease-out, caption rises 10 px', 'Same frame cost as today; the curve hides dropped frames better', 'XS', 'Low', 'Focus glides rather than ticks'), - opt('Title entry: grid fades 160 ms, page builds from the poster colour', 'Hides the 470 ms backdrop fetch', 'M', 'Medium (see load states)', 'No black gap between grid and title'), - opt('Shared-element poster → title sheet', 'None measurable', 'L', 'High. Compose shared elements across NavHost destinations on this GPU; the movie page has no poster sheet when a backdrop exists', 'The card flies into place. Not recommended now'), - opt('Honour reduced motion (system animator scale 0 or a Wolfskin toggle)', 'Accessibility', 'S', 'Low', 'Fades only, ≤100 ms; the toggle at the top shows it'), -])} -

See it: Who's watching → Home

-{pair('p-home', 'home', ('before-home-enter', 'Today: grey tiles, then art pops at 400 ms'), 'After', 200)} -

See it: tab switch and row reveal (Home → Discover)

-{pair('p-tab', 'tab', ('before-discover-tab', 'Today'), 'After', 150)} -

See it: title page entry

-{pair('p-title', 'title', ('before-title-movie', 'Today: American Gangster, first open (cast at 2.7 s)'), 'After: Apocalypse Now from the same grid', 170, '', False, ('before-title-discover', 'Today: Donnie Brasco from Discover, blank until 980 ms'))} -

See it: a late line on a series page

-{pair('p-series', 'series', ('before-title-series', 'Today: Adults, the readiness line lands at 3.4 s and pushes the text down'), 'After', 150, '', True)} -

See it: focus move

-{pair('p-focus', 'focus', ('before-movies-grid', 'Today, real grid'), 'Focus along a row', 0, 'Tick box frame rate to compare at 25 fps.', True)} -

See it: hero cross-fade

-{pair('p-hero', 'hero', ('before-discover-tab', 'Today (Discover hero)'), 'Hero advances', 0, '', True)} - -

4. Caching

-{table(OPT_HEAD, [ - opt('Persisted DNS answers (OkHttp Dns: serve the last address at once, refresh behind)', 'Removes 0.45–1.35 s on launch and on the first TMDB image after any 60 s pause', 'S', 'Low. Fall back to the system lookup if the cached address fails', 'No spinner on launch; first cast photos in ≈0.6 s instead of 2.7 s [inference: DNS is the cast delay]'), - opt('Warm the connections at process start (Jellyfin, Conductarr door, TMDB) in parallel', 'Overlaps DNS + TLS with the 450 ms first frame', 'XS', 'Low', 'Same as above, smaller'), - opt('Memory layer over the Conductarr disk cache', 'Skips disk read and JSON decode on back and re-open (title re-open settles at 450 ms today; ~250 expected) [inference]', 'S', 'Low', 'Back and re-open feel instant'), - opt('Stale-while-revalidate for ON_OPEN title routes (skip refetch if validated in the last 5 min)', 'Re-opening a title stops costing the server 875–2 900 ms', 'S', 'Low. Requests and readiness may be 5 min old; keep requests on FORCE', 'Nothing visible; less churn'), - opt('Prefetch the focused card\'s title page (detail JSON + backdrop) after 350 ms of dwell', 'Title first content 430 ms → about 60 ms; backdrop already decoded', 'M', 'Medium. Bandwidth: at most one title per dwell; cancel on move', 'Opening a title is instant'), - opt('Pre-decode last-known Home art while Who\'s watching is up', 'Home settles at 430 ms → about 250 ms with no pops', 'S', 'Low. Memory: 6 backdrops ≈ 21 MiB of the 64 MiB', 'Home appears whole'), - opt('One image loader instead of two', 'Frees a second memory cache (Coil default ≈ 25 % of the heap) [inference]', 'M', 'Medium. Upstream screens use their own', 'Fewer re-downloads after big screens'), - opt('OkHttp HTTP cache for Jellyfin JSON', 'Small. Jellyfin sends no cache headers, so only conditional revalidation is possible', 'S', 'Low', 'Nothing visible'), -])} - -

5. Backend retrieval

-{table(OPT_HEAD, [ - opt('Cache the held-title read in Conductarr (Arr library list, queue, history) for 30–60 s, shared across routes', 'Title detail 1.2 s → ≈0.1 s, season 1.1 s → ≈0.1 s, readiness 2.9 s → <1 s [inference from the code path]', 'M', 'Medium. Freshness of "Ready" after an import; invalidate on Arr webhook (already received)', 'Series pages settle in under a second; no late line', 'Conductarr release'), - opt('Readiness: drop the duplicate held-title read and the per-episode Jellyfin GETs', 'The 5 s tail on readiness', 'M', 'Medium', '"Ready for you" appears with the page', 'Conductarr release'), - opt('Compress the TV door (Caddy encode zstd gzip)', 'Feed 204 KB → ~25–35 KB [inference: JSON typically 6–8×], activity 62 KB → ~8 KB', 'XS', 'Low', 'Discover opens a little faster on Wi-Fi', 'Caddy change (server-docs)'), - opt('304 before rendering (keep a per-route version, not a hash of the full body)', 'Season 304 2.3 s → ms', 'M', 'Medium', 'Nothing visible except speed', 'Conductarr release'), - opt('Cache the token check (/Users/Me) for 60 s per token', 'Removes a Jellyfin round trip from every TV request (~10–40 ms each, 6–10 per title page)', 'S', 'Low. Revocation lag 60 s', 'Nothing visible', 'Conductarr release'), - opt('Batch display reads (one call for a row of posters)', '≈1 400 display calls/day → a few dozen', 'M', 'Low', 'Coming in and Discover rows fill together', 'Conductarr release + TV'), - opt('Size the grid posters (fillWidth=256&fillHeight=384&quality=80&format=Webp)', '626 KB → 11 KB per poster (57×); 85 films 53 MB → 1 MB', 'XS', 'Low', 'Movies and Shows fill at once on first visit', 'TV'), - opt('Ask TMDB for w342 posters, not w780', '131 KB → 41 KB per Discover poster', 'XS', 'Low. 342 px for a 256 px card', 'Discover rows fill sooner', 'TV'), - opt('Lean episode fields (drop MediaSources/MediaStreams/People from the list; fetch them on Play)', '96.8 KB → 6.5 KB per season, 142 → 26 ms', 'S', 'Medium. The episode row shows file chips', 'Series pages settle sooner', 'TV'), - opt('Fewer launch calls: UserViews 4–5× and LiveTv/Recordings/Folders 2× per launch', '≈250 ms of serial calls', 'S', 'Low', 'Nothing visible', 'TV'), -])} - -

6. Kotlin and Compose

-{table(OPT_HEAD, [ - opt('Library load re-emits the full list per 200-item page and re-sorts each time (LibraryReadFacade.kt:96, LibraryGridState.kt:99)', 'Grid first visit; matters more as the library grows', 'S', 'Low', 'Grid appears in one step', 'TV'), - opt('Hoist the Regex in titleFallbackBackground / titleAmbientBackground (TitleArtwork.kt:152,167)', 'Draw-time cost per card', 'XS', 'Low', '—', 'TV'), - opt('Discover reads firstVisibleItemIndex in composition (DiscoverScreen.kt:107) → derivedStateOf', 'Fewer recompositions while scrolling Discover (34 of 44 janky frames were UI-thread)', 'XS', 'Low', 'Smoother Discover scroll', 'TV'), - opt('NavBar label colour read in composition (NavBar.kt:86) → draw-phase', 'Tab switch frames', 'XS', 'Low', '—', 'TV'), - opt('WolfskinImages.loader() is @Synchronized and called in composition', 'Lock on every poster recomposition', 'XS', 'Low', '—', 'TV'), - opt('Cut full-screen layers: hero blur (RenderEffect) + two full-screen gradient scrims + ambient', 'GPU 14–21 ms/frame is the floor today; pre-blurred tiny art and one combined scrim drawn once', 'M', 'Medium. Must match the approved frames', 'Smoother scroll on Home and Discover', 'TV'), - opt('Keys and contentType', 'Already done everywhere', '—', '—', '—', ''), -])} - -

7. Baseline profile

-

Today: a hand seed of 6 lines (app/src/optimized/baselineProfiles/baseline-prof.txt), no generator and no Macrobenchmark. The box is at speed only because it was forced with cmd package compile. Every new install starts at verify (interpreted/JIT) until background dexopt runs.

-{table(['State (same box, earlier tv-perf run)', 'Cold start', 'Home scroll janky / p50 / p90', 'Movies grid'], [ - ['Opt just installed (verify)', '0.5–1.0 s', '51.4 % / 65 / 117 ms', '—'], - ['Opt after forced AOT (speed)', '0.39–0.44 s', '29.2 % / 38 / 61 ms', '17.7 % / 19 / 36 ms'], -], 'num')} -

Expected from a recorded profile (Baseline Profile Gradle plugin plus one UiAutomator journey through picker, Home, Discover, the grids, a title and a person page): a fresh install behaves like the AOT row from the first launch. That is roughly −40 % janky frames on Home scroll and up to half a second off cold start, for every build the second mate installs. It does not change steady state on a box already at speed. Effort M: the journey can run on an emulator in the build container; the box itself isn't needed. Risk low.

- -

8. Recommended plan

-
    -
  1. DNS and launch (TV, S, ~1 day). Persisted DNS answers plus parallel connection warm-up at process start. Show the picker from saved people and check the session in the background. Prove it: 6 cold launches spaced >60 s apart, picker at ≤0.5 s every time (today 1.65 s in half); first cast photos on a new title ≤1 s.
  2. -
  3. Image sizes (TV, XS, hours). Sized WebP grid posters, TMDB w342, lean episode fields. Prove it: first Movies visit settles ≤400 ms (today 750); tank payload table.
  4. -
  5. Load states and motion (TV, M, 2–3 days, against your pick on this page). Breathing shaped skeletons, art fade-in for network loads only, reserved late-line slots, staged reveal, hero cross-fade, reduced motion. Prove it: recordings like the ones above, no layout change after first content, gfxinfo janky no worse than today.
  6. -
  7. Prefetch and memory cache (TV, M, 2 days). Dwell prefetch of title pages, a memory layer over the Conductarr cache, pre-decoded Home art behind the picker. Prove it: title first content ≤100 ms and Home settled ≤250 ms.
  8. -
  9. Conductarr release (M–L, 2–4 days): held-title cache with webhook invalidation, readiness without duplicate reads or per-episode GETs, 304 before render, token-check cache. Caddy: compress the TV door. Prove it: the route table above for 24 h, with detail, season and readiness p50 ≤200 ms and p95 ≤600 ms, and the feed on the wire ≤40 KB.
  10. -
  11. Baseline profile (TV build, M, 1–2 days). Recorded journey profile. Prove it: fresh install without a forced compile, Home scroll janky ≤30 %, cold start ≤0.5 s.
  12. -
  13. Frame cost (TV, M, 2–3 days). The Compose fixes in section 6 and fewer full-screen layers. Prove it: Home and grid scroll janky ≤20 %, GPU p90 ≤16 ms in framestats.
  14. -
-

Total: about 2 weeks of TV work and 3–4 days for a Conductarr release. Steps 1–2 alone remove the two waits you can feel today (the launch spinner and grids filling slowly), in about a day and a half.

-

A box-side alternative to step 1's DNS part: switch the box's Private DNS from dns.adguard.com to Automatic, so it uses the LAN resolver (136–160 ms uncached, 0 ms cached). That changes the whole box, not just Wolfskin, so it's your call; the TV-side fix works either way.

-

9. Your picks (all of them on one page)

-

Six decisions. Each locks the moment you send it and the build lanes start on it. Nothing here touches the TV while you are playing.

-

1. Load-state and motion direction

-

Decides plan step 3 (skeletons and motion). The prototypes above show each part.

- - - - -

-

2. The two quick speed fixes (about a day and a half, TV only)

-

(a) DNS cache with stale-serve plus a picker painted from saved people, so the launch spinner goes away; (b) sized WebP grid posters, smaller TMDB art, lean episode fields, so grids fill fast. Both ship in the next TV build.

- - - - -

-

3. Change the box Private DNS (your call, system setting)

-

Today the box uses dns.adguard.com: an uncached lookup costs 450 to 1350 ms. Automatic uses the LAN resolver (136 to 160 ms uncached, 0 ms cached). It changes DNS for the whole box, not just Wolfskin. I do it with ADB only when the TV is free and you say so; the TV-side fix in 2(a) works either way.

- - - -

-

4. Later plan steps to queue (TV, after the build above)

-

Prefetch and memory cache (title pages, 2 days); recorded Baseline Profile (1 to 2 days); frame cost and Compose fixes (2 to 3 days). Each has its own proof target in section 8.

- - - -

-

5. Conductarr title-read speed-up (needs a Conductarr release)

-

Held-title cache with Arr webhook invalidation, readiness without duplicate reads or per-episode calls, 304 before render, token-check cache, compression on the TV door. 3 to 4 days, plus a release and a server-docs update. Title pages are slow because of this (readiness p50 2.9 s).

- - - -

-

6. When should these land on the TV?

-

You said you will not be using the TV during this session.

- - -

-

Method, raw numbers and files: data/tv-spike-motion-speed/report.md. Nothing was changed in the app or on the server. The box was left on the Movies tab.

-
''' - -os.makedirs(ASSETS, exist_ok=True) -src = os.path.join(HERE, 'tv-motion-speed') -for f in os.listdir(src): - shutil.copyfile(os.path.join(src, f), ASSETS + f) - os.chmod(ASSETS + f, 0o660) -with open(OUT, 'w') as fh: - fh.write(page) -os.chmod(OUT, 0o660) -print(OUT, len(page)) diff --git a/data/tv-v2/motion-2026-11-06/delta.json b/data/tv-v2/motion-2026-11-06/delta.json deleted file mode 100644 index ba6f4faa..00000000 --- a/data/tv-v2/motion-2026-11-06/delta.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "specRevision": 6, - "direction": "A — As recommended", - "approvedReference": "https://pages.justinvisser.org/wolfskin/tv-motion-speed.html", - "frozenInputs": ["index.html", "proto.css", "proto.js", "build_page.py", "tv-motion-speed/"], - "checksums": "SHA256SUMS", - "stagePx": [1920, 1080], - "density": 2, - "skeleton": { - "fill": "#1b1b25", - "alpha": [0.55, 1, 0.55], - "cycleMs": 1400, - "halfCycleEasing": [0.42, 0, 0.58, 1], - "layerScope": "one layer per shelf or bounded header block; no per-card stagger", - "shimmer": false, - "focusable": false - }, - "art": { - "networkRevealMs": 160, - "memoryDiskLocalRevealMs": 0, - "loaders": ["WolfskinImages", "upstream CoilConfig"], - "requestUrlChanges": false - }, - "hero": { - "backdropFadeMs": 320, - "backdropEasing": [0.65, 0, 0.35, 1], - "homeBlendBoundsPx": [1920, 820], - "discoverBlendBoundsPx": [1920, 740], - "holdPreviousUntilIncomingArtOrFallback": true, - "copyOutMs": 120, - "copyEntryAtMs": 140, - "copyInMs": 200, - "copyRisePx": 14 - }, - "focus": { - "durationMs": 180, - "easing": [0.33, 1, 0.68, 1], - "captionDelayMs": 60, - "captionRisePx": 10, - "captionOutMs": 100, - "geometry": "unchanged" - }, - "rowEntry": {"durationMs": 180, "risePx": 24, "scope": "one row, never full screen"}, - "lateSeriesReadiness": {"reservedHeightPx": 56, "fadeMs": 200, "translationPx": 0}, - "reducedMotion": { - "sources": ["Compose window MotionDurationScale == 0", "LocalReducedMotion host flag"], - "maximumFadeMs": 80, - "pulse": false, - "translation": false, - "scrollAnimation": false - }, - "nativeFrames": "https://pages.justinvisser.org/wolfskin/tv-motion-build/", - "onDeviceAcceptance": {"homeJankyPercentMaximum": 39.6, "homeP90MsMaximum": 65, "lateSeriesLayoutShiftPx": 0}, - "deviceAccess": "not performed by this worker", - "specification": "../../tv-spec/spec.md" -} diff --git a/data/tv-v2/motion-2026-11-06/index.html b/data/tv-v2/motion-2026-11-06/index.html deleted file mode 100644 index b390e5a0..00000000 --- a/data/tv-v2/motion-2026-11-06/index.html +++ /dev/null @@ -1,483 +0,0 @@ -Wolfskin TV: faster, quieter, smoother
-

Wolfskin TV: faster, quieter, smoother

-

Measured on your Xiaomi box with Wolfskin (Opt) 1.1.0-133 and from tank, on 2026-11-06. This page has the options, the plan, and live prototypes of the loading and motion. Each prototype sits next to a recording of the real app doing the same thing today.

- -
The short version
    -
  • The biggest wait isn't the app. Two of four cold launches showed a spinner for 1.5 s, and the other two showed none. The box looks up jelly.justinvisser.org through AdGuard Private DNS, and an uncached lookup takes 0.45–1.35 s. The address only stays cached for 60 s, so the first launch after a minute's pause pays it, and so does the first title page that fetches TMDB art. A small TV-side DNS cache (or one box setting) removes it.
  • -
  • The second biggest is Conductarr. On a title page, the detail, season and "Ready for you" reads take 1.1 s, 1.1 s and 2.9 s at the median. Each one re-lists the whole Radarr/Sonarr library on every request, with no cache. A title page can't settle until the slowest arrives (3.4 s on a series). This needs a Conductarr release.
  • -
  • Screens are already quick, but they pop. Home, the grids, Discover and Settings settle in 0.2–0.75 s. What makes the wait visible is art and fields arriving in steps, and the layout jumping when a late line lands. That's fixable on the TV alone: fade art in, reserve space for late lines, keep last-known screens, and prefetch.
  • -
  • Frames are slow for two reasons. At the median a frame takes 30–46 ms during moves, where 60 fps needs 16.7 ms. On grid and row scrolls, 30–44 % of frames miss. The GPU alone takes 14–21 ms per frame, so motion here has to be cheap: fades, small rises and scale on one layer. No full-screen blur or shimmer sweeps.
  • -
- -
Prototypes loop. "Replay together" restarts the recording and the prototype in step. Counters are ms after the screen first reacts to the key.
- -

1. Where the time goes today

-

Per screen, from the first frame that reacts to the key until the screen stops changing

-

Method: on-device screenrecord (it only emits frames on change), scene-change detection with ffmpeg, logcat (OkHttp and ActivityManager) and Conductarr's own request log for the same seconds. "Settled" is the last visible change. Raw clips and sheets are in data/tv-spike-motion-speed/.

-
ScreenFirst contentSettledWhat arrives lateRepeat visit
Cold start (am start -W TotalTime, 4 runs)583 / 538 / 438 / 453 ms to first framepicker at +0.3–0.4 s (DNS warm) or +1.65 s (DNS cold)Spinner while Users/Me then System/Info/Public run one after the other (ServerRepository.kt:104,107)same, bimodal on the 60 s DNS cache
Who's watching → Homehero text 135 ms430 msshelf tiles flat grey until 400 ms, then all images pop370 ms
Home → Discover tabcontent 270 ms (dims first)700 msposters pop in over 0.4 s600 ms
Movies grid230 ms (blank tiles)750 msposters fill row by row500 ms
Shows grid280 ms640 msposters—
Title, movie in library (American Gangster)430 ms (text)2.69 sbackdrop 470 ms; cast portraits 2.7 s (initials until then)450 ms total
Title, Discover pick (Donnie Brasco)980 ms (blank skeleton until then)1.53 sbackdrop 1.37 s, cast 1.5 s—
Title, series (Adults)620 ms3.42 sratings and "Ready for you · by Mindel" land at 3.4 s and push the overview down—
Person page (Martin Sheen)620 ms720 msfilmography posters—
Back from title / series / person260 / 300 / 450 ms
Settings220 ms220 ms—
Coming in sheet74 ms74 ms (from cache)posters refresh silently
- -

Frame timing per interaction (dumpsys gfxinfo, ART status speed)

-
InteractionFramesJankyp50p90p99Main thread slow / GPU slow
Home rows scroll (down 7, up 6, right 5), run 1 / 2149 / 11538.9 % / 34.8 %44 / 46 ms69 / 6589 / 10143 / 52 · 27 / 35
Movies grid scroll (down 8, up 8), run 1 / 2180 / 15943.9 % / 32.1 %30 / 30 ms53 / 4693 / 7330 / 69 · 20 / 48
Discover scroll14630.1 %34 ms577334 / 35
Tab switch ×415818.4 %28 ms6515015 / 29
Title open + back ×310333.0 %48 ms11315029 / 34
Who's watching → Home1553.3 %61 ms1171337 / 8
Open title (movie, first time)2060.0 %57 ms1502009 / 12
Back from person1275.0 %93 ms1501508 / 9
-

Frame phases on the Shows grid (framestats, 68 frames): record p50 0.5 / p90 10.3 ms, GPU issue p50 4.3 / p90 8.9 ms, GPU complete p50 14.3 / p90 21.2 ms, total p50 25.6 / p90 41.4 ms. The app renders at 1920×1080 and the box outputs 3840×2160 at 59.94 Hz. Today's grid is worse than the earlier Opt measurement (17.7 % janky), which was taken before the spacing and focus-ring builds.

- -

Network, from tank (10 runs each; cold = new connection, warm = kept-alive)

-
CallBytesCold p50 / p95Warm p50 / p95Cache headers
Jellyfin UserViews (called 4–5× per launch)2.3 KB65 / 166 ms46 / 51 msnone
Home Resume (20)1.4 KB58 / 6630 / 41none
Home NextUp (20)6.2 KB71 / 9147 / 52none
Home Items/{{id}} per series (sequential)4.0 KB43 / 6024 / 28none
Home library shelf (20)18.7 KB85 / 11674 / 84none
Movies grid page (200)51 KB68 / 14861 / 64none
Title episodes with MediaSources + People96.8 KB142 / 185122 / 169none
Title episodes, lean fields6.5 KB26 / 6511 / 12none
Grid poster, no size (today, LibraryReadFacade.kt:86)626 KB26 / 308 / 9max-age 1 y, immutable, ETag
Grid poster fillWidth 256×384 q9631.6 KB19 / 1312 / 4same
Grid poster 256×384 q80 WebP11.2 KB19 / 1181 / 2same
Backdrop 1280 q96 / q80 WebP91 / 31 KB19 / 252 / 2same
TMDB poster w780 (today, ConductarrProjection.kt:9)131 KB128 / 14449 / 70max-age 1 y
TMDB poster w342 / w18541 / 15 KB100 / 7631 / 30max-age 1 y
-

Box-side DNS (ping timing, 6 samples 70 s apart): uncached jelly.justinvisser.org 490–1390 ms, wolfskin-tv 460–490 ms, image.tmdb.org 60–1170 ms; cached 40–50 ms. The box uses Private DNS dns.adguard.com. The LAN resolver 192.168.1.2 answers uncached names in 136–160 ms and cached in 0 ms. The A record behind both hosts has a 60 s TTL. Cold launches 1 and 3 were more than 60 s after the previous lookup, and 2 and 4 were within it.

-

Conductarr TV routes, server-side (Conductarr request log, 22 h, 200/304)

-
Routenp50p95Body p50Why
activity/titles/{{tmdb}}/{{type}} (Ready for you)452856 ms5002 ms1.7 KBRe-reads the held title twice, the Seerr requests and a Jellyfin GET per episode; no cache (activity/detail.go:21, request_evidence.go:45, users.go:262). The 5002 ms p95 is most likely the TV giving up: its read timeout is 5 s (ConductarrSession.kt:53), so on the slowest titles the line never appears
catalogue/titles/{{tmdb}}/{{type}} (detail)511198 ms3217 ms11.9 KBHeld-title read lists every Arr library unpaged, then Seerr details, then ratings; sequential (catalogue/detail.go:14-17, library_detail.go:81)
catalogue/tv/{{tmdb}}/seasons/{{n}}76 / 17 (304)1084 / 2282 ms2664 ms1 KBSame held-title read; a 304 still renders and hashes the full body first (read_validators.go)
…/ratings265147 ms449 ms0.5 KB6 h server cache, 300 titles
…/enrichment · …/related · …/cast46 · 42 · 26103 · 124 · 33 ms465 · 261 · 783 · 14 · 12.5 KBenrichment uncached
recommendations feed4216 ms40 ms204 KB, uncompressedno gzip/br on the TV door (Caddyfile.base:316-333, serve.go:140)
activity (polled every 30 s)713 × 30452 ms83 ms62.5 KB when 200
operator · profiles · dismissals · options · display12–25 ms30–93 mscheap; display is called ≈1 400×/day, once per poster
-

Every TV request also costs Conductarr an uncached Jellyfin /Users/Me to check the token (clients/jellyfin/token.go:42). Every authenticated response is Cache-Control: no-store (authority.go:32). The TV client revalidates with its own ETag store instead.

- -

Caches the app has today

-
CacheWhereBehaviourObserved
Wolfskin image loader (Coil 3)WolfskinImages.kt:46-6164 MiB memory, 128 MiB disk, 4 parallel reads, crossfade offrepeat visits paint art from memory; first grid visit downloads 626 KB per poster (no size)
Upstream Wholphin image loaderCoilConfig.kt:80-101separate memory + 200 MB diska second copy of the same art in memory
Conductarr JSONFileCacheStore, noBackupFilesDir/conductarrsaved copy shown first, then revalidated with ETag; no memory layertitle re-open: every ON_OPEN route re-fetched (cast 304, detail 875 ms again)
Home, library, title, person snapshotsfilesDir/wolfskin-home, -library-cache, -titledisk JSON, painted firstrepeat visits 370–500 ms instead of 430–750
HTTP cache (OkHttp)none—Jellyfin JSON never cached; images rely on Coil only
DNSsystem only60 s TTL through Private DNS1.5 s launch spinner when expired
- -

2. Load states

-

Today: a spinner on launch, flat grey blocks on Home, the grids and title pages, and a blank title page until Conductarr answers on Discover picks. Images snap in because crossfade is off in both loaders. Nothing shows a "loading" sentence; that rule already holds.

-
OptionExpected gain (measured basis)EffortRiskWhat you would seeShips in
Picker from the saved people list, verify in the backgroundRemoves the 1.5 s spinner on every DNS-cold launch (2 of 4 measured)SLow. A revoked session must still land on the recovery pickerThe launcher fades straight into Who's watchingTV
Shaped skeletons that breathe (opacity only, staggered)No speed change; replaces the flat grey and spinner statesSLow. One alpha layer per rowCard-shaped tiles gently pulsing until the art landsTV
Art fades in (120–160 ms) instead of poppingRemoves the pop on every first visitXSLow. Keep it off for memory-cache hits so repeat visits stay instantPosters settle instead of flickering inTV
Reserve space for late fields (ratings, Ready for you, genres, overview)No layout jump at 3.4 s on series pagesSLowThe line fades into a gap that was already thereTV
Title page paints what the card already knewFirst content 430–980 ms → about 60 msMMedium. Card data must match detail (year, runtime, ratings)Title, year, ratings and Play at once, over a colour wash of the posterTV
Shimmer sweep skeletonsNoneSHigh on this GPU: a moving gradient over every card, every frameLight sweeps across the tiles. Not recommendedTV
-

See it: launch

-
-

Today, DNS cold (2 of 4 launches): 1.5 s spinner

Today, DNS warm

After: no spinner, people from the saved list

-

See it: skeleton styles

-

Pick a style. Content lands at 900 ms in each, about the Movies grid's first visit.

-
-

Today: Movies grid, first visit

Options

- -

3. Motion and transitions

-

Today's motion tokens: focus scale 1.08 over 140 ms, ring and caption fades of 140 ms, a nav blob spring, and 160–180 ms row scrolls. There are no screen transitions or hero cross-fades, and reduced motion is defined but nothing reads it (WolfskinTokens.kt:218). At 30–46 ms per frame, a 140 ms tween is drawn in 3–4 frames. Tick box frame rate at the top to see that.

-
OptionExpected gain (measured basis)EffortRiskWhat you would seeShips in
Staged reveal on screen entry (hero copy rises 16 px, rows rise 24 px, staggered 30–40 ms, 180 ms ease-out)Perceived: art arrives into motion instead of popping. Cost: translate + alpha on one layer per rowSLow. Stagger must not delay focusHome and Discover settle like a curtain rather than tiles snappingTV
Hero cross-fade (backdrop 320 ms, copy hands off)Removes the hard cut when the hero changesSMedium. Two 1080p bitmaps for 320 ms; the GPU is already at 14–21 ms/frame, so fade the backdrop only and keep the scrim staticThe next pick dissolves inTV
Focus: 180 ms ease-out, caption rises 10 pxSame frame cost as today; the curve hides dropped frames betterXSLowFocus glides rather than ticksTV
Title entry: grid fades 160 ms, page builds from the poster colourHides the 470 ms backdrop fetchMMedium (see load states)No black gap between grid and titleTV
Shared-element poster → title sheetNone measurableLHigh. Compose shared elements across NavHost destinations on this GPU; the movie page has no poster sheet when a backdrop existsThe card flies into place. Not recommended nowTV
Honour reduced motion (system animator scale 0 or a Wolfskin toggle)AccessibilitySLowFades only, ≤100 ms; the toggle at the top shows itTV
-

See it: Who's watching → Home

-
-

Today: grey tiles, then art pops at 400 ms

After

-

See it: tab switch and row reveal (Home → Discover)

-
-

Today

After

-

See it: title page entry

-
-

Today: American Gangster, first open (cast at 2.7 s)

Today: Donnie Brasco from Discover, blank until 980 ms

After: Apocalypse Now from the same grid

-

See it: a late line on a series page

-
-

Today: Adults, the readiness line lands at 3.4 s and pushes the text down

After

-

See it: focus move

-
Tick box frame rate to compare at 25 fps.
-

Today, real grid

Focus along a row

-

See it: hero cross-fade

-
-

Today (Discover hero)

Hero advances

- -

4. Caching

-
OptionExpected gain (measured basis)EffortRiskWhat you would seeShips in
Persisted DNS answers (OkHttp Dns: serve the last address at once, refresh behind)Removes 0.45–1.35 s on launch and on the first TMDB image after any 60 s pauseSLow. Fall back to the system lookup if the cached address failsNo spinner on launch; first cast photos in ≈0.6 s instead of 2.7 s [inference: DNS is the cast delay]TV
Warm the connections at process start (Jellyfin, Conductarr door, TMDB) in parallelOverlaps DNS + TLS with the 450 ms first frameXSLowSame as above, smallerTV
Memory layer over the Conductarr disk cacheSkips disk read and JSON decode on back and re-open (title re-open settles at 450 ms today; ~250 expected) [inference]SLowBack and re-open feel instantTV
Stale-while-revalidate for ON_OPEN title routes (skip refetch if validated in the last 5 min)Re-opening a title stops costing the server 875–2 900 msSLow. Requests and readiness may be 5 min old; keep requests on FORCENothing visible; less churnTV
Prefetch the focused card's title page (detail JSON + backdrop) after 350 ms of dwellTitle first content 430 ms → about 60 ms; backdrop already decodedMMedium. Bandwidth: at most one title per dwell; cancel on moveOpening a title is instantTV
Pre-decode last-known Home art while Who's watching is upHome settles at 430 ms → about 250 ms with no popsSLow. Memory: 6 backdrops ≈ 21 MiB of the 64 MiBHome appears wholeTV
One image loader instead of twoFrees a second memory cache (Coil default ≈ 25 % of the heap) [inference]MMedium. Upstream screens use their ownFewer re-downloads after big screensTV
OkHttp HTTP cache for Jellyfin JSONSmall. Jellyfin sends no cache headers, so only conditional revalidation is possibleSLowNothing visibleTV
- -

5. Backend retrieval

-
OptionExpected gain (measured basis)EffortRiskWhat you would seeShips in
Cache the held-title read in Conductarr (Arr library list, queue, history) for 30–60 s, shared across routesTitle detail 1.2 s → ≈0.1 s, season 1.1 s → ≈0.1 s, readiness 2.9 s → <1 s [inference from the code path]MMedium. Freshness of "Ready" after an import; invalidate on Arr webhook (already received)Series pages settle in under a second; no late lineConductarr release
Readiness: drop the duplicate held-title read and the per-episode Jellyfin GETsThe 5 s tail on readinessMMedium"Ready for you" appears with the pageConductarr release
Compress the TV door (Caddy encode zstd gzip)Feed 204 KB → ~25–35 KB [inference: JSON typically 6–8×], activity 62 KB → ~8 KBXSLowDiscover opens a little faster on Wi-FiCaddy change (server-docs)
304 before rendering (keep a per-route version, not a hash of the full body)Season 304 2.3 s → msMMediumNothing visible except speedConductarr release
Cache the token check (/Users/Me) for 60 s per tokenRemoves a Jellyfin round trip from every TV request (~10–40 ms each, 6–10 per title page)SLow. Revocation lag 60 sNothing visibleConductarr release
Batch display reads (one call for a row of posters)≈1 400 display calls/day → a few dozenMLowComing in and Discover rows fill togetherConductarr release + TV
Size the grid posters (fillWidth=256&fillHeight=384&quality=80&format=Webp)626 KB → 11 KB per poster (57×); 85 films 53 MB → 1 MBXSLowMovies and Shows fill at once on first visitTV
Ask TMDB for w342 posters, not w780131 KB → 41 KB per Discover posterXSLow. 342 px for a 256 px cardDiscover rows fill soonerTV
Lean episode fields (drop MediaSources/MediaStreams/People from the list; fetch them on Play)96.8 KB → 6.5 KB per season, 142 → 26 msSMedium. The episode row shows file chipsSeries pages settle soonerTV
Fewer launch calls: UserViews 4–5× and LiveTv/Recordings/Folders 2× per launch≈250 ms of serial callsSLowNothing visibleTV
- -

6. Kotlin and Compose

-
OptionExpected gain (measured basis)EffortRiskWhat you would seeShips in
Library load re-emits the full list per 200-item page and re-sorts each time (LibraryReadFacade.kt:96, LibraryGridState.kt:99)Grid first visit; matters more as the library growsSLowGrid appears in one stepTV
Hoist the Regex in titleFallbackBackground / titleAmbientBackground (TitleArtwork.kt:152,167)Draw-time cost per cardXSLow—TV
Discover reads firstVisibleItemIndex in composition (DiscoverScreen.kt:107) → derivedStateOfFewer recompositions while scrolling Discover (34 of 44 janky frames were UI-thread)XSLowSmoother Discover scrollTV
NavBar label colour read in composition (NavBar.kt:86) → draw-phaseTab switch framesXSLow—TV
WolfskinImages.loader() is @Synchronized and called in compositionLock on every poster recompositionXSLow—TV
Cut full-screen layers: hero blur (RenderEffect) + two full-screen gradient scrims + ambientGPU 14–21 ms/frame is the floor today; pre-blurred tiny art and one combined scrim drawn onceMMedium. Must match the approved framesSmoother scroll on Home and DiscoverTV
Keys and contentTypeAlready done everywhere———
- -

7. Baseline profile

-

Today: a hand seed of 6 lines (app/src/optimized/baselineProfiles/baseline-prof.txt), no generator and no Macrobenchmark. The box is at speed only because it was forced with cmd package compile. Every new install starts at verify (interpreted/JIT) until background dexopt runs.

-
State (same box, earlier tv-perf run)Cold startHome scroll janky / p50 / p90Movies grid
Opt just installed (verify)0.5–1.0 s51.4 % / 65 / 117 ms—
Opt after forced AOT (speed)0.39–0.44 s29.2 % / 38 / 61 ms17.7 % / 19 / 36 ms
-

Expected from a recorded profile (Baseline Profile Gradle plugin plus one UiAutomator journey through picker, Home, Discover, the grids, a title and a person page): a fresh install behaves like the AOT row from the first launch. That is roughly −40 % janky frames on Home scroll and up to half a second off cold start, for every build the second mate installs. It does not change steady state on a box already at speed. Effort M: the journey can run on an emulator in the build container; the box itself isn't needed. Risk low.

- -

8. Recommended plan

-
    -
  1. DNS and launch (TV, S, ~1 day). Persisted DNS answers plus parallel connection warm-up at process start. Show the picker from saved people and check the session in the background. Prove it: 6 cold launches spaced >60 s apart, picker at ≤0.5 s every time (today 1.65 s in half); first cast photos on a new title ≤1 s.
  2. -
  3. Image sizes (TV, XS, hours). Sized WebP grid posters, TMDB w342, lean episode fields. Prove it: first Movies visit settles ≤400 ms (today 750); tank payload table.
  4. -
  5. Load states and motion (TV, M, 2–3 days, against your pick on this page). Breathing shaped skeletons, art fade-in for network loads only, reserved late-line slots, staged reveal, hero cross-fade, reduced motion. Prove it: recordings like the ones above, no layout change after first content, gfxinfo janky no worse than today.
  6. -
  7. Prefetch and memory cache (TV, M, 2 days). Dwell prefetch of title pages, a memory layer over the Conductarr cache, pre-decoded Home art behind the picker. Prove it: title first content ≤100 ms and Home settled ≤250 ms.
  8. -
  9. Conductarr release (M–L, 2–4 days): held-title cache with webhook invalidation, readiness without duplicate reads or per-episode GETs, 304 before render, token-check cache. Caddy: compress the TV door. Prove it: the route table above for 24 h, with detail, season and readiness p50 ≤200 ms and p95 ≤600 ms, and the feed on the wire ≤40 KB.
  10. -
  11. Baseline profile (TV build, M, 1–2 days). Recorded journey profile. Prove it: fresh install without a forced compile, Home scroll janky ≤30 %, cold start ≤0.5 s.
  12. -
  13. Frame cost (TV, M, 2–3 days). The Compose fixes in section 6 and fewer full-screen layers. Prove it: Home and grid scroll janky ≤20 %, GPU p90 ≤16 ms in framestats.
  14. -
-

Total: about 2 weeks of TV work and 3–4 days for a Conductarr release. Steps 1–2 alone remove the two waits you can feel today (the launch spinner and grids filling slowly), in about a day and a half.

-

A box-side alternative to step 1's DNS part: switch the box's Private DNS from dns.adguard.com to Automatic, so it uses the LAN resolver (136–160 ms uncached, 0 ms cached). That changes the whole box, not just Wolfskin, so it's your call; the TV-side fix works either way.

-

9. Your picks (all of them on one page)

-

Six decisions. Each locks the moment you send it and the build lanes start on it. Nothing here touches the TV while you are playing.

-

1. Load-state and motion direction

-

Decides plan step 3 (skeletons and motion). The prototypes above show each part.

- - - - -

-

2. The two quick speed fixes (about a day and a half, TV only)

-

(a) DNS cache with stale-serve plus a picker painted from saved people, so the launch spinner goes away; (b) sized WebP grid posters, smaller TMDB art, lean episode fields, so grids fill fast. Both ship in the next TV build.

- - - - -

-

3. Change the box Private DNS (your call, system setting)

-

Today the box uses dns.adguard.com: an uncached lookup costs 450 to 1350 ms. Automatic uses the LAN resolver (136 to 160 ms uncached, 0 ms cached). It changes DNS for the whole box, not just Wolfskin. I do it with ADB only when the TV is free and you say so; the TV-side fix in 2(a) works either way.

- - - -

-

4. Later plan steps to queue (TV, after the build above)

-

Prefetch and memory cache (title pages, 2 days); recorded Baseline Profile (1 to 2 days); frame cost and Compose fixes (2 to 3 days). Each has its own proof target in section 8.

- - - -

-

5. Conductarr title-read speed-up (needs a Conductarr release)

-

Held-title cache with Arr webhook invalidation, readiness without duplicate reads or per-episode calls, 304 before render, token-check cache, compression on the TV door. 3 to 4 days, plus a release and a server-docs update. Title pages are slow because of this (readiness p50 2.9 s).

- - - -

-

6. When should these land on the TV?

-

You said you will not be using the TV during this session.

- - -

-

Method, raw numbers and files: data/tv-spike-motion-speed/report.md. Nothing was changed in the app or on the server. The box was left on the Movies tab.

-
\ No newline at end of file diff --git a/data/tv-v2/motion-2026-11-06/proto.css b/data/tv-v2/motion-2026-11-06/proto.css deleted file mode 100644 index 4f4fd155..00000000 --- a/data/tv-v2/motion-2026-11-06/proto.css +++ /dev/null @@ -1,72 +0,0 @@ -/* Prototype stages: 1920x1080 CSS px, approved tokens from data/tv-v2/focus-2026-11-06 (colors, type, ring, card sizes at Level 2 spacing). */ -.sbox{position:relative;width:100%;aspect-ratio:16/9;overflow:hidden;border-radius:8px;border:1px solid var(--line);background:#000} -.sbox video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block} -.stage{position:absolute;left:0;top:0;width:1920px;height:1080px;transform-origin:0 0;overflow:hidden;background:#07070a;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans",sans-serif;color:#f4f4f6;font-size:28px;line-height:1.3; - --bg:#07070a;--surface:#13131a;--surface2:#1b1b25;--surface3:#252532;--muted:rgb(244 244 246/.74);--dim:rgb(244 244 246/.56);--accent:oklch(78% 0.14 305);--accent-text:oklch(88% 0.08 305)} -.stage *{box-sizing:border-box} -.stage .abs{position:absolute} -/* nav */ -.stage .nav{position:absolute;left:0;top:0;width:1920px;height:150px;z-index:5;display:flex;align-items:center;gap:12px;padding:44px 96px 0} -.stage .wm{font-size:36px;font-weight:800;letter-spacing:-.02em;margin-right:40px}.stage .wm b{color:var(--accent)} -.stage .ni{height:64px;padding:0 26px;border-radius:32px;display:inline-flex;align-items:center;font-size:30px;font-weight:600;color:var(--muted);position:relative} -.stage .ni.cur{color:#f4f4f6}.stage .ni.cur::after{content:"";position:absolute;left:26px;right:26px;bottom:4px;height:4px;border-radius:2px;background:var(--accent)} -.stage .ni.f{background:#f4f4f6;color:#0b0b10}.stage .ni.f::after{display:none} -.stage .nsp{flex:1}.stage .av{width:64px;height:64px;border-radius:50%;display:grid;place-items:center;font-weight:800;color:#0b0b10;font-size:30px;background:#9be38f} -/* hero */ -.stage .amb{position:absolute;inset:0;background-size:cover;background-position:center;image-rendering:auto} -.stage .bd{position:absolute;inset:0;background-size:cover;background-position:center} -.stage .scrim{position:absolute;inset:0;background:linear-gradient(90deg,rgb(7 7 10/.92) 0%,rgb(7 7 10/.55) 42%,rgb(7 7 10/0) 70%),linear-gradient(0deg,#07070a 0%,rgb(7 7 10/.6) 26%,rgb(7 7 10/0) 48%)} -.stage .copy{position:absolute;left:96px;width:1000px} -.stage .kicker{font-size:26px;font-weight:650;color:var(--accent-text);margin-bottom:10px} -.stage .ht{font-size:84px;line-height:1.02;font-weight:800;letter-spacing:-.02em;margin:0 0 16px} -.stage .hm{font-size:28px;color:var(--muted);margin-bottom:14px;min-height:38px;display:flex;gap:22px;align-items:center} -.stage .ho{font-size:28px;color:var(--muted);margin:0 0 30px;max-width:960px} -.stage .btns{display:flex;gap:20px} -.stage .btn{height:76px;padding:0 38px;border-radius:38px;font-size:28px;font-weight:700;display:inline-flex;align-items:center;background:rgb(255 255 255/.14)} -.stage .btn.pri{background:#f4f4f6;color:#0b0b10} -.stage .btn.f{box-shadow:0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent);transform:scale(1.05)} -.stage .rate{display:inline-flex;gap:10px;align-items:center;font-weight:700;color:#f4f4f6}.stage .rate i{width:24px;height:24px;border-radius:50%;background:#e2483a;display:inline-block} -.stage .rate i.pc{background:#f5c518;border-radius:4px} -.stage .slot{display:inline-block;height:30px;border-radius:15px;background:rgb(255 255 255/.10)} -/* rows + cards */ -.stage .sh{position:absolute;left:96px;font-size:34px;font-weight:700} -.stage .card{position:absolute;border-radius:16px;overflow:hidden;background:var(--surface);background-size:cover;background-position:center} -.stage .card.poster{width:256px;height:384px}.stage .card.tile{width:400px;height:225px} -.stage .card .img{position:absolute;inset:0;background-size:cover;background-position:center} -.stage .card .cap{position:absolute;left:0;right:0;bottom:0;padding:14px 18px;background:linear-gradient(rgb(7 7 10/0),rgb(7 7 10/.92));font-size:26px;font-weight:700} -.stage .ring{box-shadow:0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent),0 18px 40px rgb(0 0 0/.55)} -.stage .circle{position:absolute;width:200px;height:200px;border-radius:50%;overflow:hidden;background:var(--surface2);display:grid;place-items:center;font-size:44px;font-weight:800;color:var(--dim)} -.stage .circle .img{position:absolute;inset:0;background-size:cover;background-position:center} -.stage .bar{position:absolute;height:28px;border-radius:14px;background:var(--surface2)} -.stage .spin{position:absolute;left:940px;top:520px;width:40px;height:40px;border-radius:50%;border:4px solid rgb(155 120 255/.25);border-top-color:oklch(65% 0.2 300);animation:spin .8s linear infinite} -/* picker */ -.stage .pk{position:absolute;top:290px;left:0;width:1920px;text-align:center;font-size:72px;font-weight:800;letter-spacing:-.02em} -.stage .person{position:absolute;top:426px;width:220px;height:220px;border-radius:50%;display:grid;place-items:center;font-size:110px;font-weight:800;color:#0b0b10} -.stage .pname{position:absolute;top:670px;width:220px;text-align:center;font-size:34px;font-weight:700;color:var(--muted)} -/* replay label */ -.stage .tl{position:absolute;right:14px;bottom:10px;font:22px/1 ui-monospace,Menlo,monospace;background:rgb(0 0 0/.6);padding:6px 8px;border-radius:6px;color:#fff;z-index:20} - -/* keyframes */ -@keyframes spin{to{transform:rotate(360deg)}} -@keyframes fade{from{opacity:0}to{opacity:1}} -@keyframes fadeout{from{opacity:1}to{opacity:0}} -@keyframes rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}} -@keyframes breathe{0%,100%{opacity:.55}50%{opacity:1}} -@keyframes shimmer{from{transform:translateX(-100%)}to{transform:translateX(100%)}} -@keyframes hide{to{opacity:0;visibility:hidden}} -@keyframes show{from{opacity:0}to{opacity:1}} - -/* anything with .an waits for .play */ -.stage .an{opacity:0} -.stage.play .an{animation-fill-mode:both} -.stage .gone{animation-fill-mode:both} -/* skeleton styles */ -.stage .sk{background:var(--surface2)} -.stage .sk.breathe{animation:breathe 1.4s ease-in-out infinite} -.stage .sk.shim{position:relative;overflow:hidden} -.stage .sk.shim::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgb(255 255 255/.07),transparent);animation:shimmer 1.3s linear infinite} - -/* reduced motion: no movement, short fades only */ -body.rm .stage.play .an{animation-name:fade!important;animation-duration:.08s!important} -body.rm .stage .sk.breathe,body.rm .stage .sk.shim::after{animation:none} -body.rm .stage .mv{transition:none!important} diff --git a/data/tv-v2/motion-2026-11-06/proto.js b/data/tv-v2/motion-2026-11-06/proto.js deleted file mode 100644 index a9b16047..00000000 --- a/data/tv-v2/motion-2026-11-06/proto.js +++ /dev/null @@ -1,251 +0,0 @@ -// Wolfskin TV motion prototypes. One clock per stage drives Web Animations; "box rate" quantises the clock -// to the measured frame time on the Xiaomi box (p50 40 ms while moving, data/tv-spike-motion-speed/raw/gfx-*), -// so the same motion can be watched at 60 fps and at the rate the box actually draws today. -(() => { -const A = 'tv-motion-speed/'; -const BOX_MS = 40; -const EASE = 'cubic-bezier(.25,.1,.25,1)', OUT = 'cubic-bezier(.33,1,.68,1)', INOUT = 'cubic-bezier(.65,0,.35,1)'; -const POSTERS = [ - ['American Gangster', '2007 · 2h 36m'], ['Annie Hall', '1977 · 1h 33m'], ['Apocalypse Now', '1979 · 2h 27m'], - ['Arrival', '2016 · 1h 56m'], ['Avatar: The Last Airbender', '2026 · 1h 52m'], ['Bohemian Rhapsody', '2018 · 2h 15m'], - ['Braveheart', '1995 · 2h 58m'], ['Citizen Kane', '1941 · 1h 59m'], ['Spirited Away', '2001 · 2h 5m'], - ['12 Years a Slave', '2013 · 2h 14m'], ['A Bronx Tale', '1993 · 2h 1m'], ['All the Money in the World', '2017 · 2h 13m'], -]; -const poster = i => `${A}p${i + 1}.webp`; -const html = (s) => { const t = document.createElement('template'); t.innerHTML = s.trim(); return t.content.firstChild; }; - -function nav(cur, focus) { - const items = ['Home', 'Discover', 'Movies', 'Shows']; - return ``; -} -function rate(rt, pc) { return `${rt}${pc}`; } -function heroCopy(o) { - return `
${o.kicker}
${o.title}
${o.meta}
${o.ov ? `
${o.ov}
` : ''}
${o.btns}
`; -} - -// ---------- scenes: build(stage, T, v) where v is the selected variant ---------- -const SCENES = { - // 1. launch -> Who's watching - launch: { len: 1400, variants: { after: 'After: people from the saved list' }, build(s, T) { - s.append(html(`
`)); - const bg = s.appendChild(html(`
`)); - T.a(bg, [{ opacity: 0 }, { opacity: 1 }], 0, 160); - const pk = s.appendChild(html(`
Wolfskin
Who’s watching?
- ${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `
${l}
${n}
`).join('')}
`)); - T.a(pk, [{ opacity: 0 }, { opacity: 1 }], 450, 160); - } }, - - // 2. picker -> Home - home: { len: 1100, variants: { after: 'After: last Home painted from disk, art pre-decoded' }, build(s, T) { - const home = s.appendChild(html(`
-
-
- ${heroCopy({ kicker: '▸ Continue watching', title: 'Arrival', meta: '2016 · 1h 56m · 38 min left ' + rate('94%', '82%'), ov: 'Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.', btns: '▶ Resumeⓘ Details', bottom: 340 })} -
Next up
- ${[3, 4, 1, 2].map((b, i) => `
${['American Gangster', 'Bohemian Rhapsody', 'Apocalypse Now', 'Arrival'][i]}
`).join('')} - ${nav(0)}
`)); - const picker = s.appendChild(html(`
Who’s watching?
- ${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `
${l}
${n}
`).join('')}
`)); - T.a(picker, [{ opacity: 1 }, { opacity: 0 }], 0, 140); - T.a(home.querySelector('.amb'), [{ opacity: 0 }, { opacity: 1 }], 40, 120); - T.a(home.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 120, 220, INOUT); - T.a(home.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 60, 180, OUT); - T.a(home.querySelector('.sh'), [{ opacity: 0 }, { opacity: 1 }], 120, 160); - home.querySelectorAll('.card').forEach((c, i) => { - T.a(c, [{ opacity: 0, transform: 'translateY(24px)' }, { opacity: 1, transform: 'none' }], 140 + i * 40, 180, OUT); - }); - } }, - - // 3. skeleton styles on a poster grid - skeleton: { len: 2200, variants: { breathe: 'Breathe (pick)', flat: 'Flat blocks (today)', shimmer: 'Shimmer sweep', spinner: 'Spinner (today, launch)' }, build(s, T, v) { - s.append(html(nav(2))); - s.append(html(`
Movies 85 films
`)); - const cells = []; - for (let r = 0; r < 2; r++) for (let c = 0; c < 6; c++) cells.push([96 + c * 296, 330 + r * 460, r * 6 + c]); - if (v === 'spinner') { const sp = s.appendChild(html('
')); T.a(sp, [{ opacity: 1 }, { opacity: 0 }], 900, 120); } - cells.forEach(([x, y, i]) => { - if (v !== 'spinner') { - const sk = s.appendChild(html(`
`)); - T.a(sk, [{ opacity: 1 }, { opacity: 0 }], 900 + i * 25, 160); - } - const c = s.appendChild(html(`
`)); - T.a(c, [{ opacity: 0 }, { opacity: 1 }], 900 + i * 25, 160); - }); - } }, - - // 4. tab switch: Home -> Discover, rows reveal - tab: { len: 1100, variants: { after: 'After: last Discover painted, rows rise, art fades' }, build(s, T) { - const disc = s.appendChild(html(`
-
- ${heroCopy({ kicker: '✦ For you', title: 'Bohemian Rhapsody', meta: 'Movie · 2018 · Drama, Music ' + rate('60%', '85%'), ov: 'Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm.', btns: 'ⓘ Details', bottom: 520 })} -
Movies for you
- ${[6, 7, 8, 9, 10, 11].map((p, i) => `
`).join('')} -
`)); - const home = s.appendChild(html(`
- ${heroCopy({ kicker: '▸ Continue watching', title: 'Arrival', meta: '2016 · 1h 56m · 38 min left', ov: 'Taking place after alien crafts land around the world, an expert linguist is recruited by the military.', btns: '▶ Resumeⓘ Details', bottom: 340 })}
`)); - const n = s.appendChild(html(nav(1, 1))); - T.a(home, [{ opacity: 1 }, { opacity: 0 }], 0, 200, EASE); - T.a(disc.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 0, 240, INOUT); - T.a(disc.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 80, 180, OUT); - T.a(disc.querySelector('.sh'), [{ opacity: 0 }, { opacity: 1 }], 140, 160); - disc.querySelectorAll('.card').forEach((c, i) => T.a(c, [{ opacity: 0, transform: 'translateY(24px)' }, { opacity: 1, transform: 'none' }], 160 + i * 30, 180, OUT)); - } }, - - // 5. title page entry from the Movies grid - title: { len: 1600, variants: { after: 'After: what the card knew, at once' }, build(s, T) { - const page = s.appendChild(html(`
-
-
-
✓ Movie · In your library
Apocalypse Now
-
1979 · 2h 27m ${rate('91%', '94%')} Drama, War
-
At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, “does not exist, nor will it ever exist.”
-
▶ Play⋯Add for someone
-
Cast
- ${[0, 1, 2, 3, 4, 5, 6].map(i => `
`).join('')} - ${nav(2)}
`)); - const grid = s.appendChild(html(`
${nav(2)} -
Movies 85 films
- ${[0, 1, 2, 3, 4, 5].map(i => `
`).join('')} - ${[6, 7, 8, 9, 10, 11].map((p, i) => `
`).join('')}
`)); - T.a(grid, [{ opacity: 1 }, { opacity: 0 }], 0, 160); - T.a(page.querySelector('.amb'), [{ opacity: 0 }, { opacity: 1 }], 0, 160); - T.a(page.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 40, 180, OUT); - T.a(page.querySelector('.late'), [{ opacity: 0 }, { opacity: 1 }], 430, 160); - T.a(page.querySelector('.late2'), [{ opacity: 0 }, { opacity: 1 }], 430, 160); - T.a(page.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 470, 280, INOUT); - T.a(page.querySelector('.sh'), [{ opacity: 0 }, { opacity: 1 }], 120, 160); - page.querySelectorAll('.circle').forEach((c, i) => { - T.a(c, [{ opacity: 0, transform: 'translateY(20px)' }, { opacity: 1, transform: 'none' }], 140 + i * 30, 180, OUT); - T.a(c.querySelector('.img'), [{ opacity: 0 }, { opacity: 1 }], 620 + i * 40, 160); - }); - } }, - - // 6. series title: late readiness line - series: { len: 4000, variants: { tv: 'TV-only: slot reserved, fades in at 3.4 s', conductarr: 'With the Conductarr cache: arrives ≈0.4 s' }, build(s, T, v) { - const late = v === 'conductarr' ? 380 : 3400; - const page = s.appendChild(html(`
-
-
-
✓ Series · In your library · Watched
Adults
-
2025 · 2 seasons · Comedy ${rate('88%', '93%')}
-
✓ Ready for you · by Mindel
-
A group of twenty-somethings in New York trying to be good people, despite being neither “good” nor “people” yet.
-
▶ Play S1 E1⋯Seasons
-
Season 1Season 2
- ${[0, 1, 2, 3].map(i => `
`).join('')} - ${nav(3)}
`)); - T.a(page.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 0, 180, OUT); - T.a(page.querySelector('.amb'), [{ opacity: 0 }, { opacity: 1 }], 0, 160); - T.a(page.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 620, 280, INOUT); - T.a(page.querySelector('.lr'), [{ opacity: 0 }, { opacity: 1 }], late, 200); - T.a(page.querySelector('.lr2'), [{ opacity: 0 }, { opacity: 1 }], late, 200); - page.querySelectorAll('.card .img').forEach((c, i) => T.a(c, [{ opacity: 0 }, { opacity: 1 }], 700 + i * 40, 160)); - } }, - - // 7. focus move along a row - focus: { len: 3000, variants: { after: 'Proposed: 180 ms ease-out, caption rises', today: 'Today: 140 ms, caption cross-fade' }, build(s, T, v) { - s.append(html(nav(2))); - s.append(html(`
Movies for you
`)); - const dur = v === 'today' ? 140 : 180, ease = v === 'today' ? EASE : OUT; - const cards = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`
`))); - const caps = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`
${POSTERS[i + 6][0]}
${POSTERS[i + 6][1]}
`))); - const step = 600; - cards.forEach((c, i) => { - const on = i * step, off = (i + 1) * step; - const up = [{ transform: 'scale(1)', boxShadow: '0 0 0 0 transparent' }, { transform: 'scale(1.08)', boxShadow: '0 0 0 2px oklch(78% 0.14 305), inset 0 0 0 2px oklch(78% 0.14 305), 0 18px 40px rgb(0 0 0/.55)' }]; - if (i > 0) T.a(c, up, on, dur, ease); else T.a(c, [up[1], up[1]], 0, 1); - if (i < 4) T.a(c, [up[1], up[0]], off, dur, ease); - const capIn = v === 'today' ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 0, transform: 'translateY(10px)' }, { opacity: 1, transform: 'none' }]; - if (i > 0) T.a(caps[i], capIn, on + (v === 'today' ? 0 : 60), dur, ease); else T.a(caps[i], [{ opacity: 1 }, { opacity: 1 }], 0, 1); - if (i < 4) T.a(caps[i], [{ opacity: 1 }, { opacity: 0 }], off, v === 'today' ? dur : 100, ease); - if (i > 4) caps[i].style.opacity = 0; - }); - } }, - - // 8. hero crossfade (Discover hero advances) - hero: { len: 2600, variants: { after: 'Proposed: 320 ms backdrop cross-fade, copy hand-off', today: 'Today: cut' }, build(s, T, v) { - const mk = (b, t, meta, ov) => s.appendChild(html(`
${heroCopy({ kicker: '✦ For you', title: t, meta, ov, btns: 'ⓘ Details', bottom: 520 })}
`)); - const b = mk(2, 'Arrival', 'Movie · 2016 · Drama, Sci-Fi ' + rate('94%', '82%'), 'Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.'); - const a = mk(1, 'Apocalypse Now', 'Movie · 1979 · Drama, War ' + rate('91%', '94%'), 'At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, does not exist.'); - s.append(html(nav(1))); - s.append(html(`
Movies for you
`)); - [0, 1, 2, 3, 4, 5].map(i => s.append(html(`
`))); - const at = 900; - if (v === 'today') { T.a(a, [{ opacity: 1 }, { opacity: 0 }], at, 1); return; } - T.a(a.querySelector('.copy'), [{ opacity: 1 }, { opacity: 0 }], at, 120, EASE); - T.a(a.querySelector('.bd'), [{ opacity: 1 }, { opacity: 0 }], at, 320, INOUT); - T.a(a.querySelector('.scrim'), [{ opacity: 1 }, { opacity: 1 }], at, 320); - T.a(a, [{ opacity: 1 }, { opacity: 0 }], at + 300, 20); - T.a(b.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(14px)' }, { opacity: 1, transform: 'none' }], at + 140, 200, OUT); - } }, -}; - -// ---------- player ---------- -const players = []; -let boxRate = false; -function makePlayer(box) { - const name = box.dataset.scene, sc = SCENES[name]; - const lead = +(box.dataset.lead || 300); - let variant = box.dataset.variant || Object.keys(sc.variants)[0]; - let anims = [], t0 = 0, raf = 0, stage, label; - const total = () => lead + sc.len + 1400; - function build() { - box.querySelectorAll('.stage').forEach(e => e.remove()); - stage = html('
'); box.appendChild(stage); - label = stage.appendChild(html('
0 ms
')); - anims = []; - const rm = document.body.classList.contains('rm'); - const seen = new WeakSet(); - const T = { a(el, kf, start, dur, easing = EASE, opt = {}) { - let k = kf, d = dur; - if (rm) { k = kf.map(f => { const g = { ...f }; delete g.transform; return g; }); d = Math.min(dur, 100); } - // only an element's first animation fills backwards; later ones on the same element must not override it while waiting - const fill = seen.has(el) ? 'forwards' : 'both'; seen.add(el); - const an = el.animate(k, { delay: lead + start, duration: Math.max(1, d), easing, fill, ...opt }); - an.pause(); anims.push(an); - } }; - sc.build(stage, T, variant); - fit(); - } - function fit() { if (stage) stage.style.transform = `scale(${box.clientWidth / 1920})`; } - function tick(now) { - let t = now - t0; - if (t > total()) { t0 = now; t = 0; } - const q = boxRate ? Math.floor(t / BOX_MS) * BOX_MS : t; - for (const an of anims) an.currentTime = q; - label.textContent = `${Math.max(0, Math.round(q - lead))} ms`; - raf = requestAnimationFrame(tick); - } - function start() { cancelAnimationFrame(raf); build(); t0 = performance.now(); raf = requestAnimationFrame(tick); } - function stop() { cancelAnimationFrame(raf); raf = 0; } - new ResizeObserver(fit).observe(box); - new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) { if (!raf) start(); } else stop(); }), { rootMargin: '100px' }).observe(box); - const p = { box, start, setVariant(v) { variant = v; start(); }, rebuild: start }; - players.push(p); - return p; -} - -document.querySelectorAll('.sbox[data-scene]').forEach(makePlayer); -document.querySelectorAll('[data-variants]').forEach(bar => { - const p = players.find(x => x.box.id === bar.dataset.variants); - const sc = SCENES[p.box.dataset.scene]; - Object.entries(sc.variants).forEach(([k, label], i) => { - const b = document.createElement('button'); b.type = 'button'; b.className = 'tab' + (i === 0 ? ' on' : ''); b.textContent = label; - b.onclick = () => { bar.querySelectorAll('.tab').forEach(x => x.classList.toggle('on', x === b)); p.setVariant(k); }; - bar.appendChild(b); - }); -}); -document.querySelectorAll('button.replay').forEach(b => b.addEventListener('click', () => { - const pair = b.closest('.pair'); - pair.querySelectorAll('video').forEach(v => { v.currentTime = 0; v.play().catch(() => {}); }); - players.filter(p => pair.contains(p.box)).forEach(p => p.start()); -})); -const rate_ = document.getElementById('boxrate'); -if (rate_) rate_.addEventListener('change', () => { boxRate = rate_.checked; }); -const rm = document.getElementById('rm'); -if (rm) { - if (matchMedia('(prefers-reduced-motion: reduce)').matches) { rm.checked = true; document.body.classList.add('rm'); } - rm.addEventListener('change', () => { document.body.classList.toggle('rm', rm.checked); players.forEach(p => p.rebuild()); }); -} -document.body.dataset.ready = '1'; -})(); diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b1.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/b1.webp deleted file mode 100644 index f4bd1f2a..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b1.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b1t.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/b1t.webp deleted file mode 100644 index ad68d431..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b1t.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b2.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/b2.webp deleted file mode 100644 index 446e4237..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b2.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b2t.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/b2t.webp deleted file mode 100644 index e50d9c72..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b2t.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b3.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/b3.webp deleted file mode 100644 index aa262c13..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b3.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b3t.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/b3t.webp deleted file mode 100644 index 5b028e9e..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b3t.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b4.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/b4.webp deleted file mode 100644 index 9f4cd1f4..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b4.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b4t.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/b4t.webp deleted file mode 100644 index fe06736b..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/b4t.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-discover-tab.webm b/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-discover-tab.webm deleted file mode 100644 index 6b54f1f7..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-discover-tab.webm and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-home-enter.webm b/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-home-enter.webm deleted file mode 100644 index ff68388f..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-home-enter.webm and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-launch-fast.webm b/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-launch-fast.webm deleted file mode 100644 index d57e13d0..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-launch-fast.webm and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-launch-spinner.webm b/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-launch-spinner.webm deleted file mode 100644 index a3cb2aef..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-launch-spinner.webm and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-movies-grid.webm b/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-movies-grid.webm deleted file mode 100644 index d264072d..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-movies-grid.webm and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-person.webm b/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-person.webm deleted file mode 100644 index e8f055af..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-person.webm and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-title-discover.webm b/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-title-discover.webm deleted file mode 100644 index 5760fe48..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-title-discover.webm and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-title-movie.webm b/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-title-movie.webm deleted file mode 100644 index 6f986f10..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-title-movie.webm and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-title-series.webm b/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-title-series.webm deleted file mode 100644 index e7836902..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/before-title-series.webm and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c1.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/c1.webp deleted file mode 100644 index d58a113d..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c1.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c2.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/c2.webp deleted file mode 100644 index b028efb8..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c2.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c3.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/c3.webp deleted file mode 100644 index 59d33d47..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c3.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c4.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/c4.webp deleted file mode 100644 index 4f17d331..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c4.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c5.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/c5.webp deleted file mode 100644 index 381097e6..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c5.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c6.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/c6.webp deleted file mode 100644 index 53cb7336..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c6.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c7.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/c7.webp deleted file mode 100644 index ad11538a..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c7.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c8.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/c8.webp deleted file mode 100644 index 89948242..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/c8.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/e1.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/e1.webp deleted file mode 100644 index 4a84766e..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/e1.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/home-before-tab.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/home-before-tab.webp deleted file mode 100644 index e34e9b12..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/home-before-tab.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/launcher.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/launcher.webp deleted file mode 100644 index 11ac96ed..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/launcher.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p1.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/p1.webp deleted file mode 100644 index 2634834b..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p1.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p10.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/p10.webp deleted file mode 100644 index 320510c8..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p10.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p11.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/p11.webp deleted file mode 100644 index 2fc5d448..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p11.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p12.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/p12.webp deleted file mode 100644 index 35a4172d..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p12.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p2.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/p2.webp deleted file mode 100644 index 69ac5438..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p2.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p3.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/p3.webp deleted file mode 100644 index 03f63ebc..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p3.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p4.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/p4.webp deleted file mode 100644 index 31ba88de..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p4.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p5.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/p5.webp deleted file mode 100644 index 76a3ad92..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p5.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p6.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/p6.webp deleted file mode 100644 index 42e77ad2..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p6.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p7.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/p7.webp deleted file mode 100644 index 5386939b..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p7.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p8.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/p8.webp deleted file mode 100644 index 2e7189e5..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p8.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p9.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/p9.webp deleted file mode 100644 index 285d45d1..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/p9.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/s1.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/s1.webp deleted file mode 100644 index 18759cfc..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/s1.webp and /dev/null differ diff --git a/data/tv-v2/motion-2026-11-06/tv-motion-speed/s1t.webp b/data/tv-v2/motion-2026-11-06/tv-motion-speed/s1t.webp deleted file mode 100644 index bdad8906..00000000 Binary files a/data/tv-v2/motion-2026-11-06/tv-motion-speed/s1t.webp and /dev/null differ diff --git a/docker/README.md b/docker/README.md index a4a688da..56c53048 100644 --- a/docker/README.md +++ b/docker/README.md @@ -11,11 +11,7 @@ make test # app default/debug suite plus wolfskin-design JVM/capture suite make apk-debug # default debug, no secrets; MPV stub and no extra codec decoders ``` -Offline JVM tests use instrumented Android 9, 11 and 14 SDK jars. Cache preparation -resolves each SDK independently; putting all three versions in one Gradle -configuration selects only the newest and prevents older-SDK tests from starting. - -APKs remain under `app/build/outputs/apk/default/{release,debug,optimized}/`. Debug builds +APKs remain under `app/build/outputs/apk/default/{release,debug}/`. Debug builds are not playback-complete and must not be distributed as releases. Upstream's `.github/workflows` and dependency version catalog are unchanged; release builds use the catalog's exact Wholphin extension version (currently `0.2.4`). Do not @@ -31,175 +27,6 @@ Upstream enables JVM unit tests only for debug variants; there is no `testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor task without changing upstream's test configuration. -## Household update channel - -The household assembly remains `defaultOptimized`: R8/resource shrinking, -non-debuggable, baseline profiles, pinned playback extensions and no debug -fixtures. Its installation identity is `org.justinvisser.wolfskin.tv.opt`. -The persistent external Android debug keystore is now the **household signing -authority**, not a disposable development key. Certificate SHA-256: -`182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d`. -Never print/copy that keystore into artifacts or regenerate it for a release. -The household wrapper fails if it is missing; protect and escrow the original. - -Same package + same signer + strictly higher Android versionCode allows an -in-place upgrade retaining Jellyfin logins, databases and preferences. Never -uninstall, clear data, change identity or replace the key as an update recovery -step. An older ephemeral signer is a separate, owner-approved migration gate; -this change does not prove the TV's installed signer or retained-login behavior. - -The dedicated public distribution repository is -`justinzeus/wolfskin-tv-updates`; its default API is -`https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin-tv-updates/releases/latest`. -Repository creation and each actual publication are separate owner gates. -Neither command creates a repository. Source remains in `justinzeus/wolfskin`. -Initialize the distribution repository's default branch (for example with a -README) before its first draft; Forgejo needs a commit on which to create a -distribution tag. That tag is not the source tag in the separate source repo. -The old production package keeps its old release API and parser. Only the Opt -package migrates the exact old default saved URL; intentional custom URLs remain. -Installed-release notes use the household channel too. - -### Build, inspect and reserve a code - -```sh -make tv-release VERSION=1.1.1 VERSION_CODE=100001 \ - TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1 -# Prerequisites only: no build, tag, ledger mutation or publication. -make tv-release VERSION=1.1.1 VERSION_CODE=100001 \ - TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-dry TV_RELEASE_FLAGS=--dry-run -``` - -Required: a clean committed source checkout, the existing persistent key, native -package credentials through the existing secret mount, no local AAR overrides, -and an explicit code above the committed ledgers at HEAD and freshly fetched -`origin/main`, plus every local consumed-code reservation. Fetch failure refuses -the build, including a dry-run. Known Opt code 65 seeds the ledger; old production -code 28 and Wholphin code 62 belong to different packages. Proposed first household -code 100001 exceeds all three. No household code derives from tag counts. -Only `make tv-release` produces an installable household release; direct optimized -Gradle builds bypass reservation and must never be installed on the household box. - -The builder inspects final signed APKs with aapt2/apksigner and checks package, -version/code, signer, non-debuggable flag, minimum SDK, native ABI inventory, -fixture exclusion and both dexopt profiles. It emits four standalone APKs: -`Wolfskin-armeabi-v7a.apk`, `Wolfskin-arm64-v8a.apk`, `Wolfskin-x86_64.apk`, -and universal `Wolfskin.apk`; plus `wolfskin-update.json`, `RELEASE-NOTES.md` -and `SHA256SUMS`. Hashes and sizes come from final signed bytes. Universal -32-bit x86 is not MPV-complete and is not a supported playback ABI. - -Manifest schema 1 contains `packageId`, `versionName`, `versionCode`, -`publishedAt` (UTC ISO-8601), immutable `sourceCommit`, `sourceTag`, -`signerSha256`, and `assets[]` with `abi`, `name`, `bytes`, `sha256`, `minSdk`. -All ABI outputs share one code/signer/identity. The local source tag is -`pwolfskin-opt-v`; the distribution tag is `wolfskin-opt-v`. -Before building, the code is consumed in the local Git ref -`refs/wolfskin-tv/version-codes/`. This survives discarding a working-tree -ledger append or checking out an older source commit. A failed real build also -consumes its code: use a higher code rather than deleting the reservation. -After successful packaging the builder appends the code to the tracked ledger. -Commit that reservation and explicitly push the source tag before distribution; -new producer checkouts obtain the committed floor by fetching `origin/main`. -An existing output directory is refused to preserve previous evidence. - -Keep `private/` private: build/dependency logs, R8 mapping/effective configuration, -APK metadata/certificate inspection and native/ZIP inventory. Publication uploads -only the seven named public files, never that directory, credentials or keys. -Review release notes and corresponding-source/native links before promotion. - -### Explicit draft publication - -```sh -make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1 \ - TV_PUBLISH_FLAGS=--dry-run -# Only after owner approval to upload: -make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1 -# Only after owner approval to publish the verified draft (use its printed id): -make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1 \ - TV_PUBLISH_FLAGS='--draft-id --promote' -make tv-tooling-test -``` - -The publisher reads git's credential helper into process memory, not arguments, -logs or files. It creates a draft, uploads all public files, then downloads and -hashes their bytes while still draft. Existing matching drafts can be resumed -and reverified. Upload/verification failures remain draft. Promotion requires -the explicit flag; then anonymous latest/asset hash checks must pass. A failed -post-promotion check attempts withdrawal to draft and reports failure, not -success. Cross-origin credential forwarding and non-HTTPS URLs are refused. - -Before creating/resuming a draft and immediately before promotion, the publisher -anonymously reads latest and its manifest. The bundle code must be strictly newer. -Only a latest 404 followed by a successful empty release list permits a first -release; other errors fail closed. A channel advance during upload leaves the -verified upload draft rather than publishing an equal or lower code. -`--test-server` permits only a loopback HTTP fake Forgejo; production defaults -cannot be redirected to a different repository by a CLI repository argument. -Dry-run never fetches a credential or makes publication requests. - -### Client trust and installation - -`UpdateController` is the process-scoped UI contract with `StateFlow` state, -shared auto-check preference, refreshed PackageManager installed version and -explicit check/download/cancel/install/retry/resume actions. Its capture fake -does not perform real operations. Classic Opt update controls use that same -controller; the old package retains its original updater. - -`release: StateFlow` retains version, size, localized -date and release notes through download, Ready and installation cancellation. -Verified records persist notes as an optional `releaseNotes` field alongside -the manifest; existing manifest-only records remain readable. `Failed.operation` -is `UpdateOperation.Check`, `Download` or `Install`, never inferred from message text. -Permission and handoff dismissals restore verified Ready without discarding its -APK. Installer ownership is tracked separately from the visible state, so a -dismissed but active system session still blocks a duplicate handoff. Calling -`install()` from PermissionNeeded resolves and opens the permission settings again. - -The client uses a dedicated anonymous OkHttp client, validating every redirect: -HTTPS, no URL user-info, no cookies/Jellyfin token/repository credential. -Loopback HTTP is available only through the test transport constructor flag. -It selects armeabi-v7a first when supported, then remaining device ABIs and -a universal fallback, subject to minSdk. Universal fallback is limited to -the channel's supported playback ABIs: armeabi-v7a, arm64-v8a and x86_64. -It cannot make an unsupported processor compatible. Empty-release-list success -is distinct from a latest-endpoint 404, authorization failure, invalid metadata -or up-to-date code. - -Downloads are app-private. Retry starts a new truncated partial rather than -concatenating unvalidated ranges; cancel and process recreation discard partials. -Length, SHA-256, APK PackageManager package/version/current signer and manifest -signer must agree before Ready. Restoring Ready re-hashes/re-inspects the APK. -Checksum files on the same server are not an independent trust authority: -the installed Android signer is the installation boundary. - -EOF never starts installation. Install requires a separate OK, per-package -unknown-app permission and a confirmation-required PackageInstaller session. -The bytes streamed into that session are checked again against the manifest length -and hash; any mismatch abandons the session before commit and discards invalid Ready. -Returning from permission settings requires another explicit Install press. -Denial, cancellation and missing system handlers retain the verified download. -No broad FileProvider, public Downloads write, silent install or automatic -uninstall is involved. PackageManager is re-read on return; a successful callback -is not proof that the running process is the new version. Real OEM confirmation, -process replacement and retained-login acceptance remain device-walk gates. - - -### Full-suite test diagnostics - -The Gradle daemon heap (`org.gradle.jvmargs` in `gradle.properties`) is separate -from the forked test-worker heap. The default test worker uses 512 MiB; changing -the daemon's `-Xmx` does not increase it. A larger worker heap did not resolve -the person-picker fixture's missing Compose snapshot notifications. - -Gradle 9.8.2 runs previously failed test classes first on the next invocation. -An immediate green `make test` rerun can therefore hide an order-sensitive -failure rather than prove a fix. For comparisons, clear only generated binary -test-result history (`build/test-results//binary` in each module) before -each run and record actual class-start order; XML file listing order is not -execution order. Keep worker heap and parallelism unchanged between samples. -`PersonPickerSessionTest` must also pass late in the full app JVM, not just alone -or promoted to the front by previous failures. - ## Saved-person launch and DNS Launch restores an unprotected saved Jellyfin login from local storage before @@ -246,22 +73,20 @@ wait for either response. The minified SDK log tag is not stable across builds. The `optimized` build type inherits `release`: R8 optimization/minification, resource shrinking, non-debuggable code, dependency consumer keep rules and -dependency baseline profiles. Its package (`org.justinvisser.wolfskin.tv.opt`), -launcher label (`Wolfskin (Opt)`) and persistent signer remain unchanged. It is -the household release assembly, with plain versionName `1.1.1` and an explicit -monotonic versionCode; source commit identity is recorded separately. -`make apk` remains the old `org.justinvisser.wolfskin.tv` production assembly and -release signer. It must never publish to the household channel. +dependency baseline profiles. It changes only signing (the Android debug key), +package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`). +The launcher icon/banner and release-compatible version name are unchanged: +the shared upgrade/updater parser rejects extra suffixes such as `-opt`. +`make apk` remains the clean, release-key-signed production build. Debug and +optimized containers share the persistent debug key described below; release +signing is unchanged. -For build-only diagnostics, build all four outputs through the container wrapper -with a deliberately non-release code. **Direct optimized builds must never be -installed on the household box or published.** Use `make tv-release` for every -household installation. On a host whose Gradle cache already holds the pinned -native extensions and their metadata: +Build all four default-flavor outputs in one invocation through the container +wrapper, without a release keystore. On a host whose Gradle cache already holds +the pinned native extensions and their metadata: ```sh bash docker/gradle.sh optimized --offline \ - -PtvVersion=1.1.1 -PtvVersionCode=1 \ -PWholphinExtensionsUsername=JustinZeus \ -PWholphinExtensionsPassword=unused \ :app:assembleDefaultOptimized @@ -274,8 +99,7 @@ through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and or securely seeded cached artifacts. No release signing inputs are needed. Without native extension configuration, the existing dependency selection uses the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a -playback-complete comparison. Inspect native libraries for diagnostic evidence; -do not install these direct-build APKs on the household box. +playback-complete comparison. Inspect native libraries before installing. Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a, armeabi-v7a and x86_64. Keep @@ -345,15 +169,6 @@ not the working tree. An uncommitted merge still builds with the old version nam Neither an upstream merge nor its validation build authorizes a release or TV installation. -## Upstream v1.1.1 integration - -The patch release makes manual Seerr setup available when the optional companion -plugin is absent: setup is marked ready immediately rather than remaining pending. -With the plugin installed, an empty settings response or a failed settings read -still reaches the existing ready-state fallback; plugin URL prefill is unchanged. -There are no new Room migrations (schema remains 41), player, layout, session, -DNS/client, dependency, branding or signing changes relative to v1.1.0. - ## Wolfskin shell Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in @@ -689,9 +504,9 @@ Down clamps a short final title row before entering the separate Try again / Sho more row; an empty slot cannot activate recovery. An unavailable library hides its unknown item count. -Library titles and compact subtitles remain visible at rest, with one-line, -card-width ellipsis rather than a 640 px focused caption. The letter rail uses -bold 24 px letters, with the focused letter bold 28 px. +Caption separators use 40% of the existing muted colour's alpha. The rail follows +the locked prototype CSS: bold 24 px letters, with the focused letter bold 28 px, +overriding the spec table's generic caption style for this component. Library and Settings header initials use the muted colour over the person's fill; navigation and picker avatars retain their dark initials. @@ -735,35 +550,12 @@ Title shelves reserve vertical drawing room for the scaled ring. Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px. -Cards use the approved option-B bottom state line instead of a separate inset -resume bar: watched is 3 px accent; resume is 6 px proportional accent over -`color.progressTrack`; unwatched and Discover have no line. Opaque background -backs the entire strip, including its bottom 2 px and the 2 px separator above -the fill. Artwork is clipped out of the backing band before painting it so -rounded-corner antialiasing cannot leak poster pixels into the lowest row. -The separately drawn focus ring stays above the line. - -Film subtitles are year + duration + a 24 px watched check, episode subtitles -are their own duration + check, and series subtitles are year + check with no -single runtime. Resuming cards have no check. The poster caption band remains -96 px; Home poster captions are focus-only. Home landscapes retain titles on -the art and reserve 64 px for focused metadata below it, making wide/tile shelf -pitches 522/441 px; poster shelves remain 632 px and grids remain 524 px. -Loading rows reserve the same blank caption bands and share one pulse layer; -only art-sized blocks are painted, with no title/subtitle placeholder pills. - -`CardBottomPixelTest` samples full native bottom rows on four real posters at -rest and individually focused, including white Annie Hall and dark Locke. -`CardGeometryTest` measures 210/327 px caption fitting, card-width titles, -24 px checks, row pitches, pinned rows and ring clearances to 0.1 px, and checks -that every reserved loading-caption pixel remains background-coloured. -`CardCaptionMotionTest` checks visible native glyph pixels when a still-focused -caption changes title directly or while moving across a Home shelf. -The [native option-B proof gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/) -contains the same illustrative watch-state data as the approved frames and -records renderer/fixture artifacts; unapproved design changes block merge. -`LibrarySpacingRegressionTest` retains the deep focused index across the -199↔200-title letter-rail boundary. +Poster progress uses the independent 18 px title-card inset, not caption spacing: +the track remains 220 px wide on a 256 px poster. Library focus-follow restarts +when the effective column count, focused row or header geometry changes, retaining +deep focus across live snapshots that add or remove the letter rail. +`LibrarySpacingRegressionTest` renders both progress geometry and the unchanged +focused index across the 199↔200-title boundary. `LibraryCaptureTest.spacingShowsFirstFocused`, `HomeCaptureTest.spacingNextUpFirstFocused` and @@ -820,29 +612,15 @@ retain the approved frames except for the requested feedback changes: - Every Discover heading uses a 104 px band, including a 60 px title-to-card gap. Home heading geometry is unchanged. - Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request - 420×630. The shared request builder replaces conflicting Jellyfin size/encoding - parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG - quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and - 400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and - authentication query parameters remain intact. TMDB uses w342 for posters and - portraits, w780 for landscape cards and w1280 for hero backdrops, never original. - Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks - decoder support and requests JPEG if WebP is unavailable. The 64 MiB memory - cache, 128 MiB disk cache and four image-read limit remain unchanged. + 420×630. The shared request builder removes conflicting Jellyfin size caps, + preserves tags and quality 96, and selects a suitable TMDB tier. The 64 MiB + memory cache, 128 MiB disk cache and four image-read limit remain unchanged. `HomeViewportTest` covers short/long poster, tile and wide rows, both row directions, first focus and native Back from the actual title surface. Native proof records real Bear season-three metadata through the title read facade; the proof-only harness is not part of the shipped tests or APK. -Episode lists request only ProviderIds and Overview. Next up reads only identity, -then hydrates the selected playback target through the existing single-item read, -including when Next up is empty. Media sources/streams and cast remain on detail -reads for header track/version controls. Performer credits omit media sources, -streams and ParentId but retain People because their cards show character roles. -Movies/Shows grids retain ProviderIds and DateCreated; server-side SortName -ordering does not require returning SortName on every item. - ## Title pages and people @@ -1118,12 +896,14 @@ Jellyfin read by the selected person's saved Jellyfin user ID, so a lost Conductarr operator or a renamed household profile does not hide that user's own library. A different saved user ID still fails closed. -`make test` keeps the native capture checks for rendered font sizes and visible -surface behavior, plus generated-token drift checks. Styling uses generated -tokens rather than raw dimensions. The source-regex wording/style audit was -removed: source spelling is not a consumer-visible behavior contract. -`LibraryProgressiveSurfaceTest` captures the usable first chunk while the final -read is still suspended. +`make test` runs `OwnedSourceAuditTest` through the design module's JVM suite. +It scans the owned Kotlin sources in `wolfskin-design/src/main/java` and +`app/src/wolfskin`, rejecting untokened `.dp`/`.sp` conversions and numeric Color +constructors outside generated tokens, token font sizes below 12 sp, raw +household library labels, and page-counter/navigation wording. Its negative +cases plant each violation independently. Existing native capture checks also +verify rendered font sizes and wording. `LibraryProgressiveSurfaceTest` captures +the usable first chunk while the final read is still suspended. ## Toolchain and disk use @@ -1170,46 +950,23 @@ the checkout when building on another host, for example: WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug ``` -For ordinary debug builds only, the wrapper creates a missing key once using -the build image's `keytool`: RSA 2048, 10,000-day validity, alias -`androiddebugkey`, store/key passwords `android`, subject `CN=Android Debug`. -Household and optimized modes require the existing key and refuse generation. -The directory is mode `0700`, the file mode `0600`; existing keys are reused. -Generation errors abort rather than falling back to an ephemeral container key. +The wrapper creates a missing key once using the build image's `keytool`: RSA +2048, 10,000-day validity, alias `androiddebugkey`, store/key passwords `android`, +and subject `CN=Android Debug`. The containing directory is mode `0700`, the +file mode `0600`. Existing keys are reused, never regenerated; generation errors +abort the build rather than falling back to an ephemeral container key. The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with `ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container. Keep and back up this file across clean builds, disposable worktrees, container rebuilds and Gradle cache resets. Never commit it. The standard debug passwords -are public; the private key is nevertheless the permanent household signing -authority. It is distinct from the old production package's release signer. +are public; this key is for local debug/optimized builds, not release signing. -### Preserve household TV data - -Normal household updates use the in-app verified download and explicit Android -confirmation, preserving the same package and persistent signer. A separately -authorized bootstrap may use operator `adb install -r`; that is not a substitute -for the in-app update acceptance walk. The certificate SHA-256 is: - -```text -182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d -``` - -Never run `adb uninstall`, `pm uninstall` or `pm clear`, or switch signers on -the household box: clearing/removing the app deletes saved people, sign-ins -and settings, and a different signer cannot update the existing app in place. -Do not delete or replace the persistent key. If an uninstall is unavoidable, -use `adb uninstall -k ` to keep app data, then reinstall the same -package with the same signer. - -The manifest sets `android:hasFragileUserData="true"` so Android 10+ can offer -a **keep app data** choice during interactive uninstall. Choose it when -available; older Android versions ignore the attribute. This is a retention -prompt, not an automatic backup or a guarantee against data clearing. - -An operator can compare `dumpsys package ` before and after an in-place -update: `firstInstallTime` must remain unchanged. The in-app service checks -package, current signer, checksum and higher code; real-device timestamp and -retained-login proof remain a separate operator walk, not a JVM-test claim. +**One-time reinstall:** the first build with the stable key still needs one +uninstall+reinstall on the box because the signer changes once; later ones +install in place with `adb install -r`, retaining sign-ins and settings. +The initial uninstall removes app data, so sign in again once after that switch. +Deleting/replacing the persistent key or choosing a different override changes +the signer again and requires another reinstall. ## Release credentials diff --git a/docker/gradle.sh b/docker/gradle.sh index 7249cf82..461018bc 100755 --- a/docker/gradle.sh +++ b/docker/gradle.sh @@ -45,10 +45,6 @@ else signing_directory=$(dirname "$debug_keystore") mkdir -p "$signing_directory" chmod 700 "$signing_directory" - if [[ ( $mode == household || $mode == optimized ) && ! -s $debug_keystore ]]; then - printf 'Household signing key is required; refusing to generate an identity\n' >&2 - exit 1 - fi if [[ ! -e $debug_keystore ]]; then temporary_directory=$(mktemp -d "$signing_directory/.debug-keystore.XXXXXX") trap 'rm -rf -- "$temporary_directory"' EXIT @@ -70,15 +66,5 @@ else chmod 600 "$debug_keystore" arguments+=(--env ANDROID_USER_HOME=/tmp/.android --mount "type=bind,src=$debug_keystore,dst=/tmp/.android/debug.keystore,readonly") - if [[ $mode == household ]]; then - [[ -s $secrets/github-packages-token ]] || { printf 'Missing native package credential\n' >&2; exit 1; } - arguments+=(--mount "type=bind,src=$secrets/github-packages-token,dst=/run/native-token,readonly") - docker run "${arguments[@]}" "$image" bash -euc ' - export ORG_GRADLE_PROJECT_WholphinExtensionsUsername=JustinZeus - export ORG_GRADLE_PROJECT_WholphinExtensionsPassword=$(cat /run/native-token) - exec ./gradlew "$@" - ' bash "${gradle_arguments[@]}" "$@" - else - docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@" - fi + docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@" fi diff --git a/docker/test_tv_publish.py b/docker/test_tv_publish.py deleted file mode 100644 index 86ccce49..00000000 --- a/docker/test_tv_publish.py +++ /dev/null @@ -1,371 +0,0 @@ -"""Exercise the release CLI against a local Forgejo-shaped HTTP server.""" - -import email.parser -import email.policy -import hashlib -import json -import os -import subprocess -import sys -import tempfile -import threading -import unittest -import urllib.parse -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from typing import Any -from unittest.mock import patch - -from tv_publish import REQUIRED, Forge -from tv_release import PACKAGE, SIGNER - - -class FakeForge: - """Draft assets require authentication; anonymous reads work only after promotion.""" - - def __init__(self) -> None: - self.release: dict[str, Any] = {} - self.assets: dict[str, bytes] = {} - self.events: list[tuple[str, bool]] = [] - self.corrupt_draft = False - self.corrupt_public = False - self.redirect_url: str | None = None - self.published_code: int | None = None - self.channel_manifest: dict[str, Any] = {} - self.latest_status = 200 - self.release_list: list[dict[str, Any]] = [] - self.latest_reads = 0 - self.advance_before_promote: int | None = None - fixture = self - - class Handler(BaseHTTPRequestHandler): - def log_message(self, format: str, *args: Any) -> None: - pass - - def respond(self, value: Any, code: int = 200) -> None: - data = value if isinstance(value, bytes) else json.dumps(value).encode() - self.send_response(code) - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def do_POST(self) -> None: - authenticated = bool(self.headers.get("Authorization")) - fixture.events.append(("POST " + self.path, authenticated)) - if not authenticated: - self.respond({}, 401) - return - data = self.rfile.read(int(self.headers["Content-Length"])) - if self.path.endswith("/releases"): - fixture.release = json.loads(data) | {"id": 17, "assets": []} - self.respond(fixture.release) - else: - name = urllib.parse.parse_qs( - urllib.parse.urlsplit(self.path).query - )["name"][0] - message = email.parser.BytesParser( - policy=email.policy.default - ).parsebytes( - b"Content-Type: " - + self.headers["Content-Type"].encode() - + b"\r\n\r\n" - + data - ) - fixture.assets[name] = next(message.iter_parts()).get_payload( - decode=True - ) - asset = { - "name": name, - "browser_download_url": fixture.base + "/assets/" + name, - "size": len(fixture.assets[name]), - } - fixture.release["assets"].append(asset) - self.respond(asset) - - def do_PATCH(self) -> None: - fixture.events.append( - ("PATCH", bool(self.headers.get("Authorization"))) - ) - fixture.release.update( - json.loads(self.rfile.read(int(self.headers["Content-Length"]))) - ) - self.respond(fixture.release) - - def do_GET(self) -> None: - authenticated = bool(self.headers.get("Authorization")) - fixture.events.append(("GET " + self.path, authenticated)) - if fixture.redirect_url: - self.send_response(302) - self.send_header("Location", fixture.redirect_url) - self.end_headers() - return - if self.path.startswith("/assets/"): - if fixture.release["draft"] and not authenticated: - self.respond({}, 404) - return - data = fixture.assets[self.path.removeprefix("/assets/")] - corrupt = ( - fixture.corrupt_draft - if fixture.release["draft"] - else fixture.corrupt_public - ) - self.respond(data + b"corrupt" if corrupt else data) - elif self.path == "/channel/wolfskin-update.json": - self.respond( - fixture.channel_manifest - | {"versionCode": fixture.published_code} - ) - elif self.path.endswith("/latest"): - fixture.latest_reads += 1 - if ( - fixture.latest_reads == 2 - and fixture.advance_before_promote is not None - ): - fixture.published_code = fixture.advance_before_promote - if fixture.latest_status != 200: - self.respond({}, fixture.latest_status) - elif fixture.release and not fixture.release["draft"]: - self.respond(fixture.release) - elif fixture.published_code is not None: - self.respond( - { - "id": 9, - "assets": [ - { - "name": "wolfskin-update.json", - "browser_download_url": fixture.base - + "/channel/wolfskin-update.json", - } - ], - } - ) - else: - self.respond({}, 404) - elif urllib.parse.urlsplit(self.path).path.endswith("/releases"): - self.respond(fixture.release_list) - else: - self.respond(fixture.release) - - self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) - self.base = f"http://127.0.0.1:{self.server.server_port}" - self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) - self.thread.start() - - def close(self) -> None: - """Release fixture resources after each independent publication scenario.""" - self.server.shutdown() - self.server.server_close() - self.thread.join() - - -class PublishTest(unittest.TestCase): - """Permanent tests assert safety transitions and real uploaded bytes, not script wording.""" - - def setUp(self) -> None: - self.temporary = tempfile.TemporaryDirectory() - self.directory = Path(self.temporary.name) - self.forge = FakeForge() - self.addCleanup(self.forge.close) - self.addCleanup(self.temporary.cleanup) - assets = [] - for abi in ("armeabi-v7a", "arm64-v8a", "x86_64", "universal"): - name = "Wolfskin.apk" if abi == "universal" else f"Wolfskin-{abi}.apk" - data = f"fixture APK bytes for {abi}".encode() - (self.directory / name).write_bytes(data) - assets.append( - { - "name": name, - "abi": abi, - "bytes": len(data), - "sha256": hashlib.sha256(data).hexdigest(), - "minSdk": 23, - } - ) - manifest = { - "schemaVersion": 1, - "packageId": PACKAGE, - "versionName": "1.1.1", - "versionCode": 100001, - "publishedAt": "2026-11-07T12:00:00Z", - "sourceCommit": "a" * 40, - "sourceTag": "pwolfskin-opt-v1.1.1", - "signerSha256": SIGNER, - "assets": assets, - } - self.forge.channel_manifest = manifest - (self.directory / "wolfskin-update.json").write_text(json.dumps(manifest)) - (self.directory / "RELEASE-NOTES.md").write_text("Fixture release notes") - self.checksums() - - def checksums(self) -> None: - """Hash fixture files after deliberately constructing or mutating the bundle.""" - (self.directory / "SHA256SUMS").write_text( - "".join( - f"{hashlib.sha256((self.directory / name).read_bytes()).hexdigest()} {name}\n" - for name in sorted(REQUIRED - {"SHA256SUMS"}) - ) - ) - - def cli(self, *flags: str) -> subprocess.CompletedProcess[str]: - """Drive the real CLI and real git credential-helper protocol with a fake credential.""" - environment = os.environ | { - "GIT_CONFIG_COUNT": "2", - "GIT_CONFIG_KEY_0": "credential.helper", - "GIT_CONFIG_VALUE_0": "", - "GIT_CONFIG_KEY_1": "credential.helper", - "GIT_CONFIG_VALUE_1": "!printf 'username=fixture\npassword=fixture-secret\n'", - } - result = subprocess.run( - [ - sys.executable, - str(Path(__file__).with_name("tv_publish.py")), - "--directory", - str(self.directory), - "--test-server", - self.forge.base, - *flags, - ], - capture_output=True, - text=True, - env=environment, - timeout=30, - check=False, - ) - self.assertNotIn("fixture-secret", result.stdout + result.stderr) - return result - - def test_dry_run_never_requests_or_fetches_credentials(self) -> None: - result = self.cli("--dry-run", "--promote") - self.assertEqual(0, result.returncode, result.stderr) - self.assertEqual([], self.forge.events) - - def test_draft_then_explicit_promotion_reverifies_all_bytes(self) -> None: - result = self.cli() - self.assertEqual(0, result.returncode, result.stderr) - self.assertTrue(self.forge.release["draft"]) - self.assertEqual(REQUIRED, set(self.forge.assets)) - self.assertTrue( - all( - not auth - for path, auth in self.forge.events - if "/latest" in path or "?limit=" in path - ) - ) - result = self.cli("--draft-id", "17", "--promote") - self.assertEqual(0, result.returncode, result.stderr) - self.assertFalse(self.forge.release["draft"]) - for name, data in self.forge.assets.items(): - self.assertEqual((self.directory / name).read_bytes(), data) - - def test_corrupt_uploaded_bytes_remain_draft(self) -> None: - self.forge.corrupt_draft = True - result = self.cli("--promote") - self.assertNotEqual(0, result.returncode) - self.assertTrue(self.forge.release["draft"]) - self.assertFalse(any(event == "PATCH" for event, _ in self.forge.events)) - - def test_failed_anonymous_verification_withdraws_promoted_release(self) -> None: - self.forge.corrupt_public = True - result = self.cli("--promote") - self.assertNotEqual(0, result.returncode) - self.assertTrue(self.forge.release["draft"]) - self.assertEqual(2, sum(event == "PATCH" for event, _ in self.forge.events)) - - def test_invalid_local_checksum_never_creates_draft(self) -> None: - (self.directory / "Wolfskin.apk").write_bytes(b"tampered") - result = self.cli() - self.assertNotEqual(0, result.returncode) - self.assertEqual([], self.forge.events) - - def test_manifest_size_mismatch_never_creates_draft(self) -> None: - path = self.directory / "wolfskin-update.json" - manifest = json.loads(path.read_text()) - manifest["assets"][0]["bytes"] += 1 - path.write_text(json.dumps(manifest)) - self.checksums() - result = self.cli() - self.assertNotEqual(0, result.returncode) - self.assertEqual([], self.forge.events) - - def test_empty_manifest_asset_set_never_creates_draft(self) -> None: - path = self.directory / "wolfskin-update.json" - manifest = json.loads(path.read_text()) - manifest["assets"] = [] - path.write_text(json.dumps(manifest)) - self.checksums() - result = self.cli() - self.assertNotEqual(0, result.returncode) - self.assertEqual([], self.forge.events) - - def test_credential_helper_tracing_is_disabled(self) -> None: - trace = self.directory / "credential.trace" - with patch.dict(os.environ, {"GIT_TRACE": str(trace)}): - result = self.cli() - self.assertEqual(0, result.returncode, result.stderr) - self.assertFalse(trace.exists()) - - def test_equal_and_lower_bundle_codes_never_create_a_draft(self) -> None: - for published_code in (100001, 100002): - with self.subTest(published_code=published_code): - self.forge.published_code = published_code - result = self.cli("--promote") - self.assertNotEqual(0, result.returncode) - self.assertEqual({}, self.forge.release) - self.assertTrue(all(not auth for _, auth in self.forge.events)) - - def test_newer_bundle_reads_live_manifest_anonymously(self) -> None: - self.forge.published_code = 100000 - result = self.cli() - self.assertEqual(0, result.returncode, result.stderr) - self.assertTrue(self.forge.release["draft"]) - live_reads = [ - auth - for path, auth in self.forge.events - if path.endswith("/latest") or path.startswith("GET /channel/") - ] - self.assertTrue(live_reads) - self.assertFalse(any(live_reads)) - - def test_channel_advance_during_upload_prevents_promotion(self) -> None: - self.forge.advance_before_promote = 100001 - result = self.cli("--promote") - self.assertNotEqual(0, result.returncode) - self.assertTrue(self.forge.release["draft"]) - self.assertFalse(any(path == "PATCH" for path, _ in self.forge.events)) - - def test_empty_channel_404_permits_first_draft(self) -> None: - result = self.cli() - self.assertEqual(0, result.returncode, result.stderr) - self.assertTrue(self.forge.release["draft"]) - self.assertIn( - ( - "GET /api/v1/repos/justinzeus/wolfskin-tv-updates/releases?limit=1", - False, - ), - self.forge.events, - ) - - def test_missing_latest_with_nonempty_channel_is_not_first_release(self) -> None: - self.forge.release_list = [{"id": 9}] - result = self.cli() - self.assertNotEqual(0, result.returncode) - self.assertEqual({}, self.forge.release) - - def test_refused_live_channel_never_creates_draft(self) -> None: - self.forge.latest_status = 403 - result = self.cli() - self.assertNotEqual(0, result.returncode) - self.assertEqual({}, self.forge.release) - - def test_authenticated_redirect_never_reaches_another_origin(self) -> None: - other = FakeForge() - self.addCleanup(other.close) - self.forge.redirect_url = other.base + "/asset" - forge = Forge(self.forge.base, "Basic fixture-authorization", test_server=True) - with self.assertRaises(ValueError): - forge.verify(self.forge.base + "/redirect", "0" * 64, authenticated=True) - self.assertEqual([], other.events) - - -if __name__ == "__main__": - unittest.main() diff --git a/docker/test_tv_release.py b/docker/test_tv_release.py deleted file mode 100644 index 4a5d679a..00000000 --- a/docker/test_tv_release.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Exercise source cleanliness, monotonic codes and fail-closed signing prerequisites.""" - -import contextlib -import io -import os -import subprocess -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -import tv_release - - -class ReleaseInputTest(unittest.TestCase): - """Use a disposable Git fixture, never a build or a real signing key.""" - - def setUp(self) -> None: - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) / "source" - self.root.mkdir() - (self.root / "docker").mkdir() - (self.root / "docker/tv-version-codes").write_text("65 installed-opt\n") - self.git("init", "--quiet") - self.git("add", "docker/tv-version-codes") - self.git( - "-c", - "user.name=Fixture", - "-c", - "user.email=fixture@example.invalid", - "commit", - "--quiet", - "-m", - "Record used code", - ) - self.remote = Path(self.temporary.name) / "origin.git" - subprocess.run( - ["git", "init", "--bare", "--quiet", str(self.remote)], check=True - ) - self.git("remote", "add", "origin", str(self.remote)) - self.git("push", "--quiet", "origin", "HEAD:main") - self.previous_root = tv_release.ROOT - tv_release.ROOT = self.root - self.addCleanup(setattr, tv_release, "ROOT", self.previous_root) - - def git(self, *args: str) -> str: - """Run the isolated source fixture's actual Git commands.""" - return subprocess.check_output(["git", *args], cwd=self.root, text=True).strip() - - def test_new_code_identifies_clean_source(self) -> None: - self.assertEqual( - self.git("rev-parse", "HEAD"), tv_release.validate_inputs("1.1.1", 100001) - ) - - def test_reused_and_lower_codes_are_rejected(self) -> None: - for code in (1, 64, 65): - with self.subTest(code=code), self.assertRaises(ValueError): - tv_release.validate_inputs("1.1.1", code) - - def test_dirty_source_is_rejected(self) -> None: - (self.root / "untracked").write_text("not part of the source commit") - with self.assertRaises(ValueError): - tv_release.validate_inputs("1.1.1", 100001) - - def test_unparseable_version_and_invalid_codes_are_rejected(self) -> None: - for version, code in ( - ("1.1.1-opt", 100001), - ("1.1.1", 0), - ("1.1.1", 2100000001), - ): - with ( - self.subTest(version=version, code=code), - self.assertRaises(ValueError), - ): - tv_release.validate_inputs(version, code) - - def test_missing_signing_key_never_generates_identity(self) -> None: - key = Path(self.temporary.name) / "absent-key" - with ( - patch.dict(os.environ, {"WOLFSKIN_DEBUG_KEYSTORE": str(key)}), - self.assertRaises(ValueError), - ): - tv_release.release( - "1.1.1", 100001, Path(self.temporary.name) / "output", True - ) - self.assertFalse(key.exists()) - - def test_dry_run_never_builds_tags_or_mutates_ledger(self) -> None: - key = Path(self.temporary.name) / "fixture-key-presence-only" - key.write_bytes(b"not an actual keystore; dry run must not open it") - output = Path(self.temporary.name) / "output" - with ( - patch.dict(os.environ, {"WOLFSKIN_DEBUG_KEYSTORE": str(key)}), - contextlib.redirect_stdout(io.StringIO()), - ): - tv_release.release("1.1.1", 100001, output, True) - self.assertFalse(output.exists()) - self.assertEqual("", self.git("tag", "--list")) - self.assertEqual( - "65 installed-opt\n", (self.root / "docker/tv-version-codes").read_text() - ) - self.assertEqual( - "", self.git("for-each-ref", "refs/wolfskin-tv/version-codes/") - ) - - def test_stale_branch_uses_fetched_main_ledger(self) -> None: - stale = self.git("rev-parse", "HEAD") - (self.root / "docker/tv-version-codes").write_text( - "65 installed-opt\n100001 released\n" - ) - self.git("add", "docker/tv-version-codes") - self.git( - "-c", - "user.name=Fixture", - "-c", - "user.email=fixture@example.invalid", - "commit", - "--quiet", - "-m", - "Reserve published code", - ) - self.git("push", "--quiet", "origin", "HEAD:main") - self.git("checkout", "--quiet", "--detach", stale) - with self.assertRaises(ValueError): - tv_release.validate_inputs("1.1.2", 100001) - self.assertEqual(stale, tv_release.validate_inputs("1.1.2", 100002)) - - def test_discarding_ledger_append_cannot_reuse_reserved_code(self) -> None: - commit = self.git("rev-parse", "HEAD") - tv_release.reserve_code(100001, commit) - with (self.root / "docker/tv-version-codes").open("a") as ledger: - ledger.write("100001 1.1.1 consumed\n") - self.git("restore", "docker/tv-version-codes") - with self.assertRaises(ValueError): - tv_release.validate_inputs("1.1.2", 100001) - self.assertEqual(commit, tv_release.validate_inputs("1.1.2", 100002)) - - def test_head_reservation_wins_over_older_main(self) -> None: - (self.root / "docker/tv-version-codes").write_text( - "65 installed-opt\n100002 released\n" - ) - self.git("add", "docker/tv-version-codes") - self.git( - "-c", - "user.name=Fixture", - "-c", - "user.email=fixture@example.invalid", - "commit", - "--quiet", - "-m", - "Reserve newer branch code", - ) - with self.assertRaises(ValueError): - tv_release.validate_inputs("1.1.3", 100002) - - -if __name__ == "__main__": - unittest.main() diff --git a/docker/tv-version-codes b/docker/tv-version-codes deleted file mode 100644 index 63071ca8..00000000 --- a/docker/tv-version-codes +++ /dev/null @@ -1,3 +0,0 @@ -# Household org.justinvisser.wolfskin.tv.opt codes already used. -# Append successful tv-release codes; commit this ledger before another release. -65 installed-opt diff --git a/docker/tv_publish.py b/docker/tv_publish.py deleted file mode 100755 index 7a1a131f..00000000 --- a/docker/tv_publish.py +++ /dev/null @@ -1,413 +0,0 @@ -#!/usr/bin/env python3 -"""Upload a verified draft; promote only with explicit --promote owner approval.""" - -import argparse -import base64 -import datetime -import hashlib -import json -import os -import re -import subprocess -import urllib.error -import urllib.parse -import urllib.request -import uuid -from collections.abc import Iterable -from pathlib import Path -from typing import Any - -SERVER = "https://git.justinvisser.org" -REPOSITORY = "justinzeus/wolfskin-tv-updates" -REQUIRED = { - "Wolfskin-armeabi-v7a.apk", - "Wolfskin-arm64-v8a.apk", - "Wolfskin-x86_64.apk", - "Wolfskin.apk", - "wolfskin-update.json", - "RELEASE-NOTES.md", - "SHA256SUMS", -} - - -class ForgeHttpError(RuntimeError): - """Retain HTTP status without exposing response bodies or authorization.""" - - def __init__(self, method: str, status: int) -> None: - super().__init__(f"Forgejo {method} failed: HTTP {status}") - self.status = status - - -class NoRedirect(urllib.request.HTTPRedirectHandler): - """Validate every redirect before forwarding distribution-tool credentials.""" - - def redirect_request( - self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str - ) -> None: - return None - - -class Forge: - """Credential-scoped release API; credentials exist only in process memory.""" - - def __init__( - self, server: str, authorization: str, test_server: bool = False - ) -> None: - self.server = server.rstrip("/") - self.origin = urllib.parse.urlsplit(self.server) - self.authorization = authorization - self.test_server = test_server - self.opener = urllib.request.build_opener(NoRedirect) - self.api = self.server + "/api/v1/repos/" + REPOSITORY - - def request( - self, - method: str, - url: str, - data: Any = None, - content_type: str | None = None, - length: int | None = None, - authenticated: bool = True, - ) -> Any: - """Reject cross-origin credential forwarding and non-HTTPS production URLs.""" - for _ in range(6): - parsed = urllib.parse.urlsplit(url) - local = self.test_server and parsed.hostname in ( - "localhost", - "127.0.0.1", - "::1", - ) - if ( - parsed.username - or parsed.password - or (parsed.scheme != "https" and not local) - ): - raise ValueError( - "Distribution requests require credential-free HTTPS URLs" - ) - if authenticated and (parsed.scheme, parsed.netloc) != ( - self.origin.scheme, - self.origin.netloc, - ): - raise ValueError( - "Refusing to send release credentials to another origin" - ) - headers = {"Accept": "application/json"} - if authenticated: - headers["Authorization"] = self.authorization - if content_type: - headers["Content-Type"] = content_type - if length is not None: - headers["Content-Length"] = str(length) - request = urllib.request.Request( - url, data=data, method=method, headers=headers - ) - try: - return self.opener.open(request, timeout=60) - except urllib.error.HTTPError as error: - if method != "GET" or error.code not in (301, 302, 303, 307, 308): - status = error.code - error.close() - raise ForgeHttpError(method, status) from None - location = error.headers.get("Location") - error.close() - if not location: - raise ValueError("Forgejo redirect has no location") - url = urllib.parse.urljoin(url, location) - raise ValueError("Too many Forgejo redirects") - - def json( - self, - method: str, - path: str, - value: dict[str, Any] | None = None, - authenticated: bool = True, - ) -> Any: - """Read or mutate release metadata without exposing HTTP authorization.""" - data = None if value is None else json.dumps(value).encode() - with self.request( - method, - self.api + path, - data, - "application/json", - authenticated=authenticated, - ) as response: - return json.load(response) - - def upload(self, release_id: int, file: Path) -> dict[str, Any]: - """Stream multipart APK bytes instead of copying a universal APK into memory.""" - boundary = uuid.uuid4().hex - prefix = ( - f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="{file.name}"\r\n' - "Content-Type: application/octet-stream\r\n\r\n" - ).encode() - suffix = f"\r\n--{boundary}--\r\n".encode() - - def chunks() -> Iterable[bytes]: - yield prefix - with file.open("rb") as stream: - while chunk := stream.read(1024 * 1024): - yield chunk - yield suffix - - url = f"{self.api}/releases/{release_id}/assets?name={urllib.parse.quote(file.name)}" - with self.request( - "POST", - url, - chunks(), - f"multipart/form-data; boundary={boundary}", - len(prefix) + file.stat().st_size + len(suffix), - ) as response: - return json.load(response) - - def verify(self, url: str, expected: str, authenticated: bool) -> None: - """Verify bytes fetched from Forgejo, not only its declared asset size.""" - with self.request("GET", url, authenticated=authenticated) as response: - actual = hashlib.file_digest(response, "sha256").hexdigest() - if actual != expected: - raise ValueError("Uploaded artifact checksum mismatch") - - def require_newer_code(self, code: int) -> None: - """Read the public channel anonymously and fail closed on an uncertain floor.""" - try: - release = self.json("GET", "/releases/latest", authenticated=False) - except ForgeHttpError as error: - if error.status != 404: - raise - releases = self.json("GET", "/releases?limit=1", authenticated=False) - if releases == []: - return - raise ValueError("Latest is missing but the channel is not empty") from None - assets = [ - asset - for asset in release["assets"] - if asset["name"] == "wolfskin-update.json" - ] - if len(assets) != 1: - raise ValueError("Latest release must have exactly one update manifest") - with self.request( - "GET", assets[0]["browser_download_url"], authenticated=False - ) as response: - manifest = json.load(response) - latest_code = manifest["versionCode"] - if ( - manifest["schemaVersion"] != 1 - or manifest["packageId"] != "org.justinvisser.wolfskin.tv.opt" - or type(latest_code) is not int - or not 1 <= latest_code <= 2100000000 - ): - raise ValueError("Invalid live household version identity") - if code <= latest_code: - raise ValueError( - f"Bundle versionCode must exceed live channel code {latest_code}" - ) - - -def credential(server: str) -> str: - """Read git's existing credential helper without secrets in argv, stdout or disk.""" - parsed = urllib.parse.urlsplit(server) - environment = { - name: value - for name, value in os.environ.items() - if not name.startswith("GIT_TRACE") - and name not in {"GIT_CURL_VERBOSE", "SHELLOPTS", "BASHOPTS", "BASH_ENV", "ENV"} - } - environment["GIT_TERMINAL_PROMPT"] = "0" - result = subprocess.run( - ["git", "credential", "fill"], - input=f"protocol={parsed.scheme}\nhost={parsed.netloc}\n\n", - text=True, - capture_output=True, - check=False, - env=environment, - ) - if result.returncode: - raise RuntimeError("Git credential helper failed; no release created") - fields = dict( - line.split("=", 1) for line in result.stdout.splitlines() if "=" in line - ) - if not fields.get("password"): - raise ValueError("Git credential helper returned no release credential") - encoded = base64.b64encode( - f"{fields.get('username', '')}:{fields['password']}".encode() - ).decode() - return "Basic " + encoded - - -def validate_bundle(directory: Path) -> tuple[dict[str, Any], dict[str, str]]: - """Require every public asset and verify the local final-byte checksum set.""" - for name in REQUIRED: - if not (directory / name).is_file(): - raise ValueError(f"Required release asset missing: {name}") - hashes: dict[str, str] = {} - for line in (directory / "SHA256SUMS").read_text().splitlines(): - match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9_.-]+)", line) - if match is None or match[2] in hashes: - raise ValueError("Invalid or duplicate SHA256SUMS entry") - hashes[match[2]] = match[1] - if set(hashes) != REQUIRED - {"SHA256SUMS"}: - raise ValueError("Checksum set must cover exactly the public artifacts") - for name, expected in hashes.items(): - with (directory / name).open("rb") as stream: - if hashlib.file_digest(stream, "sha256").hexdigest() != expected: - raise ValueError(f"Local checksum mismatch: {name}") - hashes["SHA256SUMS"] = hashlib.sha256( - (directory / "SHA256SUMS").read_bytes() - ).hexdigest() - manifest = json.loads((directory / "wolfskin-update.json").read_text()) - if ( - manifest["schemaVersion"] != 1 - or manifest["packageId"] != "org.justinvisser.wolfskin.tv.opt" - ): - raise ValueError("Wrong household manifest identity") - if ( - manifest["signerSha256"] - != "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d" - ): - raise ValueError("Wrong household signing authority") - version = manifest["versionName"] - code = manifest["versionCode"] - if ( - not re.fullmatch(r"\d+\.\d+\.\d+", version) - or type(code) is not int - or not 1 <= code <= 2100000000 - ): - raise ValueError("Invalid household version identity") - if ( - not re.fullmatch(r"[0-9a-f]{40}", manifest["sourceCommit"]) - or manifest["sourceTag"] != f"pwolfskin-opt-v{version}" - ): - raise ValueError("Invalid immutable source identity") - published = datetime.datetime.fromisoformat( - manifest["publishedAt"].replace("Z", "+00:00") - ) - if published.utcoffset() is None: - raise ValueError("Publication time must include a timezone") - expected_abis = {"armeabi-v7a", "arm64-v8a", "x86_64", "universal"} - if ( - len(manifest["assets"]) != 4 - or {asset["abi"] for asset in manifest["assets"]} != expected_abis - ): - raise ValueError("Manifest must describe all four standalone APKs") - for asset in manifest["assets"]: - expected_name = ( - "Wolfskin.apk" - if asset["abi"] == "universal" - else f"Wolfskin-{asset['abi']}.apk" - ) - if asset["name"] != expected_name or asset["minSdk"] < 23: - raise ValueError("Invalid APK asset identity or minimum SDK") - path = directory / asset["name"] - if ( - hashes.get(path.name) != asset["sha256"] - or path.stat().st_size != asset["bytes"] - ): - raise ValueError("Manifest does not match final APK bytes") - return manifest, hashes - - -def publish( - directory: Path, - server: str, - promote: bool, - dry_run: bool, - test_server: bool = False, - draft_id: int | None = None, -) -> None: - """Keep incomplete uploads draft; withdraw a failed promoted release from latest.""" - manifest, hashes = validate_bundle(directory) - version = manifest["versionName"] - tag = f"wolfskin-opt-v{version}" - if dry_run: - print( - f"Dry run: draft {REPOSITORY} {tag}; upload and verify {len(hashes)} assets; promote={promote}; no requests or credentials" - ) - return - Forge(server, "", test_server).require_newer_code(manifest["versionCode"]) - forge = Forge(server, credential(server), test_server) - if draft_id is None: - release = forge.json( - "POST", - "/releases", - { - "name": f"v{version}", - "tag_name": tag, - "body": (directory / "RELEASE-NOTES.md").read_text(), - "draft": True, - "prerelease": False, - }, - ) - else: - release = forge.json("GET", f"/releases/{draft_id}") - if not release["draft"] or release["tag_name"] != tag: - raise ValueError("Resume target must be the matching draft release") - release_id = release["id"] - existing = {asset["name"]: asset for asset in release.get("assets", [])} - print( - f"Verifying draft release {release_id}; it stays draft on any verification failure" - ) - uploaded: dict[str, str] = {} - for name, expected in hashes.items(): - asset = existing.get(name) or forge.upload(release_id, directory / name) - url = asset["browser_download_url"] - forge.verify(url, expected, authenticated=True) - uploaded[name] = url - if not promote: - print( - "Draft upload verified. Publication requires owner approval and explicit --promote." - ) - return - forge.require_newer_code(manifest["versionCode"]) - try: - forge.json("PATCH", f"/releases/{release_id}", {"draft": False}) - latest = forge.json("GET", "/releases/latest", authenticated=False) - if latest["id"] != release_id or latest["tag_name"] != tag: - raise ValueError("Anonymous latest does not identify the promoted release") - for name, url in uploaded.items(): - forge.verify(url, hashes[name], authenticated=False) - except (ValueError, RuntimeError, OSError, KeyError, TypeError): - try: - forge.json("PATCH", f"/releases/{release_id}", {"draft": True}) - except (ValueError, RuntimeError, OSError): - raise RuntimeError( - "Post-publication verification AND withdrawal failed; release may remain public" - ) from None - raise RuntimeError( - "Post-publication verification failed; release withdrawn to draft" - ) from None - print(f"Published and anonymously verified {tag}") - - -def main() -> None: - """Expose a deliberately separate publication command.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--directory", required=True, type=Path) - parser.add_argument("--promote", action="store_true") - parser.add_argument("--dry-run", action="store_true") - parser.add_argument( - "--draft-id", type=int, help="Resume and reverify an existing matching draft" - ) - parser.add_argument( - "--test-server", help="Local fake Forgejo only; HTTP loopback permitted" - ) - args = parser.parse_args() - if args.test_server: - parsed = urllib.parse.urlsplit(args.test_server) - if parsed.scheme != "http" or parsed.hostname not in ( - "localhost", - "127.0.0.1", - "::1", - ): - parser.error("--test-server is restricted to an HTTP loopback fixture") - publish( - args.directory.resolve(), - args.test_server or SERVER, - args.promote, - args.dry_run, - bool(args.test_server), - args.draft_id, - ) - - -if __name__ == "__main__": - main() diff --git a/docker/tv_release.py b/docker/tv_release.py deleted file mode 100755 index 5ce4b890..00000000 --- a/docker/tv_release.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 -"""Build and inspect the household assembly without creating a signing identity.""" - -import argparse -import datetime -import hashlib -import json -import os -import re -import shutil -import subprocess -import zipfile -from pathlib import Path - -PACKAGE = "org.justinvisser.wolfskin.tv.opt" -SIGNER = "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d" -ABIS = ("armeabi-v7a", "arm64-v8a", "x86_64", "universal") -ROOT = Path(__file__).resolve().parent.parent - - -def run(*arguments: str) -> str: - """Run a checked command whose arguments contain no credentials.""" - return subprocess.check_output(arguments, cwd=ROOT, text=True).strip() - - -def digest(path: Path) -> str: - """Hash final artifact bytes without loading an APK into memory.""" - with path.open("rb") as stream: - return hashlib.file_digest(stream, "sha256").hexdigest() - - -def validate_inputs(version: str, code: int) -> str: - """Require a clean immutable source and a code newer than the tracked history.""" - if not re.fullmatch(r"\d+\.\d+\.\d+", version) or not 1 <= code <= 2100000000: - raise ValueError("Supply a plain VERSION and a positive Android VERSION_CODE") - if run("git", "status", "--porcelain"): - raise ValueError( - "Release source must be clean, including the version-code ledger" - ) - commit = run("git", "rev-parse", "HEAD") - run( - "git", - "fetch", - "--no-tags", - "origin", - "refs/heads/main:refs/remotes/origin/main", - ) - used = [] - for reference in (commit, "refs/remotes/origin/main"): - if run("git", "ls-tree", "--name-only", reference, "docker/tv-version-codes"): - ledger = run("git", "show", f"{reference}:docker/tv-version-codes") - used.extend( - int(line.split()[0]) - for line in ledger.splitlines() - if line and not line.startswith("#") - ) - reservations = run( - "git", - "for-each-ref", - "--format=%(refname:strip=3)", - "refs/wolfskin-tv/version-codes/", - ) - used.extend(int(value) for value in reservations.splitlines()) - if code <= max(used): - raise ValueError(f"VERSION_CODE must exceed reserved maximum {max(used)}") - if list((ROOT / "app/libs").glob("*.aar")): - raise ValueError("Local AAR overrides are forbidden for household releases") - return commit - - -def reserve_code(code: int, commit: str) -> None: - """Keep a consumed code reserved even if its working-tree ledger append is discarded.""" - run("git", "update-ref", f"refs/wolfskin-tv/version-codes/{code}", commit, "") - - -def inspect_apk(path: Path, version: str, code: int, abi: str, proof: Path) -> int: - """Verify identity, final signature, optimization and playback-complete native inventory.""" - image = os.environ.get("WOLFSKIN_BUILD_IMAGE", "wolfskin-build:sdk37-jdk21") - mount = f"type=bind,src={path.parent},dst=/artifacts,readonly" - base = ( - "docker", - "run", - "--rm", - "--platform", - "linux/amd64", - "--mount", - mount, - image, - ) - badging = run(*base, "aapt2", "dump", "badging", f"/artifacts/{path.name}") - certificate = run( - *base, - "apksigner", - "verify", - "--verbose", - "--print-certs", - f"/artifacts/{path.name}", - ) - proof.write_text(badging + "\n" + certificate + "\n") - package = re.search( - r"package: name='([^']+)' versionCode='(\d+)' versionName='([^']+)'", badging - ) - if package is None or package.groups() != (PACKAGE, str(code), version): - raise ValueError(f"Wrong APK identity: {path.name}") - fingerprints = re.findall( - r"Signer #\d+ certificate SHA-256 digest: ([0-9a-fA-F]+)", certificate - ) - if fingerprints != [SIGNER]: - raise ValueError(f"Wrong household signer: {path.name}") - if "application-debuggable" in badging: - raise ValueError("Household APK must not be debuggable") - minimum = re.search(r"sdkVersion:'(\d+)'", badging) - if minimum is None: - raise ValueError("APK minimum SDK is missing") - with zipfile.ZipFile(path) as archive: - names = archive.namelist() - for profile in ("assets/dexopt/baseline.prof", "assets/dexopt/baseline.profm"): - if profile not in names: - raise ValueError(f"Missing optimized profile: {profile}") - if any( - name.startswith(("assets/wolfskin-", "assets/library/")) for name in names - ): - raise ValueError("Debug fixtures leaked into the household assembly") - for target in ABIS[:-1] if abi == "universal" else (abi,): - libraries = [name for name in names if name.startswith(f"lib/{target}/")] - for component in ("mpv", "ffmpeg", "av1"): - if not any(component in name.lower() for name in libraries): - raise ValueError(f"Missing native {component} for {target}") - proof.with_suffix(".inventory").write_text("\n".join(names) + "\n") - return int(minimum.group(1)) - - -def release(version: str, code: int, output: Path, dry_run: bool) -> None: - """Produce named APKs, inspected metadata, checksums and source-linked release notes.""" - commit = validate_inputs(version, code) - tag = f"pwolfskin-opt-v{version}" - key = Path( - os.environ.get( - "WOLFSKIN_DEBUG_KEYSTORE", - "/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore", - ) - ) - if not key.is_file() or key.stat().st_size == 0: - raise ValueError( - "Existing household signing key required; never generate a replacement" - ) - if output.exists(): - raise ValueError("Release output must not exist; preserve previous proof") - if dry_run: - print( - f"Dry run: source {commit}; defaultOptimized {version} code {code}; four APKs, manifest, notes and SHA256SUMS; no build, tag or publication" - ) - return - existing = run("git", "tag", "--list", tag) - if existing and run("git", "rev-parse", f"{tag}^{{commit}}") != commit: - raise ValueError("Source tag already identifies another commit") - reserve_code(code, commit) - output.mkdir(parents=True) - private = output / "private" - private.mkdir(mode=0o700) - with (private / "build.log").open("w") as log: - subprocess.run( - [ - "bash", - "docker/gradle.sh", - "household", - f"-PtvVersion={version}", - f"-PtvVersionCode={code}", - "assembleDefaultOptimized", - ":app:dependencies", - "--configuration", - "defaultOptimizedRuntimeClasspath", - ], - cwd=ROOT, - check=True, - stdout=log, - stderr=subprocess.STDOUT, - ) - assets: list[dict[str, object]] = [] - for abi in ABIS: - suffix = "" if abi == "universal" else f"-{abi}" - source = ( - ROOT - / f"app/build/outputs/apk/default/optimized/Wholphin-default-optimized-{version}-{code}{suffix}.apk" - ) - target = output / f"Wolfskin{suffix}.apk" - shutil.copyfile(source, target) - minimum = inspect_apk(target, version, code, abi, private / f"{abi}.inspection") - assets.append( - { - "abi": abi, - "name": target.name, - "bytes": target.stat().st_size, - "sha256": digest(target), - "minSdk": minimum, - } - ) - mapping = ROOT / "app/build/outputs/mapping/defaultOptimized" - shutil.copytree(mapping, private / "mapping") - manifest = { - "schemaVersion": 1, - "packageId": PACKAGE, - "versionName": version, - "versionCode": code, - "publishedAt": datetime.datetime.now(datetime.UTC) - .isoformat() - .replace("+00:00", "Z"), - "sourceCommit": commit, - "sourceTag": tag, - "signerSha256": SIGNER, - "assets": assets, - } - (output / "wolfskin-update.json").write_text(json.dumps(manifest, indent=2) + "\n") - source = f"https://git.justinvisser.org/justinzeus/wolfskin/src/tag/{tag}" - (output / "RELEASE-NOTES.md").write_text( - f"# Wolfskin TV {version}\n\nIn-app verified household updates with explicit Android confirmation.\n\n" - f"Package `{PACKAGE}`, code {code}, signer SHA-256 `{SIGNER}`.\n" - "Same-package, same-signer upgrades retain app data; never uninstall or clear data to update. " - "An earlier ephemeral signer needs a separately authorized migration, not an automatic reinstall.\n\n" - "Android 6 (API 23) or later. Standalone armeabi-v7a, arm64-v8a and x86_64 APKs; " - "universal fallback. 32-bit x86 is not MPV-complete and is unsupported for playback. " - "MPV, ffmpeg and AV1 extensions: pinned Wholphin extensions 0.2.4.\n\n" - f"GPL-2.0-only; Wolfskin derives from Wholphin by damontecres and contributors. " - f"Corresponding source: [{commit}]({source}); " - f"[source archive](https://git.justinvisser.org/justinzeus/wolfskin/archive/{tag}.tar.gz).\n" - "[Upstream Wholphin](https://github.com/damontecres/wholphin), " - "[native extension source 0.2.4](https://github.com/damontecres/wholphin-extensions/tree/v0.2.4).\n" - ) - files = sorted(path for path in output.iterdir() if path.is_file()) - (output / "SHA256SUMS").write_text( - "".join(f"{digest(path)} {path.name}\n" for path in files) - ) - if not existing: - run("git", "tag", tag, commit) - with (ROOT / "docker/tv-version-codes").open("a") as ledger: - ledger.write(f"{code} {version} {commit}\n") - print( - f"Release packaged at {output}; commit the code ledger and push source tag {tag} before distribution. Nothing published." - ) - - -def main() -> None: - """Expose the make tv-release build contract.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--version", required=True) - parser.add_argument("--version-code", required=True, type=int) - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--dry-run", action="store_true") - args = parser.parse_args() - release(args.version, args.version_code, args.output.resolve(), args.dry_run) - - -if __name__ == "__main__": - main() diff --git a/wolfskin-design/build.gradle.kts b/wolfskin-design/build.gradle.kts index 74caf761..d1275edb 100644 --- a/wolfskin-design/build.gradle.kts +++ b/wolfskin-design/build.gradle.kts @@ -19,18 +19,13 @@ android { } kotlin.compilerOptions.jvmTarget = JvmTarget.JVM_11 +val robolectricSdk by configurations.creating { + isCanBeResolved = true + isCanBeConsumed = false + isTransitive = false +} val cacheRobolectricSdk by tasks.registering(Copy::class) { - val versions = listOf( - "9-robolectric-4913185-2-i7", - "11-robolectric-6757853-i7", - "14-robolectric-10818077-i7", - ) - // One configuration would evict the older SDKs through version conflict resolution. - from(versions.map { version -> - project.configurations.detachedConfiguration( - project.dependencies.create("org.robolectric:android-all-instrumented:$version"), - ).apply { isTransitive = false } - }) + from(robolectricSdk) into(layout.buildDirectory.dir("robolectric-sdk")) } @@ -55,6 +50,9 @@ tasks.withType().configureEach { } dependencies { + robolectricSdk("org.robolectric:android-all-instrumented:9-robolectric-4913185-2-i7") + robolectricSdk("org.robolectric:android-all-instrumented:11-robolectric-6757853-i7") + robolectricSdk("org.robolectric:android-all-instrumented:14-robolectric-10818077-i7") implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.tv.material) diff --git a/wolfskin-design/generate_tokens.py b/wolfskin-design/generate_tokens.py index e18bc364..51a49b20 100755 --- a/wolfskin-design/generate_tokens.py +++ b/wolfskin-design/generate_tokens.py @@ -32,7 +32,6 @@ def render_tokens(tokens: dict) -> tuple[str, str]: "// Generated by generate_tokens.py; edit the JSON sources instead.", "package org.justinvisser.wolfskin.design", "", - "import androidx.compose.animation.core.CubicBezierEasing", "import androidx.compose.ui.graphics.Color as ComposeColor", "import androidx.compose.ui.text.TextStyle", "import androidx.compose.ui.text.font.FontWeight", @@ -72,10 +71,6 @@ def render_tokens(tokens: dict) -> tuple[str, str]: elif kind == "duration": lines.append(f" const val {name} = {int(value.removesuffix('ms'))}") css.append(f" --{group_name}-{name}: {value};") - elif kind == "cubicBezier": - points = ", ".join(f"{number(point)}f" for point in value) - lines.append(f" val {name} = CubicBezierEasing({points})") - css.append(f" --{group_name}-{name}: cubic-bezier({', '.join(map(str, value))});") elif kind == "transition" and value.get("type") == "spring": lines.append(f" const val {name}DampingRatio = {number(value['dampingRatio'])}f") lines.append(f" const val {name}Stiffness = {number(value['stiffness'])}f") diff --git a/wolfskin-design/home-tokens.json b/wolfskin-design/home-tokens.json index 7cceeaa7..a574a31d 100644 --- a/wolfskin-design/home-tokens.json +++ b/wolfskin-design/home-tokens.json @@ -114,6 +114,12 @@ "dp": 18.0, "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" }, + "captionWidth": { + "$type": "dimension", + "px": 640, + "dp": 320.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, "plateTop": { "$type": "dimension", "px": 14, @@ -234,6 +240,18 @@ "dp": 30.0, "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" }, + "progressInset": { + "$type": "dimension", + "px": 18, + "dp": 9.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "progressBottom": { + "$type": "dimension", + "px": 10, + "dp": 5.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, "endTop": { "$type": "dimension", "px": 40, @@ -298,6 +316,7 @@ "savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" }, "savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" }, "savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" }, - "savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" } + "savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" }, + "posterProgressBottom": { "$type": "dimension", "px": 12, "dp": 6.0, "source": "locked index.html:138-140" } } } diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json index 3c29b7ae..e66ac6ec 100644 --- a/wolfskin-design/shell-tokens.json +++ b/wolfskin-design/shell-tokens.json @@ -367,6 +367,9 @@ "chipGap": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184-185"}, "chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"}, "chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"}, + "captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"}, + "captionSeparatorOpacity": {"$type":"number","$value":0.4,"source":"index.html:1207; spec 4.4"}, + "captionFactsGap": {"$type":"dimension","px":18,"dp":9,"source":"index.html:154"}, "badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"}, "plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"}, "plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"}, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt index 065e26e4..5f9a0742 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt @@ -1,34 +1,72 @@ package org.justinvisser.wolfskin.design import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.TextStyle import androidx.tv.material3.Text +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.constrainHeight -/** Captions rise inside the reserved band without changing measured geometry. */ +/** Captions fade without moving the reserved band under the focused poster. */ @Composable -fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) { +fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false, + titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption, + titleOverflow: TextOverflow = TextOverflow.Ellipsis) { val alpha = remember(title, facts) { Animatable(0f) } - val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) } - val reduced = reducedMotion() - val rise = with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() } - LaunchedEffect(alpha, visible, reduced) { - if (visible && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong()) - alpha.animateTo(if (visible) 1f else 0f, tween(motionDuration(if (visible) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) { - if (visible) entryProgress.floatValue = value - } + LaunchedEffect(alpha, visible) { + alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) } - val titleStyle = WolfskinTokens.Type.button - val factsStyle = WolfskinTokens.Type.caption - Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) { - Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(facts, color = WolfskinTokens.Color.muted, style = factsStyle, modifier = Modifier.exactLineBox(factsStyle), maxLines = 1, overflow = TextOverflow.Ellipsis) + Column(modifier.graphicsLayer { this.alpha = alpha.value }) { + Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow) + CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle) + } +} + +/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */ +@Composable +private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean, style: TextStyle) { + val parts = remember(facts) { facts.split(" · ") } + val muted = WolfskinTokens.Color.muted + val colour = WolfskinTokens.Color.accentText + Layout( + content = { + parts.forEachIndexed { index, part -> + val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast) + Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.exactLineBox(style), maxLines = 1, overflow = TextOverflow.Clip) + if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.exactLineBox(style)) + } + }, + modifier = Modifier.fillMaxWidth().clipToBounds(), + ) { children, constraints -> + val gap = WolfskinTokens.Library.captionFactsGap.roundToPx() + val basis = IntArray(children.size) + var fixed = gap * (children.size - 1).coerceAtLeast(0) + var flexible = 0 + fun shrinks(index: Int) = (index == 0 && accentFirst) || (index == children.lastIndex && accentLast) + children.forEachIndexed { index, child -> + val width = child.maxIntrinsicWidth(constraints.maxHeight) + basis[index] = width + if (shrinks(index)) flexible += width else fixed += width + } + val available = (constraints.maxWidth - fixed).coerceAtLeast(0) + val ratio = if (flexible > available) available.toFloat() / flexible else 1f + val measured = children.mapIndexed { index, child -> + val width = if (shrinks(index)) (basis[index] * ratio).toInt() else basis[index] + child.measure(constraints.copy(minWidth = 0, maxWidth = width, minHeight = 0)) + } + layout(constraints.maxWidth, constraints.constrainHeight(measured.maxOfOrNull { it.height } ?: 0)) { + var x = 0 + measured.forEach { child -> child.placeRelative(x, 0); x += child.width + gap } + } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CardCaption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CardCaption.kt deleted file mode 100644 index 9d6f28a8..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CardCaption.kt +++ /dev/null @@ -1,81 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.res.painterResource -import androidx.tv.material3.Icon -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.CardMetadata as M -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C - -/** Card-width titles never displace runtime or watched metadata into a neighbouring slot. */ -@Composable -fun CardCaption(title: String, year: Int?, duration: String?, watched: Boolean, focused: Boolean, - modifier: Modifier = Modifier, focusOnly: Boolean = false, showTitle: Boolean = true) { - val transition = if (focusOnly) { - val alpha = remember(title) { Animatable(0f) } - val entryProgress = remember(title) { mutableFloatStateOf(0f) } - val reduced = reducedMotion() - val rise = with(LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() } - LaunchedEffect(alpha, focused, reduced) { - if (focused && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong()) - alpha.animateTo(if (focused) 1f else 0f, tween(motionDuration(if (focused) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) { - if (focused) entryProgress.floatValue = value - } - } - Modifier.graphicsLayer { - this.alpha = alpha.value - translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) - } - } else Modifier - Column(modifier.then(transition)) { - if (showTitle) { - val style = if (focused || focusOnly) M.titleFocus else M.titleRest - Text(title, color = if (focused || focusOnly) C.fg else C.muted, style = style, - modifier = Modifier.fillMaxWidth().exactLineBox(style), maxLines = 1, overflow = TextOverflow.Ellipsis) - } - CardMetadata(year, duration, watched) - } -} - -/** Measured gaps tighten before any fixed-size year, runtime or check can be clipped. */ -@Composable -fun CardMetadata(year: Int?, duration: String?, watched: Boolean, modifier: Modifier = Modifier) { - Layout(content = { - if (year != null) Text(year.toString(), color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1) - if (duration != null) Text(duration, color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1) - if (watched) Icon(painterResource(R.drawable.card_watched_check), "Watched", Modifier.size(M.check), tint = C.muted) - }, modifier = modifier.fillMaxWidth().clipToBounds()) { children, constraints -> - val sizes = IntArray(children.size) { children[it].maxIntrinsicWidth(constraints.maxHeight) } - val gaps = (children.size - 1).coerceAtLeast(0) - val textWidth = sizes.sum() - val gap = when { - textWidth + gaps * M.gap.roundToPx() <= constraints.maxWidth -> M.gap.roundToPx() - textWidth + gaps * M.compactGap.roundToPx() <= constraints.maxWidth -> M.compactGap.roundToPx() - else -> M.minimumGap.roundToPx() - } - val measured = Array(children.size) { children[it].measure(Constraints.fixedWidth(sizes[it])) } - val height = with(this) { M.subtitle.lineHeight.toPx().toInt() } - layout(constraints.maxWidth, height) { - var left = 0 - measured.forEach { child -> - child.placeRelative(left, (height - child.height) / 2) - left += child.width + gap - } - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt index 905deb57..77e26655 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt @@ -56,17 +56,12 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth, if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright) } - val heroReady = LocalHeroArtReady.current - if (painter != null) { - androidx.compose.runtime.SideEffect { heroReady?.invoke() } - Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter) - } + if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter) else AsyncImage( model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter, onError = { onError() }, - onSuccess = { heroReady?.invoke() }, ) } @@ -75,11 +70,7 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) { val failed = remember(title.poster, title.backdrop) { androidx.compose.runtime.mutableStateOf(false) } Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).clipToBounds().background(C.surface3)) { - if (failed.value || (title.poster == null && title.backdrop == null)) { - val heroReady = LocalHeroArtReady.current - androidx.compose.runtime.SideEffect { heroReady?.invoke() } - HomeAmbient(Modifier.fillMaxSize().testTag("discover-missing-art")) - } + if (failed.value || (title.poster == null && title.backdrop == null)) HomeAmbient(Modifier.fillMaxSize().testTag("discover-missing-art")) else if (title.posterOnly) { DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset) .wrapContentSize(Alignment.TopStart, unbounded = true) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index e3137e45..fcc891ff 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -32,7 +32,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.tv.material3.Text @@ -73,8 +73,8 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { return } if (state.snapshot.status == DiscoverStatus.Loading) { - LaunchedEffect(state) { if (!shell.navFocused) shell.focusNav(shell.currentTab) } - DiscoverLoading(modifier) + val loading = remember(state) { LibraryGridState("Discover", emptyList(), catalogue = true).apply { status = LibraryStatus.Loading } } + LibraryScreen(shell, loading, modifier, offlineArt = LocalDiscoverArt.current, registerFocus = false) return } val density = LocalDensity.current @@ -104,9 +104,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { } } Box(modifier.fillMaxSize().testTag("discover-screen")) { - if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { title -> - HeroArtCrossfade(title.key, title, L.row_phero, Modifier.offset { IntOffset(0, -list.firstVisibleItemScrollOffset) }) { DiscoverBackdrop(it) } - } + if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { DiscoverBackdrop(it, modifier = Modifier.graphicsLayer { translationY = -list.firstVisibleItemScrollOffset.toFloat() }) } LazyColumn(state = list, contentPadding = PaddingValues(bottom = (WolfskinTokens.Shell.stageHeight - L.pinnedLine - heights.last()).coerceAtLeast(D.frameBottomMin)), modifier = Modifier.fillMaxSize()) { item(key = "discover-header", contentType = if (state.hasHero && state.service == null) "hero" else "header") { if (state.hasHero && state.service == null) DiscoverHero(state) else LibraryHeader(state.shell.person, state.service?.name ?: "Discover", if (state.service != null) "General interest" else "") @@ -134,23 +132,22 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { @Composable private fun DiscoverHero(state: DiscoverState) { val title = state.hero ?: return - HeroCopyHandoff(title.key, title) { copy -> - Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) { + Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) { Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) { Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon)) Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label) } Spacer(Modifier.height(D.heroKickerGap)) - Text(copy.title, Modifier.width(L.heroCopyWidth).testTag("discover-hero-title-box").discoverLineBox(DiscoverType.hero), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(title.title, Modifier.width(L.heroCopyWidth).testTag("discover-hero-title-box").discoverLineBox(DiscoverType.hero), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis) Spacer(Modifier.height(D.heroTitleGap)) Row(Modifier.height(D.heroFactsHeight), horizontalArrangement = Arrangement.spacedBy(D.heroFactsGap), verticalAlignment = Alignment.CenterVertically) { - Text("${copy.typeWord}${copy.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = DiscoverType.body) - if (copy.genres.isNotEmpty()) Text(copy.genres.take(2).joinToString(", "), color = C.muted, style = DiscoverType.body) - if (state.isSignedIn) DiscoverRatings(copy.ratings) - if (state.shell.person.key in copy.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body) + Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = DiscoverType.body) + if (title.genres.isNotEmpty()) Text(title.genres.take(2).joinToString(", "), color = C.muted, style = DiscoverType.body) + if (state.isSignedIn) DiscoverRatings(title.ratings) + if (state.shell.person.key in title.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body) } Spacer(Modifier.height(D.heroMetaGap)) - Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(title.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis) Spacer(Modifier.height(D.heroOverviewGap)) Row(verticalAlignment = Alignment.CenterVertically) { DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) } @@ -161,7 +158,6 @@ private fun DiscoverHero(state: DiscoverState) { } } } - } } @Composable diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index ed527a73..e6ded43c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -55,7 +55,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { LazyRow(state = cards, userScrollEnabled = false, contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap), horizontalArrangement = Arrangement.spacedBy(K.posterGap), - modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) { + modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2)) { if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service -> Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard) .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) @@ -66,7 +66,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { } } else if (lane.pending) { items(slots, key = { "pending:$it" }) { - PosterCaptionSkeleton() + Box(Modifier.size(K.posterW, K.posterH).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.card))) } } else { itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item -> @@ -80,16 +80,15 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { } } } - if (!services && !lane.pending) { + if (!services && active && !lane.pending) { val item = lane.titles.getOrNull(column) - val captionModifier = Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(K.posterW) - if (item != null) { - val card = item.libraryCard(state) - CardCaption(item.title, card.year, card.duration, false, active, captionModifier, focusOnly = true) - } else Caption( - if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", - if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", - active, captionModifier) + Caption( + item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", + if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", + true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth), + accentFirstFact = lane.hidden, + accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()), + ) } } } @@ -109,3 +108,7 @@ internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick) } +internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String { + val card = title.libraryCard(state) + return (if (hidden) "Hidden from your picks · " else "") + card.facts(true) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 8b0d8209..f0e2dabb 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -156,8 +156,7 @@ class DiscoverState( } private fun moveRow(direction: Int) { - var target = focusedRow + direction - while (target in firstShelf until rowCount && lanes[target - firstShelf].pending) target += direction + val target = focusedRow + direction if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return } if (target >= rowCount) return val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt index 9a386180..39e23ea1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt @@ -1,5 +1,6 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable @@ -25,10 +26,9 @@ import androidx.compose.ui.unit.Dp /** Only transforms animate: focus never changes measured geometry. */ @Composable fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier { - val reduced = reducedMotion() val animated by animateFloatAsState( if (focused) scale else 1f, - tween(if (reduced) 0 else WolfskinTokens.Motion.focusMove, easing = WolfskinTokens.Motion.easeOut), + tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-scale", ) return graphicsLayer { scaleX = animated; scaleY = animated } @@ -40,9 +40,8 @@ enum class FocusLift { None, Card, Button } /** Every focus ring straddles the surface edge above content, without a background gap. */ @Composable fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier { - val reduced = reducedMotion() val ringAlpha by animateFloatAsState(if (focused) 1f else 0f, - tween(motionDuration(WolfskinTokens.Motion.focusMove, reduced), easing = WolfskinTokens.Motion.easeOut), label = "focus-ring") + tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring") val shadow = if (lift == FocusLift.None) this else { val shape = remember(radius) { RoundedCornerShape(radius) } val density = LocalDensity.current diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt index bab07b25..18b83e5f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.animate import androidx.compose.animation.core.tween import androidx.compose.foundation.lazy.LazyListState @@ -10,8 +11,8 @@ internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) { if (distance == 0f) return list.scroll { var previous = 0f - animate(0f, distance, animationSpec = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, - easing = WolfskinTokens.Motion.easeOut)) { value, _ -> + animate(0f, distance, animationSpec = tween(WolfskinTokens.Motion.scroll, + easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ -> scrollBy(value - previous) previous = value } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt deleted file mode 100644 index 13faf0f5..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt +++ /dev/null @@ -1,100 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.platform.testTag -import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M -import kotlinx.coroutines.launch -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow -import kotlinx.coroutines.flow.first - -/** Artwork success or its final fallback releases a pending hero hand-off. */ -internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null } - -/** Only the upper hero region blends; the lower backdrop changes without an animated screen layer. */ -@Composable -fun HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) { - var displayed by remember { mutableStateOf(target) } - var displayedIdentity by remember { mutableStateOf(identity) } - var outgoing by remember { mutableStateOf(null) } - val alpha = remember { Animatable(0f) } - val reduced = reducedMotion() - var readyIdentity by remember { mutableStateOf(identity) } - val latestIdentity by rememberUpdatedState(identity) - val onReady = remember(identity) { { if (latestIdentity == identity) readyIdentity = identity } } - LaunchedEffect(identity, reduced) { - if (displayedIdentity != identity) { - outgoing = displayed - alpha.snapTo(1f) - snapshotFlow { readyIdentity == identity }.first { it } - displayed = target - displayedIdentity = identity - } - if (outgoing != null) { - alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut)) - outgoing = null - } - } - Box(modifier) { - CompositionLocalProvider(LocalHeroArtReady provides onReady) { content(target) } - outgoing?.let { old -> - Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height).graphicsLayer { this.alpha = alpha.value; clip = true }.testTag("hero-outgoing")) { - Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { content(old) } - } - } - } -} - -/** One bounded copy layer fades out, hands off, then rises; controls keep their logical owner. */ -@Composable -fun HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) { - val reduced = reducedMotion() - val alpha = remember { Animatable(0f) } - val progress = remember { Animatable(0f) } - var displayed by remember { mutableStateOf(target) } - var previous by remember { mutableStateOf(identity) } - var handingOff by remember { mutableStateOf(false) } - var entered by remember { mutableStateOf(false) } - val rise = with(androidx.compose.ui.platform.LocalDensity.current) { (if (entered) M.copyRise else M.copyEntryRise).toPx() } - LaunchedEffect(identity, reduced) { - if (previous != identity || handingOff) { - handingOff = true - alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease)) - displayed = target - previous = identity - progress.snapTo(0f) - if (!reduced) kotlinx.coroutines.delay((M.heroCopyDelay - M.heroCopyOut).toLong()) - kotlinx.coroutines.coroutineScope { - launch { alpha.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) } - launch { progress.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) } - } - handingOff = false - } else if (!entered) { - kotlinx.coroutines.coroutineScope { - launch { alpha.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) } - launch { progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) } - } - } - entered = true - displayed = target - } - Box(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - progress.value) }) { - content(if (handingOff || previous != identity) displayed else target) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt index 8057e033..8aad42d4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -23,27 +23,24 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List - TitleArtwork(art.title, art.type, art.poster, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, backdrop = art.backdrop, posterOnly = art.posterOnly, landscape = true, hero = true) - } + TitleArtwork(title.title, title.type, title.poster, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = true, hero = true) Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg))) Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f)))) - HeroCopyHandoff(title.id, title, Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) { copy -> - Column { + Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) { Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f))) Text(when (row.heroKind) { "resume" -> "Continue watching"; "next" -> "Up next"; "new" -> "New in your library"; else -> "For you" }, modifier = Modifier.exactLineBox(T.label), color = C.accentText, style = T.label) } Spacer(Modifier.height(H.kickerBottom)) - Text(copy.displayTitle, modifier = Modifier.exactLineBox(T.displayHero), color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(title.displayTitle, modifier = Modifier.exactLineBox(T.displayHero), color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis) Spacer(Modifier.height(H.titleBottom)) Row(Modifier.heightIn(min = H.factsHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.factsGap)) { - Text(if (copy.isEpisode) copy.subtitle else copy.year?.toString().orEmpty(), modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1) - if (row.heroKind == "resume" || copy.runtimeSeconds != null) Text(if (row.heroKind == "resume") copy.remaining else copy.runtime, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1) + Text(if (title.isEpisode) title.subtitle else title.year?.toString().orEmpty(), modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1) + if (row.heroKind == "resume" || title.runtimeSeconds != null) Text(if (row.heroKind == "resume") title.remaining else title.runtime, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1) HomeRatingsRow(ratings) } Spacer(Modifier.height(H.factsBottom)) - Text(copy.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body)) + Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body)) Spacer(Modifier.height(H.overviewBottom)) Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { val detailsOnly = row.heroKind == "pick" || !row.watchReady @@ -52,15 +49,14 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List, modifier: Modifier = Modifier, fon /** The plate is a prefix of the full ratings set; its first entry is never discarded. */ @Composable fun HomeRatingPlate(ratings: List, visible: Boolean, modifier: Modifier = Modifier) { - val alpha by animateFloatAsState(if (visible && ratings.isNotEmpty()) 1f else 0f, tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())), label = "rating-plate") + val alpha by animateFloatAsState(if (visible && ratings.isNotEmpty()) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate") if (ratings.isEmpty()) return val measurer = rememberTextMeasurer() val density = LocalDensity.current diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index 11725f4a..10090be2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -1,5 +1,6 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.* @@ -68,7 +69,7 @@ fun HomeScreen( (heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx() }.coerceAtLeast(0f) val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset - view.listState.animateScrollBy(target - current, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) + view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) view.home.positionedKey = key view.home.positionedReset = view.home.resetScroll } @@ -119,7 +120,7 @@ fun HomeScreen( private fun rowHeight(row: HomeRow) = when (row.kind) { HomeRowKind.Hero -> L.row_hero - HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand) + L.rowGapAfter + HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.Shell.zero) + L.rowGapAfter HomeRowKind.Section -> L.row_section HomeRowKind.State -> L.row_state HomeRowKind.Note -> L.row_note diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt index d1827758..069851f1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -19,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.platform.testTag +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.zIndex @@ -34,7 +36,7 @@ import org.justinvisser.wolfskin.design.HomeType as T fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List, onCard: (Int) -> Unit, onHover: (Int) -> Unit) { val width = row.cardWidth val height = row.cardHeight - val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand + val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0) SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first } val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } } @@ -48,36 +50,39 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) { if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide)) val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList() - LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + caption + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) { + LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) { itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title -> - val selected = !row.skeleton && focused && column == index + val selected = focused && column == index val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index") .then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier) - if (row.skeleton) { - PosterCaptionSkeleton(width, height, caption) - } else if (title == null) { + if (title == null && !row.skeleton) { LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label) - } else { - val progress = if (row.catalogue || title.runtimeSeconds == null || title.runtimeSeconds <= 0 || title.resumeSeconds <= 0) 0f else (title.resumeSeconds / title.runtimeSeconds).toFloat() - TitleCard(selected, cardModifier.clickable { onCard(index) }, width, height, - watched = !row.catalogue && title.played, progress = if (progress >= 1f) 0f else progress) { + } else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) { + if (title == null) Box(Modifier.fillMaxSize().background(C.surface)) else { TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster) if (row.cardKind != HomeCardKind.Poster) TileOverlay(title) if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) } + if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = if (row.cardKind == HomeCardKind.Poster) H.posterProgressBottom else H.progressBottom).fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) { + Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent)) + } HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth()) } } } } - if (!row.skeleton) { + if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) { val title = row.cards.getOrNull(column) - val captionModifier = Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(width) - if (title == null) { - if (row.cardKind == HomeCardKind.Poster) Caption("See all ${row.title}", "${row.total} titles", focused, captionModifier) - } else CardCaption(title.displayTitle, if (title.isEpisode) null else title.year, - title.takeIf { it.type == "movie" || it.isEpisode }?.runtimeSeconds?.takeIf { it > 0 }?.let { title.runtime }, - !row.catalogue && title.played && title.resumeSeconds <= 0, focused, captionModifier, - focusOnly = true, showTitle = row.cardKind == HomeCardKind.Poster) + val availability = when { + title == null -> null + row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" } + title.played -> "Watched" + title.resumeSeconds > 0 -> title.remaining + else -> null + } + val facts = remember(title, row.total, availability) { + (title?.facts ?: "${row.total} titles").replace(" · ", " · ") + availability?.let { " · $it" }.orEmpty() + } + Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), accentLastFact = availability != null, titleStyle = T.button, factsStyle = T.caption, titleOverflow = TextOverflow.Ellipsis) } } } @@ -85,11 +90,9 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In @Composable private fun TileOverlay(title: HomeTitle) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) { - val metadata = WolfskinTokens.CardMetadata - Column(Modifier.padding(bottom = metadata.bottom + metadata.separator + metadata.resumeLine).fillMaxWidth() - .background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))) - .padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) { - Text(title.displayTitle, modifier = Modifier.exactLineBox(metadata.titleFocus), color = C.fg, style = metadata.titleFocus, maxLines = 1, overflow = TextOverflow.Ellipsis) + Column(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))).padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) { + Text(title.displayTitle, modifier = Modifier.exactLineBox(T.button), color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(title.subtitle, modifier = Modifier.exactLineBox(T.caption), color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt index d4f09754..2b6bcc1a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt @@ -18,7 +18,6 @@ interface PosterContent { val poster: String? val ratings: List val progress: Float - val posterWatched: Boolean val badge: String? } @@ -41,15 +40,18 @@ data class LibraryTitle( ) : PosterContent { override val posterTitle: String get() = title override val posterIsMovie: Boolean get() = type == "movie" - override val posterWatched: Boolean get() = played - val duration: String? get() = if (type == "movie" || type == "episode") runtimeSeconds?.takeIf { it > 0 }?.let { - val minutes = (it + 30) / 60 - if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" - } else null val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT) val catalogueKey: String = "$type:${tmdbId ?: id}" val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#' val leadScore: Double get() = ratings.firstOrNull()?.let { if (it.source.startsWith("rt")) it.score else it.score * 10 } ?: -1.0 + + fun facts(catalogue: Boolean): String = buildList { + year?.let { add(it.toString()) } + add(if (type == "movie") runtimeSeconds?.let { val minutes = (it + 30) / 60; if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" } ?: "Movie" else seasons?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series") + if (catalogue) { if (availability.isNotEmpty()) add(availability) } + else if (played) add("Watched") + else if (progress > 0f && runtimeSeconds != null) add("${((runtimeSeconds * (1 - progress) + 30) / 60).toInt().coerceAtLeast(1)} min left") + }.joinToString(" · ") } /** Ordered rating entries are formatted after the active TV locale is known. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt index e3df4802..41e1e053 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt @@ -25,12 +25,9 @@ fun LibraryPoster( catalogue: Boolean, modifier: Modifier = Modifier, offlineArt: ((String) -> ImageBitmap?)? = null, - showWatchState: Boolean = !catalogue, ) { val poster = title.poster - val progress = if (showWatchState) title.progress else 0f - TitleCard(focused, modifier.testTag("poster-${title.id}"), watched = showWatchState && title.posterWatched, - progress = if (progress >= 1f) 0f else progress) { + TitleCard(focused, modifier.testTag("poster-${title.id}")) { val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } } if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) else if (poster != null && offlineArt == null) { @@ -42,6 +39,13 @@ fun LibraryPoster( onSuccess = { ready = true }, onError = { ready = false }) } else MediaArtworkFallback(title.posterTitle, title.posterIsMovie) if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) + val progress = title.progress + if (progress > 0f && progress < 1f) { + Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap) + .fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) { + Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape)) + } + } if (catalogue && title.badge != null) { val held = title.badge == "held" val color = if (title.badge == "failed") C.fail else C.accent diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 93c55c85..fa339d43 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -120,8 +120,8 @@ fun LibraryScreen( when (grid.status) { LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) { Box(Modifier.fillMaxWidth().height(rowHeight)) { - Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { - repeat(grid.columns) { PosterCaptionSkeleton() } + Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { + repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) } } } } @@ -133,13 +133,12 @@ fun LibraryScreen( "grid-${if (grid.catalogue) title.catalogueKey else title.id}" }, contentType = { "grid" }) { row -> Box(Modifier.fillMaxWidth().height(rowHeight)) { - Row(Modifier.padding(start = L.safeSide).rowReveal("grid-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { + Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { for (column in 0 until grid.columns) { val index = row * grid.columns + column val title = items.getOrNull(index) ?: break key(if (grid.catalogue) title.catalogueKey else title.id) { val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index - Box(Modifier.width(K.posterW)) { LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue, Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) { awaitPointerEventScope { @@ -151,13 +150,15 @@ fun LibraryScreen( } } }.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt) - CardCaption(title.title, if (title.type == "episode") null else title.year, title.duration, - !grid.catalogue && title.played && !(title.progress > 0f && title.progress < 1f), isFocused, - Modifier.offset(y = K.posterH + L.captionOffset).width(K.posterW)) - } } } } + if (active && !grid.toolbar && !grid.inRail && grid.index / grid.columns == row) { + val title = items.getOrNull(grid.index) + if (title != null) Caption(title.title, title.facts(grid.catalogue), true, + Modifier.offset(L.safeSide + K.posterPitch * (grid.index % grid.columns), K.posterH + L.captionOffset).width(G.captionWidth), + accentLastFact = if (grid.catalogue) title.availability.isNotEmpty() else title.played || title.progress > 0f) + } } } if (grid.tail != GridTail.None) item(key = "tail", contentType = "grid") { @@ -239,7 +240,7 @@ private fun LibraryTail(grid: LibraryGridState, focused: Boolean) { Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) { CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide)) Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused, - Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(K.posterW)) + Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt deleted file mode 100644 index e7607e04..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D - -/** Discover loads in its actual hero-and-lanes silhouette, not a library grid. */ -@Composable -internal fun DiscoverLoading(modifier: Modifier = Modifier) { - Column(modifier.fillMaxSize().testTag("discover-loading")) { - Box(Modifier.fillMaxWidth().height(L.row_phero)) { TitleHeaderSkeleton() } - Spacer(Modifier.height(L.row_chips)) - PosterSkeletonRow(WolfskinTokens.Discover.shelfHeadingBand) - } -} - -/** A person read reserves its portrait/header and the six-column filmography grid. */ -@Composable -internal fun PersonLoading(modifier: Modifier = Modifier) { - Column(modifier.fillMaxSize().testTag("person-loading")) { - Box(Modifier.fillMaxWidth().height(L.row_phead)) { - Row(Modifier.offset(L.safeSide, D.personTop).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(D.personGap)) { - Box(Modifier.size(L.personPortrait).background(C.surface2, CircleShape)) - Column(verticalArrangement = Arrangement.spacedBy(D.skeletonTitleGap)) { - SkeletonBlock(D.skeletonTitleWidth, D.skeletonTitleHeight) - SkeletonBlock(D.skeletonFactsWidth, D.skeletonKickerHeight) - SkeletonBlock(D.skeletonOverviewWidth, D.skeletonOverviewHeight) - } - } - } - PosterSkeletonRow(L.row_gh) - } -} - -/** All posters in a visible row share a single pulse layer. */ -@Composable -private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) { - Column { - Spacer(Modifier.height(heading)) - Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { - repeat(K.posterSlots) { PosterCaptionSkeleton() } - } - } -} - -@Composable -private fun SkeletonBlock(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp) { - Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) -} - -/** Loading reserves the ready caption band without painting unapproved text placeholders. */ -@Composable -internal fun PosterCaptionSkeleton(width: androidx.compose.ui.unit.Dp = K.posterW, - height: androidx.compose.ui.unit.Dp = K.posterH, band: androidx.compose.ui.unit.Dp = K.posterCaptionBand) { - Column(Modifier.width(width)) { - SkeletonBlock(width, height) - Spacer(Modifier.height(band)) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt deleted file mode 100644 index 111966df..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.MotionDurationScale -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M - -/** A host may force reduced motion independently of the platform animator scale. */ -val LocalReducedMotion = compositionLocalOf { false } - -/** Compose's window duration-scale state follows system settings without UI-thread settings reads. */ -@Composable -fun reducedMotion(): Boolean = LocalReducedMotion.current || - (rememberCoroutineScope().coroutineContext[MotionDurationScale]?.scaleFactor == 0f) - -/** Short fades remain available when movement and continuous animation are disabled. */ -fun motionDuration(duration: Int, reduced: Boolean): Int = if (reduced) minOf(duration, M.reducedFade) else duration - -@Stable -private class SkeletonPulseClock { - var subscribers by mutableIntStateOf(0) - val idle: State = mutableFloatStateOf(M.skeletonLowAlpha) - var alpha by mutableStateOf(idle) -} - -private val LocalSkeletonPulseClock = compositionLocalOf { - error("Skeleton loading requires WolfskinTheme") -} - -/** Share phase across shelves, including late arrivals; stop the clock when no shelf needs it. */ -@Composable -internal fun SkeletonPulseScope(content: @Composable () -> Unit) { - val clock = remember { SkeletonPulseClock() } - val reduced = reducedMotion() - val alpha = if (clock.subscribers > 0 && !reduced) { - rememberInfiniteTransition(label = "skeleton-screen").animateFloat(M.skeletonLowAlpha, 1f, - infiniteRepeatable(tween(M.skeletonCycle / 2, easing = M.breatheEase), RepeatMode.Reverse), label = "breathe") - } else clock.idle - SideEffect { clock.alpha = alpha } - CompositionLocalProvider(LocalSkeletonPulseClock provides clock, content = content) -} - -/** One alpha layer owns all shapes in a shelf; individual placeholders never animate. */ -@Composable -fun Modifier.skeletonPulse(loading: Boolean = true): Modifier { - if (!loading || reducedMotion()) return this - val clock = LocalSkeletonPulseClock.current - DisposableEffect(clock) { - clock.subscribers++ - onDispose { clock.subscribers-- } - } - return graphicsLayer { alpha = clock.alpha.value } -} - -/** Content enters on its row layer, never one animation per card or a full-screen transition. */ -@Composable -fun Modifier.rowReveal(identity: Any, enabled: Boolean = true): Modifier { - val reduced = reducedMotion() - val progress = remember(identity, enabled) { Animatable(if (enabled) 0f else 1f) } - LaunchedEffect(progress, reduced) { if (enabled) progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) } - val rise = with(LocalDensity.current) { M.rowRise.toPx() } - return if (!enabled) this else graphicsLayer { - alpha = progress.value - translationY = if (reduced) 0f else rise * (1f - progress.value) - } -} - -/** Late facts occupy an existing slot and fade without a translation. */ -@Composable -internal fun Modifier.lateLineFade(identity: Any): Modifier { - val reduced = reducedMotion() - val alpha = remember(identity) { Animatable(0f) } - LaunchedEffect(alpha, reduced) { alpha.animateTo(1f, tween(motionDuration(M.lateLineFade, reduced), easing = M.ease)) } - return graphicsLayer { this.alpha = alpha.value } -} - -/** Title copy enters as one bounded block, leaving measured layout unchanged. */ -@Composable -internal fun Modifier.copyEntry(identity: Any): Modifier { - val reduced = reducedMotion() - val progress = remember(identity) { Animatable(0f) } - val rise = with(LocalDensity.current) { M.copyEntryRise.toPx() } - LaunchedEffect(progress, reduced) { progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) } - return graphicsLayer { alpha = progress.value; translationY = if (reduced) 0f else rise * (1f - progress.value) } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index 40b8563c..f88d4458 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -58,20 +58,18 @@ fun navBoxes(tabs: List): List { @Composable fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) { val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) } - val reduced = reducedMotion() - val labelFade = tween(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f)) + val labelFade = remember { tween(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) } val index = if (state.navFocused) state.navIndex else state.currentTab val box = boxes[index] val avatarFocused = state.navFocused && index == state.tabs.size val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero val damping = WolfskinTokens.Motion.navBlobDampingRatio val stiffness = WolfskinTokens.Motion.navBlobStiffness - val geometry = if (reduced) tween(0) else spring(damping, stiffness) - val x by animateFloatAsState((box.left - pad).value, geometry, label = "blob-x") - val y by animateFloatAsState((box.top - pad).value, geometry, label = "blob-y") - val width by animateFloatAsState((box.width + pad * 2).value, geometry, label = "blob-width") - val height by animateFloatAsState((box.height + pad * 2).value, geometry, label = "blob-height") - val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, if (reduced) tween(motionDuration(WolfskinTokens.Motion.base, true)) else spring(damping, stiffness), label = "blob-color") + val x by animateFloatAsState((box.left - pad).value, spring(damping, stiffness), label = "blob-x") + val y by animateFloatAsState((box.top - pad).value, spring(damping, stiffness), label = "blob-y") + val width by animateFloatAsState((box.width + pad * 2).value, spring(damping, stiffness), label = "blob-width") + val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height") + val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color") val density = LocalDensity.current.density val band = remember { Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f))) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NetworkArtTransition.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NetworkArtTransition.kt deleted file mode 100644 index 920b30bc..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NetworkArtTransition.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.justinvisser.wolfskin.design - -import coil3.decode.DataSource -import coil3.request.ImageResult -import coil3.request.SuccessResult -import coil3.transition.CrossfadeTransition -import coil3.transition.Transition -import coil3.transition.TransitionTarget - -/** Both Coil loaders share the same source-aware reveal; cache and local reads never fade. */ -object NetworkArtTransition : Transition.Factory { - @Volatile var reduced = false - - /** The result source, not its URL, decides whether art needs a reveal. */ - fun duration(source: DataSource, reduced: Boolean): Int = if (source == DataSource.NETWORK) - motionDuration(WolfskinTokens.Motion.artReveal, reduced) else 0 - - override fun create(target: TransitionTarget, result: ImageResult): Transition { - val duration = if (result is SuccessResult) duration(result.dataSource, reduced) else 0 - return if (duration == 0) Transition.Factory.NONE.create(target, result) - else CrossfadeTransition(target, result, duration) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index 7da2fba8..ea95f24b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -148,7 +148,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { row.forEachIndexed { column, item -> val index = rowIndex * columns + column - TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), alwaysShowCaption = true, onFocus = { state.index = index }) { state.click(index) } + TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt index 08c79cd8..d206ccc4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -1,5 +1,6 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.background @@ -62,7 +63,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1) val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset - list.animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) + list.animateScrollBy(distance, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) } LazyColumn(modifier.fillMaxSize(), state = list, contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt index 614caff7..fc84ed7f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt @@ -1,5 +1,6 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.lazy.LazyListState @@ -15,7 +16,7 @@ internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int /** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */ internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) { val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset - if (distance != 0f) animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.base, - easing = WolfskinTokens.Motion.ease)) + if (distance != 0f) animateScrollBy(distance, tween(WolfskinTokens.Motion.base, + easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt index 026d0a14..018e4a4b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt @@ -44,7 +44,7 @@ fun TitleRatings(ratings: List, modifier: Modifier = Modifier, st val numberStyle = remember(textStyle) { textStyle.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) } val gap = if (plate) with(density) { (style.fontSize * P.plateGapEm).toDp() } else D.ratingGap val markGap = if (plate) with(density) { (style.fontSize * P.markGapEm).toDp() } else D.ratingMarkGap - Row(if (plate) modifier else modifier.lateLineFade(ratings), horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) { + Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) { ratings.forEach { rating -> Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) { val resource = when (rating.source) { @@ -70,7 +70,7 @@ fun LibraryRatingPlate(ratings: List, modifier: Modifier = Modifi if (ratings.isEmpty()) return val measurer = rememberTextMeasurer() val density = LocalDensity.current - val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())), label = "rating-plate") + val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate") BoxWithConstraints(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha } .background(Brush.verticalGradient(0f to C.bg.copy(alpha = P.plateOpacity), P.plateMidStop to C.bg.copy(alpha = P.plateMidOpacity), 1f to C.bg.copy(alpha = 0f))) .testTag("rating-plate").padding(horizontal = P.plateSide)) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt index 3a3efd9b..cc2696d6 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt @@ -43,23 +43,18 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D /** Local decoded fixture art keeps screenshot tests deterministic and completely offline. */ val LocalTitleBitmaps = compositionLocalOf> { emptyMap() } -/** Decode at the slot size; network results reveal through Coil's painter without a new layer. */ +/** Decode at the slot size, with no crossfade or oversized poster allocations. */ @Composable fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) { val bitmap = LocalTitleBitmaps.current[path] - val heroReady = LocalHeroArtReady.current - if (bitmap != null) { - androidx.compose.runtime.SideEffect { heroReady?.invoke() } - Image(bitmap, null, modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter) - } + if (bitmap != null) Image(bitmap, null, modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter) else if (path != null) { val context = LocalContext.current val request = remember(context, path, width, height, poster) { WolfskinImages.request(context, path, width, height, poster) } AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), - contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, - onSuccess = { heroReady?.invoke() }, onError = { onError() }) + contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() }) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt index ea760054..fd581f12 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt @@ -9,51 +9,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp -// Precompose before packing into Color: packing first loses the browser's truncated channel fractions. -private val opaqueProgressTrack = WolfskinTokens.Color.progressTrack.let { track -> - val backing = WolfskinTokens.Color.bg - val inverse = 1f - track.alpha - Color( - ((track.red * track.alpha + backing.red * inverse) * 255).toInt(), - ((track.green * track.alpha + backing.green * inverse) * 255).toInt(), - ((track.blue * track.alpha + backing.blue * inverse) * 255).toInt(), - ) -} - /** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */ @Composable fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW, - height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f, - content: @Composable BoxScope.() -> Unit) { + height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) { val shape = RoundedCornerShape(WolfskinTokens.Radius.card) Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) { - Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2) - .drawWithCache { - val edge = WolfskinTokens.CardMetadata.bottom.toPx() - val fill = when { - progress > 0f -> WolfskinTokens.CardMetadata.resumeLine.toPx() - watched -> WolfskinTokens.CardMetadata.watchedLine.toPx() - else -> 0f - } - val backing = edge + if (fill > 0f) fill + WolfskinTokens.CardMetadata.separator.toPx() else 0f - onDrawWithContent { - clipRect(bottom = size.height - backing) { this@onDrawWithContent.drawContent() } - drawRect(WolfskinTokens.Color.bg, Offset(0f, size.height - backing), Size(size.width, backing)) - if (fill > 0f) { - val top = Offset(0f, size.height - edge - fill) - if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill)) - drawRect(WolfskinTokens.Color.accent, top, - Size(size.width * if (progress > 0f) progress.coerceIn(0f, 1f) else 1f, fill)) - } - } - }, content = content) + Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt index 5b2a7c47..b5d35264 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -41,7 +42,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie else -> state.data.playTarget?.takeIf { it.kind == "episode" } } val type = if (item.kind == "movie") "Movie" else "Series" - CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth).copyEntry(item.id)) { + CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) { Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon)) Text(buildString { @@ -57,19 +58,17 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings) - if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres)) + if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) } - Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) { - state.data.requestedStage?.let { stage -> - val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted } - Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { - Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon)) - Text(stage, color = color, style = TitleTextStyles.label) - Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine) - } + state.data.requestedStage?.let { stage -> + val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted } + Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { + Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon)) + Text(stage, color = color, style = TitleTextStyles.label) + Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine) } } - Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).lateLineFade((episode ?: item).overview).testTag("title-overview")) + Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview")) if (!item.held && state.data.connection != TitleConnection.SignedIn) { Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.", color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap)) @@ -106,18 +105,18 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie /** A no-copy load has no focusable content and never draws a spinner. */ @Composable fun TitleHeaderSkeleton(modifier: Modifier = Modifier) { - Column(modifier.offset(L.safeSide, L.detailCopyTop).skeletonPulse()) { - Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.cardSheet))) + Column(modifier.offset(L.safeSide, L.detailCopyTop)) { + Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet))) Spacer(Modifier.height(D.skeletonTitleGap)) - Box(Modifier.size(D.skeletonTitleWidth, D.skeletonTitleHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) + Box(Modifier.size(D.skeletonTitleWidth, D.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) Spacer(Modifier.height(D.skeletonFactsGap)) - Box(Modifier.size(D.skeletonFactsWidth, D.skeletonKickerHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.cardSheet))) + Box(Modifier.size(D.skeletonFactsWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet))) Spacer(Modifier.height(D.skeletonOverviewGap)) - Box(Modifier.size(D.skeletonOverviewWidth, D.skeletonOverviewHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) + Box(Modifier.size(D.skeletonOverviewWidth, D.skeletonOverviewHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) Spacer(Modifier.height(D.overviewGap)) Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.button))) - repeat(2) { Box(Modifier.size(L.iconButton).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.button))) } + Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) + repeat(2) { Box(Modifier.size(L.iconButton).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt index 1fb71dd9..4630ce75 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt @@ -30,7 +30,6 @@ data class TitleItem( ) : PosterContent { override val posterTitle: String get() = name override val posterIsMovie: Boolean get() = kind == "movie" - override val posterWatched: Boolean get() = watched override val progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f override val badge: String? get() = if (held) "held" else null val episodeLabel: String get() = "S$season E$episode" diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index cefe5064..c683c8e7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -30,7 +30,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.animation.core.tween -import androidx.compose.ui.draw.drawBehind +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.focus.FocusRequester @@ -146,9 +147,8 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie }.focusable()) { // This remains visible behind the scrolled rows; it is not an off-screen header image. TitleBackdrop(state.data.item, Modifier.fillMaxSize()) - Box(Modifier.fillMaxSize().drawBehind { drawRect(C.bg.copy(alpha = dim)) }) + Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg)) if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state) - else if (state.data.status == TitleReadStatus.Loading && state.data.item.kind == "person") PersonLoading() else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) { displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") { val active = state.row == id && !shell.navFocused && state.sheet == null @@ -185,9 +185,7 @@ private fun TitleSeasons(state: TitleState, active: Boolean) { val list = androidx.compose.foundation.lazy.rememberLazyListState() val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season) LaunchedEffect(visibleChip, state.data.seasonNumbers) { - if (visibleChip >= 0) { - if (NetworkArtTransition.reduced) list.scrollToItem(visibleChip) else list.animateScrollToItem(visibleChip) - } + if (visibleChip >= 0) list.animateScrollToItem(visibleChip) } 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)) { items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index -> @@ -222,4 +220,4 @@ private fun TitleReadError(state: TitleState) { } } -internal val titleFrameScrollSpec get() = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut) +internal val titleFrameScrollSpec = tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index 429a38ce..6d7752ae 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.core.tween import androidx.compose.ui.platform.LocalDensity +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -53,11 +54,11 @@ fun TitleCast(state: TitleState, active: Boolean) { } Box(Modifier.height(D.castHeight)) { Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) - LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading), + LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) { if (state.data.castStatus == TitleReadStatus.Loading) { items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) { - Box(Modifier.size(L.castCircle).background(C.surface2, CircleShape)) + Box(Modifier.size(L.castCircle).background(C.surface, CircleShape)) } } else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person -> val focused = active && state.column == index @@ -88,14 +89,14 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { LaunchedEffect(state.column, active, titles.size) { if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size) } - Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand).testTag("title-shelf:$id")) { + Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) { Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter) - .height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading), + .height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) { items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { - Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) + Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) } } else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item -> TitlePoster(item, active && state.column == index, Modifier.width(width) @@ -106,36 +107,53 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { } } -/** Shelf captions are focus-only; filmography keeps card-width metadata visible at rest. */ +/** Posters draw art at rest, with facts only in the reserved focused-caption band. */ @Composable -fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, alwaysShowCaption: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { +fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { val focusOwner = LocalTitleFocusOwner.current Column(modifier.zIndex(if (focused) 1f else 0f)) { if (!tile) { val bitmaps = LocalTitleBitmaps.current val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } } - LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true, + LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) - } else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, - WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched, - progress = item.progress.takeIf { it < 1f } ?: 0f) { + } else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) + .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { TitleTileArtwork(item) Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis) - if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0) } - androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) { + val progress = item.progress + if (progress > 0f && progress < 1f) { + Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = D.tileProgressBottom) + .fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) { + Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent)) + } + } + androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) { LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth()) } } if (!tile) { - Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand).width(WolfskinTokens.Card.posterW)) { - CardCaption(item.name, item.year, if (item.kind == "movie" && item.runtimeSeconds > 0) item.runtime else null, - item.watched && item.resumeSeconds <= 0, focused, - Modifier.offset(y = L.captionOffset).width(WolfskinTokens.Card.posterW), focusOnly = !alwaysShowCaption) + val availability = androidx.compose.runtime.remember(item, catalogue) { + if (catalogue) { + if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" } + } else if (item.watched) "Watched" + else if (item.resumeSeconds > 0) "${((item.runtimeSeconds - item.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left" + else null + } + val facts = androidx.compose.runtime.remember(item, availability) { + listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), + if (item.kind == "movie") { if (item.runtimeSeconds > 0) item.runtime else "Movie" } else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", + availability).joinToString(" · ") + } + Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) { + Caption(item.name, facts, focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth), + accentLastFact = availability != null, accentFirstFact = !item.character.isNullOrBlank()) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt index 559ebd02..154c44a3 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt @@ -34,8 +34,6 @@ fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height val source = if (landscape) backdrop ?: poster else poster val failed = remember(source) { androidx.compose.runtime.mutableStateOf(false) } val missing = source == null || failed.value - val heroReady = LocalHeroArtReady.current - if (missing) androidx.compose.runtime.SideEffect { heroReady?.invoke() } Box(modifier.background(C.surface2)) { if (!missing) { if (landscape && (posterOnly || backdrop == null) && poster != null) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt index 9a7718f8..349fd2d2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt @@ -1,15 +1,13 @@ package org.justinvisser.wolfskin.design import android.content.Context -import android.graphics.ImageDecoder -import android.os.Build import android.net.Uri import coil3.ComponentRegistry import coil3.ImageLoader import coil3.disk.DiskCache import coil3.intercept.Interceptor import coil3.disk.directory -import coil3.request.transitionFactory +import coil3.request.crossfade import coil3.memory.MemoryCache import coil3.request.ImageRequest import coil3.request.allowRgb565 @@ -25,8 +23,6 @@ object WolfskinImages { private var networkConfiguration: (ComponentRegistry.Builder.() -> Unit)? = null private const val MEBIBYTE = 1024L * 1024L private const val IMAGE_READS = 4 - private const val CARD_QUALITY = "80" - private const val HERO_QUALITY = "96" private val readPermits = Semaphore(IMAGE_READS) /** The app supplies authenticated networking without exposing its server/session to design code. */ @@ -61,7 +57,7 @@ object WolfskinImages { networkConfiguration?.invoke(this) add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } }) } - .fetcherCoroutineContext(reads).decoderCoroutineContext(reads).transitionFactory(NetworkArtTransition).build() + .fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build() } return loader!! } @@ -82,34 +78,32 @@ object WolfskinImages { private val jellyfinImagePath = Regex("/Items/[^/]+/Images/[^/]+(?:/\\d+)?", RegexOption.IGNORE_CASE) private val tmdbSizePath = Regex("/t/p/(?:w\\d+|original)/") - private val imageParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight", "quality", "format") - // Lossy WebP is mandatory from API 14; minSdk is 23. Newer platforms expose a decoder capability query. - private val supportsWebp by lazy { Build.VERSION.SDK_INT < 28 || ImageDecoder.isMimeTypeSupported("image/webp") } + private val dimensionParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight") private fun sourceForSize(source: Any, width: Int, height: Int): Any { if (source !is String) return source val uri = Uri.parse(source) if (uri.scheme != "https" && uri.scheme != "http") return source if (uri.host == "image.tmdb.org") { - val size = when { - height >= width -> "w342" - width > WolfskinTokens.Image.wideRequestWidth -> "w1280" - else -> "w780" + val size = if (height > width) when { + width <= 500 -> "w500" + width <= 780 -> "w780" + else -> "original" + } else when { + width <= 780 -> "w780" + width <= 1280 -> "w1280" + else -> "original" } return uri.buildUpon().path(uri.path?.replace(tmdbSizePath, "/t/p/$size/")).build().toString() } if (!jellyfinImagePath.matches(uri.path.orEmpty())) return source - val hero = width == WolfskinTokens.Image.heroRequestWidth && height == WolfskinTokens.Image.heroRequestHeight || - width == WolfskinTokens.Image.heroPosterRequestWidth && height == WolfskinTokens.Image.heroPosterRequestHeight val builder = uri.buildUpon().clearQuery() for (name in uri.queryParameterNames) { - if (name.lowercase() !in imageParameters) { + if (name.lowercase() !in dimensionParameters) { uri.getQueryParameters(name).forEach { value -> builder.appendQueryParameter(name, value) } } } return builder.appendQueryParameter("fillWidth", width.toString()) - .appendQueryParameter("fillHeight", height.toString()) - .appendQueryParameter("format", if (hero || !supportsWebp) "Jpg" else "Webp") - .appendQueryParameter("quality", if (hero) HERO_QUALITY else CARD_QUALITY).build().toString() + .appendQueryParameter("fillHeight", height.toString()).build().toString() } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt index 5a1fbe37..d3e155a8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt @@ -35,11 +35,7 @@ object WolfskinColors { /** Upstream content inherits tokens without a fork-specific theme enum. */ @Composable fun WolfskinTheme(content: @Composable () -> Unit) { - val reduced = reducedMotion() - androidx.compose.runtime.SideEffect { NetworkArtTransition.reduced = reduced } androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) { - MaterialTheme(colorScheme = WolfskinColors.darkScheme) { - SkeletonPulseScope(content) - } + MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 6c014755..fa730cd2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -1,7 +1,6 @@ // Generated by generate_tokens.py; edit the JSON sources instead. package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.ui.graphics.Color as ComposeColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -11,20 +10,6 @@ import androidx.compose.ui.unit.sp /** The locked TV design, converted at the source density. */ object WolfskinTokens { - object CardMetadata { - val titleRest = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(500), lineHeight = 16.0.sp, letterSpacing = 0.0.em) - val titleFocus = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.0.sp, letterSpacing = 0.0.em) - val subtitle = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 13.0.sp, letterSpacing = 0.0.em) - val check = 12.0.dp - val gap = 6.0.dp - val compactGap = 4.0.dp - val minimumGap = 2.0.dp - val bottom = 1.0.dp - val separator = 1.0.dp - val watchedLine = 1.5.dp - val resumeLine = 3.0.dp - val landscapeBand = 32.0.dp - } object Color { val bg = ComposeColor(0xFF07070A) val surface = ComposeColor(0xFF13131A) @@ -187,11 +172,13 @@ object WolfskinTokens { val tileH = 112.5.dp val tileGap = 20.0.dp val tilePitch = 220.0.dp + val tileCaptionBand = 0.0.dp const val tileSlots = 4 val wideW = 272.0.dp val wideH = 153.0.dp val wideGap = 20.0.dp val widePitch = 292.0.dp + val wideCaptionBand = 0.0.dp const val wideSlots = 3 val entryW = 128.0.dp val entryH = 72.0.dp @@ -221,43 +208,22 @@ object WolfskinTokens { const val fillInverse = "background color.fg, text color.onFocus" } object Motion { - const val skeletonCycle = 1400 - const val skeletonLowAlpha = 0.55f - const val focusMove = 180 - const val captionDelay = 60 - const val captionOut = 100 - const val artReveal = 160 - const val heroFade = 320 - const val heroCopyOut = 120 - const val heroCopyDelay = 140 - const val heroCopyIn = 200 - const val rowEnter = 180 - const val lateLineFade = 200 - val copyEntryRise = 8.0.dp - const val reducedFade = 80 - val rowRise = 12.0.dp - val captionRise = 5.0.dp - val copyRise = 7.0.dp - val readinessSlot = 28.0.dp - val ease = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) - val easeOut = CubicBezierEasing(0.33f, 1.0f, 0.68f, 1.0f) - val easeInOut = CubicBezierEasing(0.65f, 0.0f, 0.35f, 1.0f) - val breatheEase = CubicBezierEasing(0.42f, 0.0f, 0.58f, 1.0f) + const val fast = 140 const val base = 160 const val scroll = 180 const val navBlobDampingRatio = 1.0f const val navBlobStiffness = 650.0f const val ratingDwell = 350 const val toast = 3200 - const val reducedMotion = "no pulse or movement; fades at most 80ms" + const val reducedMotion = "all transitions instant" } object Image { const val posterRequestWidth = 256 const val posterRequestHeight = 384 - const val tileRequestWidth = 400 - const val tileRequestHeight = 225 - const val wideRequestWidth = 544 - const val wideRequestHeight = 306 + const val tileRequestWidth = 416 + const val tileRequestHeight = 234 + const val wideRequestWidth = 560 + const val wideRequestHeight = 315 const val heroRequestWidth = 1920 const val heroRequestHeight = 1080 const val heroPosterRequestWidth = 420 @@ -391,6 +357,9 @@ object WolfskinTokens { val chipGap = 8.0.dp val chipTop = 8.0.dp val chipIcon = 13.0.dp + val captionWidth = 320.0.dp + const val captionSeparatorOpacity = 0.4f + val captionFactsGap = 9.0.dp val badgeIcon = 14.0.dp val plateTop = 7.0.dp val plateSide = 8.0.dp @@ -474,6 +443,7 @@ object WolfskinTokens { val personGap = 24.0.dp val personNameBottom = 5.0.dp val personNoteBottom = 3.0.dp + val captionWidth = 320.0.dp val sheetPaddingTop = 40.0.dp val sheetPaddingSide = 32.0.dp val sheetGap = 9.0.dp @@ -560,6 +530,8 @@ object WolfskinTokens { val tileOverlaySide = 11.0.dp val tileOverlayBottom = 9.0.dp const val cardOverlayOpacity = 0.92f + val cardProgressInset = 9.0.dp + val tileProgressBottom = 5.0.dp val ambientInset = 40.0.dp const val ambientScale = 1.1f val posterShadowRadius = 40.0.dp @@ -597,6 +569,7 @@ object WolfskinTokens { val statePaddingY = 17.0.dp val stateGap = 4.0.dp val stateIcon = 18.0.dp + val captionWidth = 320.0.dp val plateTop = 7.0.dp val plateSide = 8.0.dp val plateFade = 23.0.dp @@ -617,6 +590,8 @@ object WolfskinTokens { val heroSheetTop = 75.0.dp val cardBlur = 13.0.dp val heroBlur = 30.0.dp + val progressInset = 9.0.dp + val progressBottom = 5.0.dp val endTop = 20.0.dp val frameBottom = 40.0.dp val toastPadding = 14.0.dp @@ -637,6 +612,7 @@ object WolfskinTokens { val savedGap = 5.0.dp val savedIcon = 14.0.dp val savedRadius = 14.0.dp + val posterProgressBottom = 6.0.dp } object Discover { val shelfHeadingBand = 52.0.dp diff --git a/wolfskin-design/src/main/res/drawable/card_watched_check.xml b/wolfskin-design/src/main/res/drawable/card_watched_check.xml deleted file mode 100644 index 1dd41eb0..00000000 --- a/wolfskin-design/src/main/res/drawable/card_watched_check.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardBottomPixelTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardBottomPixelTest.kt deleted file mode 100644 index 5bbc98ce..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardBottomPixelTest.kt +++ /dev/null @@ -1,135 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.graphics.BitmapFactory -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Real white, dark and colourful art cannot leak through the card's opaque bottom edge. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class CardBottomPixelTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun realPosterBottomRowsStayOpaqueAtRestAndFocused() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - val focused = mutableStateOf(-1) - val showArt = mutableStateOf(true) - val state = mutableStateOf("unwatched") - val names = listOf("annie-hall-1977", "locke-2014", "apocalypse-now-1979", "avatar-aang-the-last-airbender-2026") - rule.setContent { WolfskinTheme { - Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { - names.forEachIndexed { index, name -> - LibraryPoster(LibraryTitle(name, name, "movie", poster = if (showArt.value) "/library/assets/movie-$name-poster.jpg" else null, - played = state.value == "watched", progress = if (state.value == "resume") .43f else 0f), - focused.value == index, false, false, Modifier.offset((48 + 148 * index).dp, 130.dp), ::fixtureArt) - } - } - } } - val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() } - for (watchState in listOf("unwatched", "watched", "resume")) for (selection in -1..3) { - rule.runOnIdle { state.value = watchState; focused.value = selection; showArt.value = false } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val referenceFile = File(directory, "reference.png") - rule.onRoot().captureRoboImage(referenceFile.absolutePath) - val reference = BitmapFactory.decodeFile(referenceFile.absolutePath) - rule.runOnIdle { showArt.value = true } - rule.waitForIdle() - val file = File(directory, "$watchState-focus-$selection.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - names.forEachIndexed { index, name -> - val focus = selection == index - val left = 96 + 296 * index - val bottom = if (focus) 659 else 643 - val edgeLeft = if (focus) left - 10 else left - val edgeRight = if (focus) left + 266 else left + 256 - // The reference supplies only rounded-corner/ring coverage, never poster colours. - for (x in edgeLeft until edgeRight) assertEquals("$name $watchState focus=$focus full bottom row ($x,$bottom)", - reference.getPixel(x, bottom), bitmap.getPixel(x, bottom)) - val expected = if (focus) WolfskinTokens.Color.accent.toArgb() else WolfskinTokens.Color.bg.toArgb() - for (x in left + 32 until left + 224) assertEquals("$name $watchState focus=$focus straight edge ($x,$bottom)", - expected, bitmap.getPixel(x, bottom)) - if (watchState != "unwatched") { - val fillY = if (focus) 655 else 640 - assertEquals("$name $watchState fill", WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(left + 32, fillY)) - val right = if (watchState == "resume") androidx.compose.ui.graphics.Color(0xFF3D3D3F) else WolfskinTokens.Color.accent - assertEquals("$name $watchState track", right.toArgb(), bitmap.getPixel(left + 224, fillY)) - } - } - reference.recycle() - bitmap.recycle() - } - } - - @Test fun completedHomeProgressUsesTheSameWatchedStripAsLibraryCards() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - val path = "/library/assets/movie-annie-hall-1977-poster.jpg" - val fraction = mutableStateOf(.5f) - val art = mapOf(path to fixtureArt(path)!!) - val memory = HomeViewMemory() - rule.setContent { WolfskinTheme { - androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) { - Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { - val title = HomeTitle("home", "Home film", "movie", runtimeSeconds = 100, - poster = path, played = true, resumeSeconds = fraction.value * 100.0) - HomeShelf(HomeRow("progress", HomeRowKind.Shelf, "Progress", listOf(title)), - memory, false, 0, emptyList(), {}, {}) - LibraryPoster(LibraryTitle("reference", "Reference", "movie", poster = path, - played = true, progress = fraction.value), false, false, false, - Modifier.offset(240.dp, 42.dp), ::fixtureArt) - } - } - } } - val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() } - for (progress in listOf(.5f, 1f, 1.5f)) { - rule.runOnIdle { fraction.value = progress } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val home = rule.onNodeWithTag("home-card:progress:0").fetchSemanticsNode().boundsInRoot - val reference = rule.onNodeWithTag("poster-reference").fetchSemanticsNode().boundsInRoot - assertEquals(reference.width, home.width, .1f) - assertEquals(reference.bottom, home.bottom, .1f) - val file = File(directory, "home-progress-${(progress * 100).toInt()}.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - try { - val bottom = home.bottom.toInt() - val band = if (progress >= 1f) 7 else 10 - for (y in bottom - band until bottom) for (x in 0 until home.width.toInt()) { - assertEquals("Home/library strip progress=$progress ($x,$y)", - bitmap.getPixel(reference.left.toInt() + x, y), bitmap.getPixel(home.left.toInt() + x, y)) - } - val separator = if (progress >= 1f) WolfskinTokens.Color.bg else WolfskinTokens.Color.accent - assertEquals("Completion changes the resume strip to the 3px watched strip", - separator.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 7)) - assertEquals(WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 4)) - } finally { bitmap.recycle() } - } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardCaptionMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardCaptionMotionTest.kt deleted file mode 100644 index ee5b9d57..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardCaptionMotionTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.graphics.BitmapFactory -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Changing a shelf's selected identity must restart its focus-only caption reveal. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class CardCaptionMotionTest { - @get:Rule val rule = createAndroidComposeRule() - - @Before fun fullscreen() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - rule.mainClock.autoAdvance = false - } - - @Test fun changingTitleWhileStillFocusedRestoresOpaqueCaptionPixels() { - val title = mutableStateOf("Initial caption") - rule.setContent { WolfskinTheme { - Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { - CardCaption(title.value, 2007, "2h 56m", true, true, - Modifier.offset(40.dp, 80.dp).width(128.dp).testTag("animated"), focusOnly = true) - CardCaption(title.value, 2007, "2h 56m", true, true, - Modifier.offset(40.dp, 180.dp).width(128.dp).testTag("reference")) - } - } } - for ((index, selected) in listOf("Initial caption", "Second caption", "Third caption").withIndex()) { - update { title.value = selected } - rule.mainClock.advanceTimeBy(400) - assertCaptionPixels("component-$index", - rule.onNodeWithTag("animated").fetchSemanticsNode().boundsInRoot, - rule.onNodeWithTag("reference").fetchSemanticsNode().boundsInRoot) - } - } - - @Test fun movingAcrossAFocusedHomeShelfKeepsEachSelectedCaptionVisible() { - val path = "/library/assets/movie-annie-hall-1977-poster.jpg" - val cards = listOf(HomeTitle("first", "First shelf caption", "movie", 2007, 10560, poster = path), - HomeTitle("second", "Second shelf caption", "movie", 2008, 11520, poster = path)) - val row = HomeRow("captions", HomeRowKind.Shelf, "Shelf", cards) - val column = mutableStateOf(0) - val memory = HomeViewMemory() - val art = mapOf(path to fixtureArt(path)!!) - rule.setContent { WolfskinTheme { - CompositionLocalProvider(LocalTitleBitmaps provides art) { - Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { - Box(Modifier.testTag("home-shelf")) { - HomeShelf(row, memory, true, column.value, emptyList(), {}, {}) - } - val selected = cards[column.value] - CardCaption(selected.title, selected.year, selected.runtime, false, true, - Modifier.offset(48.dp, 400.dp).width(128.dp).testTag("reference")) - } - } - } } - for ((step, selected) in listOf(0, 1, 0).withIndex()) { - update { column.value = selected } - rule.mainClock.advanceTimeBy(400) - val title = cards[selected].title - val actual = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("home-shelf")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - val reference = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("reference")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - assertCaptionPixels("home-$step", actual, reference) - } - } - - private fun update(block: () -> Unit) { - rule.runOnIdle { block(); Snapshot.sendApplyNotifications() } - rule.mainClock.advanceTimeByFrame() - } - - private fun assertCaptionPixels(name: String, actual: Rect, reference: Rect) { - assertEquals(reference.width, actual.width, .1f) - assertEquals(reference.height, actual.height, .1f) - rule.waitForIdle() - val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() } - val file = File(directory, "$name.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - try { - val foreground = WolfskinTokens.Color.fg.toArgb() - var compared = 0 - for (y in 0 until reference.height.toInt()) for (x in 0 until reference.width.toInt()) { - if (bitmap.getPixel(reference.left.toInt() + x, reference.top.toInt() + y) == foreground) { - assertEquals("$name opaque title pixel ($x,$y)", foreground, - bitmap.getPixel(actual.left.toInt() + x, actual.top.toInt() + y)) - compared++ - } - } - assertTrue("The reference must contain opaque title glyphs", compared >= 24) - } finally { bitmap.recycle() } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardGeometryTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardGeometryTest.kt deleted file mode 100644 index be217089..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardGeometryTest.kt +++ /dev/null @@ -1,169 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.graphics.BitmapFactory -import androidx.compose.foundation.background -import androidx.compose.ui.graphics.toArgb -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.unit.dp -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Caption fitting and row pitches are measured from the same surfaces used by the TV. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class CardGeometryTest { - @get:Rule val rule = createComposeRule() - - @Test fun narrowCaptionsEllipsiseOnlyTheTitleAndKeepRuntimeAndCheck() { - val width = mutableStateOf(105.dp) - rule.setContent { WolfskinTheme { - CardCaption("Steins;Gate: The Movie - Load Region of Déjà Vu", 2007, "999h 59m", true, true, - Modifier.width(width.value).testTag("caption")) - } } - for (pixels in listOf(210, 327)) { - rule.runOnIdle { width.value = (pixels / 2f).dp } - rule.waitForIdle() - val layouts = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().map { node -> - val results = mutableListOf() - node.config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results) - results.single() - } - val title = layouts.single { it.layoutInput.text.text.startsWith("Steins;") } - assertEquals(1, title.lineCount) - assertTrue("Only the long title ellipsises at $pixels", title.isLineEllipsized(0)) - for (facts in layouts.filter { it !== title }) { - assertFalse("Metadata must not overflow at $pixels", facts.hasVisualOverflow) - assertFalse(facts.isLineEllipsized(0)) - } - val caption = rule.onNodeWithTag("caption").fetchSemanticsNode().boundsInRoot - assertEquals(58f, caption.height, .1f) - assertEquals(pixels.toFloat(), caption.width, .1f) - val check = rule.onNodeWithContentDescription("Watched").fetchSemanticsNode().boundsInRoot - assertEquals(24f, check.width, .1f) - assertEquals(24f, check.height, .1f) - assertTrue(check.right <= caption.right) - } - } - - @Test fun unfocusedLibraryTitlesKeepCardWidthAndGridPitch() { - val titles = List(12) { LibraryTitle("$it", "A long title occupying only one card column ${it.toString().padStart(2, '0')}", "movie", 2007, 10560, poster = "/library/assets/movie-annie-hall-1977-poster.jpg") } - val shell = ShellState(fixturePeople(), "shared").apply { pickerVisible = false; enterDestination("movies") } - val grid = LibraryGridState("Movies", titles) - rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it, offlineArt = ::fixtureArt) } } } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val first = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot - val second = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot - // Focus scales around the unchanged slot centre. - assertEquals(372f, first.top + (first.height - 384f) / 2, .1f) - assertEquals(524f, second.top - (first.top + (first.height - 384f) / 2), .1f) - for (index in 0..5) { - val caption = rule.onNodeWithText(titles[index].title, useUnmergedTree = true).fetchSemanticsNode() - assertEquals(256f, caption.boundsInRoot.width, .1f) - assertEquals(794f, caption.boundsInRoot.top, .1f) - } - val focusedCaption = rule.onNodeWithText(titles[0].title, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - assertEquals(20.48f, focusedCaption.top - first.bottom - first.height * .04f - 2f * 1.08f, .1f) - assertEquals(26.48f, second.top - first.height * .04f - 2f * 1.08f - (focusedCaption.top + 58f), .1f) - rule.runOnIdle { kotlinx.coroutines.runBlocking { shell.view.listState.scrollToItem(0, 204) } } - rule.waitForIdle() - val pinnedFirst = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot - val pinnedSecond = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot - assertEquals(168f, pinnedFirst.top, .1f) - assertEquals(692f, pinnedSecond.top, .1f) - assertEquals(1076f, pinnedSecond.bottom, .1f) - } - - @Test fun homePosterWideAndTileShelvesReserveTheirCaptionBands() { - val kind = mutableStateOf(HomeCardKind.Poster) - val loading = mutableStateOf(false) - val memory = HomeViewMemory() - rule.setContent { WolfskinTheme { - Column { - Box(Modifier.testTag("shelf")) { - HomeShelf(HomeRow("fixture", HomeRowKind.Shelf, "Fixture", listOf(HomeTitle("one", "One", "movie", 2007, 10560)), - kind.value, skeleton = loading.value), memory, false, 0, emptyList(), {}, {}) - } - Box(Modifier.size(1.dp).testTag("next")) - } - } } - for ((cardKind, expected) in listOf(HomeCardKind.Poster to 632f, HomeCardKind.Wide to 522f, HomeCardKind.Tile to 441f)) { - for (pending in listOf(false, true)) { - rule.runOnIdle { kind.value = cardKind; loading.value = pending } - rule.waitForIdle() - assertEquals("$cardKind loading=$pending", expected, rule.onNodeWithTag("shelf").fetchSemanticsNode().boundsInRoot.height, .1f) - assertEquals(expected, rule.onNodeWithTag("next").fetchSemanticsNode().boundsInRoot.top, .1f) - } - } - } - - @Test fun filmographyKeepsUnfocusedMetadataAndDistinguishesSeriesFromFilmRuntime() { - val path = "/library/assets/movie-annie-hall-1977-poster.jpg" - val item = mutableStateOf(TitleItem("credit", "A filmography credit", "movie", 2007, 10560, - poster = path, held = true, watched = true)) - val art = mapOf(path to fixtureArt(path)!!) - rule.setContent { WolfskinTheme { - androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) { - TitlePoster(item.value, false, alwaysShowCaption = true) {} - } - } } - rule.onNodeWithText("A filmography credit").assertIsDisplayed() - rule.onNodeWithText("2007").assertIsDisplayed() - rule.onNodeWithText("2h 56m").assertIsDisplayed() - rule.onNodeWithContentDescription("Watched").assertIsDisplayed() - rule.runOnIdle { item.value = item.value.copy(kind = "tv", year = 2016, runtimeSeconds = 123456) } - rule.onNodeWithText("2016").assertIsDisplayed() - rule.onNodeWithText("2h 56m").assertDoesNotExist() - rule.onNodeWithContentDescription("Watched").assertIsDisplayed() - rule.runOnIdle { item.value = item.value.copy(resumeSeconds = 10) } - rule.onNodeWithContentDescription("Watched").assertDoesNotExist() - rule.onNodeWithText("A filmography credit").assertIsDisplayed() - } - - @Test fun loadingCaptionBandsReserveSpaceWithoutPaintingBars() { - val slots = listOf("poster" to Triple(128.dp, 192.dp, 48.dp), - "wide" to Triple(272.dp, 153.dp, 32.dp), "tile" to Triple(200.dp, 112.5.dp, 32.dp)) - rule.setContent { WolfskinTheme { - Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { - Row(Modifier.offset(48.dp, 42.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) { - slots.forEach { (name, geometry) -> - Box(Modifier.testTag("loading-$name")) { - PosterCaptionSkeleton(geometry.first, geometry.second, geometry.third) - } - } - } - } - } } - rule.waitForIdle() - val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() } - val file = File(directory, "loading-bands.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - try { - for ((name, geometry) in slots) { - val bounds = rule.onNodeWithTag("loading-$name").fetchSemanticsNode().boundsInRoot - val band = geometry.third.value * 2 - assertEquals((geometry.second.value + geometry.third.value) * 2, bounds.height, .1f) - for (y in (bounds.bottom - band).toInt() until bounds.bottom.toInt()) { - for (x in bounds.left.toInt() until bounds.right.toInt()) { - assertEquals("$name reserved caption band ($x,$y)", WolfskinTokens.Color.bg.toArgb(), bitmap.getPixel(x, y)) - } - } - } - } finally { bitmap.recycle() } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt deleted file mode 100644 index 440c0fe3..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt +++ /dev/null @@ -1,97 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.graphics.BitmapFactory -import androidx.activity.ComponentActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -import java.io.File - -/** A slow or interrupted artwork read cannot replace the last painted hero with a blank. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class HeroMotionTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun heroWaitsForArtBlendsOnlyItsBoundedRegionAndKeepsTheLatestSelection() { - rule.mainClock.autoAdvance = false - val selected = mutableStateOf("red") - val ready = mutableStateOf(true) - val reduced = mutableStateOf(false) - val colors = mapOf("red" to Color.Red, "blue" to Color.Blue, "green" to Color.Green) - rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme { - Box(Modifier.requiredSize(960.dp, 540.dp)) { - HeroArtCrossfade(selected.value, selected.value, 200.dp) { title -> - val reportReady = LocalHeroArtReady.current - if (ready.value) SideEffect { reportReady?.invoke() } - Box(Modifier.fillMaxSize().background(colors.getValue(title))) - } - } - } } } - val directory = kotlin.io.path.createTempDirectory("wolfskin-hero-").toFile() - try { - rule.mainClock.advanceTimeBy(32) - update { selected.value = "blue"; ready.value = false } - rule.mainClock.advanceTimeBy(1000) - assertEquals(Color.Red.toArgb(), pixel(directory, 100)) - val probeFile = File(directory, "hero.png") - val probe = BitmapFactory.decodeFile(probeFile.absolutePath) - try { - for (y in listOf(380, 400, 420, 600, 800)) { - assertEquals("The outgoing hero must stop at its 400px boundary: y=$y", - (if (y < 400) Color.Red else Color.Blue).toArgb(), probe.getPixel(100, y)) - } - assertEquals(400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f) - } finally { probe.recycle() } - update { ready.value = true } - rule.mainClock.advanceTimeBy(176) - val mixed = pixel(directory, 100) - assertNotEquals(Color.Red.toArgb(), mixed) - assertNotEquals(Color.Blue.toArgb(), mixed) - rule.mainClock.advanceTimeBy(400) - assertEquals(Color.Blue.toArgb(), pixel(directory, 100)) - update { selected.value = "green"; ready.value = false } - rule.mainClock.advanceTimeBy(32) - update { selected.value = "red"; ready.value = true } - rule.mainClock.advanceTimeBy(400) - assertEquals(Color.Red.toArgb(), pixel(directory, 100)) - update { reduced.value = true; selected.value = "green" } - rule.mainClock.advanceTimeBy(128) - assertEquals(Color.Green.toArgb(), pixel(directory, 100)) - } finally { directory.deleteRecursively() } - } - - private fun update(block: () -> Unit) { - rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } - rule.mainClock.advanceTimeByFrame() - } - - private fun pixel(directory: File, y: Int): Int { - rule.waitForIdle() - val file = File(directory, "hero.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - return try { bitmap.getPixel(100, y) } finally { bitmap.recycle() } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt index a3d8a958..79e89277 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt @@ -11,12 +11,23 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -/** Live letter-rail changes preserve deep selection and poster geometry. */ +/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") class LibrarySpacingRegressionTest { @get:Rule val rule = createComposeRule() + @Test fun partiallyWatchedPosterKeepsItsOriginalProgressInsets() { + val title = LibraryTitle("partial", "Partially watched", "movie", progress = .5f) + rule.setContent { WolfskinTheme { LibraryPoster(title, false, false, false) } } + val poster = rule.onNodeWithTag("poster-partial").fetchSemanticsNode().boundsInRoot + val track = rule.onNodeWithTag("poster-progress-partial").fetchSemanticsNode().boundsInRoot + assertEquals(256f, poster.width, .1f) + assertEquals(220f, track.width, .1f) + assertEquals(18f, track.left - poster.left, .1f) + assertEquals(18f, poster.right - track.right, .1f) + } + @Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() { val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") } val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt deleted file mode 100644 index 12d6ddad..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt +++ /dev/null @@ -1,230 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.graphics.BitmapFactory -import android.os.StrictMode -import androidx.activity.ComponentActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.ui.MotionDurationScale -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -import java.io.File - -/** Delayed metadata and loading animations cannot move content or capture the remote's focus. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class MotionSurfaceTest { - private val animatorScale = object : MotionDurationScale { override var scaleFactor by mutableFloatStateOf(1f) } - @get:Rule val rule = createAndroidComposeRule(effectContext = animatorScale) - private val viewer = ShellPerson("justin", "Justin", "J", false) - - @Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() { - val shell = shell() - val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview") - val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview") - val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {} - rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } - rule.waitForIdle() - val before = bounds() - for (stage in listOf("Ready", "Downloading", null)) { - rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Mindel", item = series.copy(ratings = listOf(LibraryRating("rt", 88.0))))) } - rule.mainClock.advanceTimeBy(3400) - rule.waitForIdle() - assertEquals("Late metadata cannot change reserved header geometry", before, bounds()) - } - } - - @Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() { - val shell = shell() - val actions = mutableListOf() - val state = TitleState(TitlePageData(TitleItem("loading", "", "movie"), status = TitleReadStatus.Loading), viewer, actions::add) - rule.setContent { WolfskinTheme { TitleSurface(state, shell) } } - rule.mainClock.advanceTimeBy(2100) - rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } - rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible) } - rule.onRoot().performKeyInput { pressKey(Key.Back) } - rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) } - } - - @Test fun pendingCastAndEpisodesAreSkippedUntilRealCardsExist() { - val shell = shell() - val series = TitleItem("series", "Series", "tv", held = true, seasons = 1) - val related = TitleItem("related", "Related", "movie") - val state = TitleState(TitlePageData(series, castStatus = TitleReadStatus.Loading, episodesStatus = TitleReadStatus.Loading, related = listOf(related)), viewer) {} - assertEquals(listOf("header", "seasons", "related"), state.rows) - state.focus("seasons", 0) - state.key(ShellKey.Down, true, 0, shell) - assertEquals("related", state.row) - state.update(state.data.copy(castStatus = TitleReadStatus.Ready, cast = listOf(TitlePerson("person", "Actor")), episodesStatus = TitleReadStatus.Ready, episodes = listOf(TitleItem("episode", "Pilot", "episode")))) - assertEquals("related", state.row) - state.key(ShellKey.Up, true, 0, shell) - assertEquals("cast", state.row) - } - - @Test fun reducedMotionFreezesPulseWhileNormalMotionBreathesWithoutRelayout() { - rule.mainClock.autoAdvance = false - val reduced = mutableStateOf(true) - rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme { - Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { - Box(Modifier.size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2).testTag("shape")) - } - } } } - rule.mainClock.advanceTimeBy(32) - val directory = kotlin.io.path.createTempDirectory("wolfskin-motion-").toFile() - try { - val first = pixel(File(directory, "first.png")) - val bounds = rule.onNodeWithTag("shape").fetchSemanticsNode().boundsInRoot - rule.mainClock.advanceTimeBy(700) - assertEquals(first, pixel(File(directory, "reduced.png"))) - rule.runOnIdle { reduced.value = false; animatorScale.scaleFactor = 0f; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } - rule.mainClock.advanceTimeBy(32) - val systemReduced = pixel(File(directory, "system-reduced.png")) - rule.mainClock.advanceTimeBy(700) - assertEquals(systemReduced, pixel(File(directory, "system-later.png"))) - rule.runOnIdle { animatorScale.scaleFactor = 1f; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } - rule.mainClock.advanceTimeBy(32) - val low = pixel(File(directory, "low.png")) - rule.mainClock.advanceTimeBy(650) - assertNotEquals(low, pixel(File(directory, "high.png"))) - assertEquals(bounds, rule.onNodeWithTag("shape").fetchSemanticsNode().boundsInRoot) - } finally { directory.deleteRecursively() } - } - - @Test fun shelvesJoiningAnActiveLoadShareTheSameBreathingPhase() { - rule.mainClock.autoAdvance = false - val second = mutableStateOf(false) - rule.setContent { WolfskinTheme { - Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { - Box(Modifier.size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2)) - if (second.value) Box(Modifier.offset(y = 200.dp).size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2)) - } - } } - rule.mainClock.advanceTimeBy(600) - update { second.value = true } - val directory = kotlin.io.path.createTempDirectory("wolfskin-shared-pulse-").toFile() - try { - var previous: Int? = null - repeat(2) { sample -> - val file = File(directory, "$sample.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - try { - val first = bitmap.getPixel(100, 100) - assertEquals("Late shelves must join the existing phase", first, bitmap.getPixel(100, 500)) - if (previous != null) assertNotEquals("The shared clock must still breathe", previous, first) - previous = first - } finally { bitmap.recycle() } - rule.mainClock.advanceTimeBy(450) - } - } finally { directory.deleteRecursively() } - } - - @Test fun captionExitKeepsTranslationFixedAtRestAndDuringInterruptedEntry() { - rule.mainClock.autoAdvance = false - val title = mutableStateOf("Settled caption") - val visible = mutableStateOf(true) - rule.setContent { WolfskinTheme { Caption(title.value, "Facts", visible.value, Modifier.offset(40.dp, 80.dp)) } } - rule.mainClock.advanceTimeBy(400) - fun top() = rule.onNodeWithText(title.value).fetchSemanticsNode().boundsInRoot.top - val settled = top() - update { visible.value = false } - rule.mainClock.advanceTimeBy(48) - assertEquals("Exit cannot sink a settled caption", settled, top(), 0.01f) - update { title.value = "Interrupted caption"; visible.value = true } - rule.mainClock.advanceTimeBy(112) - val interrupted = top() - assertTrue("Entry must still be partway through its rise", interrupted > settled) - update { visible.value = false } - val exitTop = top() - assertTrue("Exit must retain the unfinished rise, not snap to its baseline", exitTop > settled) - rule.mainClock.advanceTimeBy(48) - assertEquals("An interrupted entry must fade exactly where it stopped", exitTop, top(), 0.01f) - } - - @Test fun lateRatingsGenresAndOverviewFadeInsideTheirExistingSlots() { - rule.mainClock.autoAdvance = false - val series = TitleItem("series", "A series", "tv", held = true) - val state = TitleState(TitlePageData(series, connection = TitleConnection.SignedIn), viewer) {} - val shell = shell() - rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } - rule.mainClock.advanceTimeBy(500) - val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot - update { state.update(state.data.copy(item = series.copy(ratings = listOf(LibraryRating("rt", 88.0)), genres = listOf("Drama"), overview = "Late overview"))) } - rule.mainClock.advanceTimeBy(16) - val regions = listOf("88%", "Drama", "Late overview").map { rule.onNodeWithText(it).fetchSemanticsNode().boundsInRoot } - val directory = kotlin.io.path.createTempDirectory("wolfskin-late-fade-").toFile() - fun brightness(name: String): List { - val file = File(directory, name) - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - return try { regions.map { bounds -> - var sum = 0L - for (y in bounds.top.toInt() until bounds.bottom.toInt()) for (x in bounds.left.toInt() until bounds.right.toInt()) { - val pixel = bitmap.getPixel(x, y) - sum += android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) - } - sum - } } finally { bitmap.recycle() } - } - try { - val entering = brightness("entering.png") - rule.mainClock.advanceTimeBy(240) - brightness("settled.png").zip(entering).forEach { (settled, early) -> assertTrue("Late text must fade rather than pop", settled > early) } - assertEquals(regions, listOf("88%", "Drama", "Late overview").map { rule.onNodeWithText(it).fetchSemanticsNode().boundsInRoot }) - assertEquals(actions, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot) - } finally { directory.deleteRecursively() } - } - - @Test fun animatingSkeletonDoesNotPerformMainThreadDiskOrNetworkWork() { - val loading = mutableStateOf(false) - rule.setContent { WolfskinTheme { Box(Modifier.size(200.dp, 140.dp).skeletonPulse(loading.value).background(WolfskinTokens.Color.surface2)) } } - rule.waitForIdle() - var previous: StrictMode.ThreadPolicy? = null - rule.runOnIdle { - previous = StrictMode.getThreadPolicy() - StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectDiskReads().detectDiskWrites().detectNetwork().penaltyDeath().build()) - loading.value = true - } - try { rule.mainClock.advanceTimeBy(1500); rule.waitForIdle() } - finally { rule.runOnIdle { StrictMode.setThreadPolicy(requireNotNull(previous)) } } - } - - private fun update(block: () -> Unit) { - rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } - rule.mainClock.advanceTimeByFrame() - } - - private fun shell() = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") } - private fun bounds() = listOf("title-overview", "title-actions", "title-shelf:episodes").map { rule.onNodeWithTag(it).fetchSemanticsNode().boundsInRoot } - private fun pixel(file: File): Int { - rule.waitForIdle() - rule.onRoot().captureRoboImage(file.absolutePath) - return BitmapFactory.decodeFile(file.absolutePath).let { bitmap -> try { bitmap.getPixel(100, 100) } finally { bitmap.recycle() } } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/NetworkArtTransitionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/NetworkArtTransitionTest.kt deleted file mode 100644 index e300114f..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/NetworkArtTransitionTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.graphics.Bitmap -import android.graphics.drawable.Drawable -import android.view.View -import coil3.asImage -import coil3.decode.DataSource -import coil3.request.ErrorResult -import coil3.request.ImageRequest -import coil3.request.SuccessResult -import coil3.transition.CrossfadeTransition -import coil3.transition.TransitionTarget -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config - -/** Provenance, errors and reduced motion determine the consumer-visible artwork transition. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class NetworkArtTransitionTest { - private val target = object : TransitionTarget { - override val view: View get() = error("Compose transition factories must not read a View") - override val drawable: Drawable? = null - } - - @Test fun onlyNetworkSuccessFadesAndReducedMotionCapsItsDuration() { - val request = ImageRequest.Builder(RuntimeEnvironment.getApplication()).data("https://example.invalid/poster").build() - val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888) - try { - for (reduced in listOf(false, true)) { - NetworkArtTransition.reduced = reduced - for (source in DataSource.entries) { - val transition = NetworkArtTransition.create(target, SuccessResult(bitmap.asImage(), request, source)) - if (source == DataSource.NETWORK) { - assertTrue(transition is CrossfadeTransition) - assertEquals(if (reduced) 80 else 160, (transition as CrossfadeTransition).durationMillis) - } else assertFalse("$source must paint instantly", transition is CrossfadeTransition) - } - assertFalse(NetworkArtTransition.create(target, ErrorResult(null, request, IllegalStateException("offline"))) is CrossfadeTransition) - } - } finally { NetworkArtTransition.reduced = false; bitmap.recycle() } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt new file mode 100644 index 00000000..ec217257 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import java.io.File +import org.junit.Assert.* +import org.junit.Test + +/** The normal JVM gate rejects untokened styling, undersized typography and internal screen wording. */ +class OwnedSourceAuditTest { + @Test fun ownedScreensUseTokensReadableTextAndViewerFacingNames() { + val roots = listOf(File("src/main/java"), File("../app/src/wolfskin")) + roots.forEach { assertTrue("Missing owned source root: $it", it.isDirectory) } + val violations = roots.flatMap { root -> + root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { source -> + audit(source.name, source.readText()).map { "$source:$it" } + }.toList() + } + assertTrue(violations.joinToString("\n"), violations.isEmpty()) + } + + @Test fun plantedViolationsFailEachDoneBarRule() { + val cases = listOf( + Triple("Screen.kt", "Box(Modifier.width(12.dp))", "unit"), + Triple("Screen.kt", "Box(Modifier.width(12f.dp))", "unit"), + Triple("Screen.kt", "Text(fontSize = 14.sp)", "unit"), + Triple("Screen.kt", "Box(Modifier.width((width + gap).dp))", "unit"), + Triple("Screen.kt", "Text(color = Color(0xFF123456))", "color"), + Triple("Screen.kt", "Text(color = ComposeColor(red = 1f, green = 0f, blue = 0f))", "color"), + Triple("WolfskinTokens.kt", "val tiny = TextStyle(fontSize = 11.5.sp)", "font-floor"), + Triple("Screen.kt", "Text(\"Recs Movies\")", "wording"), + Triple("Screen.kt", "Text(\"DGiC\")", "wording"), + Triple("Screen.kt", "Text(\"Justin Shows\")", "wording"), + Triple("Screen.kt", "Text(\"Next page\")", "wording"), + Triple("Screen.kt", "Text(\"Previous page\")", "wording"), + Triple("Screen.kt", "Text(\"Page 2\")", "wording"), + ) + cases.forEach { (name, source, rule) -> + assertTrue("$rule accepted $source", audit(name, source).any { it.endsWith(":$rule") }) + } + assertTrue(audit("WolfskinTokens.kt", "val body = TextStyle(fontSize = 12.sp); val fill = Color(0xFF123456)").isEmpty()) + assertTrue(audit("Screen.kt", "import androidx.compose.ui.unit.dp\nText(\"This title can’t be read\", style = WolfskinTokens.Type.body)").isEmpty()) + } + + private fun audit(name: String, source: String): List { + val generated = name == "WolfskinTokens.kt" + return source.lineSequence().mapIndexedNotNull { index, line -> + val code = line.trimStart() + if (code.startsWith("import ") || code.startsWith("//") || code.startsWith("*") || code.startsWith("/*")) return@mapIndexedNotNull null + val rules = buildList { + if (!generated && units.containsMatchIn(line)) add("unit") + if (!generated && colors.containsMatchIn(line)) add("color") + if (fonts.findAll(line).any { it.groupValues[1].toDouble() < 12 }) add("font-floor") + if (strings.findAll(line).any { forbidden.containsMatchIn(it.value) }) add("wording") + } + rules.map { "${index + 1}:$it" } + }.flatten().toList() + } + + companion object { + private val units = Regex("\\.(?:dp|sp)\\b") + private val colors = Regex("\\b(?:Compose)?Color\\s*\\(\\s*(?:0[xX]|[0-9.]|(?:red|green|blue|alpha|color)\\s*=)") + private val fonts = Regex("\\bfontSize\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)[fF]?\\.sp\\b") + private val strings = Regex("\"(?:\\\\.|[^\"\\\\])*\"") + private val forbidden = Regex("Recs (?:Movies|Shows)|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page (?:[0-9]|\\$)|\\bpagination\\b", RegexOption.IGNORE_CASE) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt deleted file mode 100644 index dfefe549..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.net.Uri -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config - -/** Card URLs bound server work and transfer size, while hero quality and cache identity stay intact. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class WolfskinImagesTest { - private data class Surface(val name: String, val width: Int, val height: Int, val tier: String, val hero: Boolean = false) - private val surfaces = listOf( - Surface("grid/Home/Discover/person poster", 256, 384, "w342"), - Surface("Home tile", 400, 225, "w780"), - Surface("Home wide", 544, 306, "w780"), - Surface("episode still", 400, 280, "w780"), - Surface("cast portrait", 200, 200, "w342"), - Surface("person portrait", 240, 240, "w342"), - Surface("hero poster", 420, 630, "w342", true), - Surface("Home/Discover/title hero", 1920, 1080, "w1280", true), - ) - - private fun request(source: Any, surface: Surface): Any = WolfskinImages.request( - RuntimeEnvironment.getApplication(), source, surface.width, surface.height, surface.height > surface.width).data - - @Test fun jellyfinSurfacesReplaceStaleDimensionsAndEncodingWithoutLosingIdentity() { - for (surface in surfaces) { - val source = "https://jelly.example/Items/123/Images/Backdrop/0?tag=stable&api_key=kept&MaxWidth=1280&height=720&FillHeight=720&Quality=96&Format=Png" - val rewritten = request(source, surface).toString() - val uri = Uri.parse(rewritten) - assertEquals(surface.name, surface.width.toString(), uri.getQueryParameter("fillWidth")) - assertEquals(surface.name, surface.height.toString(), uri.getQueryParameter("fillHeight")) - assertEquals(surface.name, if (surface.hero) "Jpg" else "Webp", uri.getQueryParameter("format")) - assertEquals(surface.name, if (surface.hero) "96" else "80", uri.getQueryParameter("quality")) - assertEquals("stable", uri.getQueryParameter("tag")) - assertEquals("kept", uri.getQueryParameter("api_key")) - assertEquals(setOf("tag", "api_key", "fillWidth", "fillHeight", "format", "quality"), uri.queryParameterNames) - assertEquals(rewritten, request(rewritten, surface)) - } - } - - @Test fun tmdbSurfacesNeverDownloadOriginalArtwork() { - for (surface in surfaces) { - for (oldTier in listOf("original", "w500", "w780")) { - assertEquals(surface.name, "https://image.tmdb.org/t/p/${surface.tier}/art.jpg?identity=kept", - request("https://image.tmdb.org/t/p/$oldTier/art.jpg?identity=kept", surface)) - } - } - } - - @Test fun localAndUnrelatedSourcesRemainUntouched() { - for (source in listOf("file:///android_asset/poster.webp", "https://cdn.example/poster.jpg?width=800", "https://jelly.example/Users/123/Images/Primary", Uri.parse("content://art/1"))) { - assertEquals(source, request(source, surfaces.first())) - } - } -} diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index 309913d4..156054d0 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -23,6 +23,7 @@ "personGap": {"dp": 24, "px": 48, "source": "index.html:293"}, "personNameBottom": {"dp": 5, "px": 10, "source": "index.html:296"}, "personNoteBottom": {"dp": 3, "px": 6, "source": "index.html:297"}, + "captionWidth": {"dp": 320, "px": 640, "source": "index.html:1242"}, "sheetPaddingTop": {"dp": 40, "px": 80, "source": "index.html:227"}, "sheetPaddingSide": {"dp": 32, "px": 64, "source": "index.html:227"}, "sheetGap": {"dp": 9, "px": 18, "source": "index.html:227"}, @@ -109,6 +110,8 @@ "tileOverlaySide": {"dp": 11, "px": 22, "source": "index.html:135"}, "tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"}, "cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"}, + "cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"}, + "tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"}, "ambientInset": {"dp": 40, "px": 80, "source": "index.html:96"}, "ambientScale": {"$value": 1.1, "source": "index.html:96"}, "posterShadowRadius": {"dp": 40, "px": 80, "source": "index.html:97"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index cb81f2a5..423a5e4c 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -1,17 +1,5 @@ /* Generated from the locked TV token sources. */ :root { - --cardMetadata-titleRest: 28.0px; - --cardMetadata-titleFocus: 28.0px; - --cardMetadata-subtitle: 24.0px; - --cardMetadata-check: 24.0px; - --cardMetadata-gap: 12.0px; - --cardMetadata-compactGap: 8.0px; - --cardMetadata-minimumGap: 4.0px; - --cardMetadata-bottom: 2.0px; - --cardMetadata-separator: 2.0px; - --cardMetadata-watchedLine: 3.0px; - --cardMetadata-resumeLine: 6.0px; - --cardMetadata-landscapeBand: 64.0px; --color-bg: #07070a; --color-surface: #13131a; --color-surface2: #1b1b25; @@ -135,27 +123,7 @@ --layout-castCircle: 200.0px; --layout-personPortrait: 240.0px; --layout-row_chipsGrid: 136.0px; - --motion-skeletonCycle: 1400ms; - --motion-focusMove: 180ms; - --motion-captionDelay: 60ms; - --motion-captionOut: 100ms; - --motion-artReveal: 160ms; - --motion-heroFade: 320ms; - --motion-heroCopyOut: 120ms; - --motion-heroCopyDelay: 140ms; - --motion-heroCopyIn: 200ms; - --motion-rowEnter: 180ms; - --motion-lateLineFade: 200ms; - --motion-copyEntryRise: 16.0px; - --motion-reducedFade: 80ms; - --motion-rowRise: 24.0px; - --motion-captionRise: 10.0px; - --motion-copyRise: 14.0px; - --motion-readinessSlot: 56.0px; - --motion-ease: cubic-bezier(0.25, 0.1, 0.25, 1); - --motion-easeOut: cubic-bezier(0.33, 1, 0.68, 1); - --motion-easeInOut: cubic-bezier(0.65, 0, 0.35, 1); - --motion-breatheEase: cubic-bezier(0.42, 0, 0.58, 1); + --motion-fast: 140ms; --motion-base: 160ms; --motion-scroll: 180ms; --motion-ratingDwell: 350ms; @@ -254,6 +222,8 @@ --library-chipGap: 16.0px; --library-chipTop: 16.0px; --library-chipIcon: 26.0px; + --library-captionWidth: 640.0px; + --library-captionFactsGap: 18.0px; --library-badgeIcon: 28.0px; --library-plateTop: 14.0px; --library-plateSide: 16.0px; @@ -313,6 +283,7 @@ --title-personGap: 48.0px; --title-personNameBottom: 10.0px; --title-personNoteBottom: 6.0px; + --title-captionWidth: 640.0px; --title-sheetPaddingTop: 80.0px; --title-sheetPaddingSide: 64.0px; --title-sheetGap: 18.0px; @@ -393,6 +364,8 @@ --title-tileOverlayTop: 54.0px; --title-tileOverlaySide: 22.0px; --title-tileOverlayBottom: 18.0px; + --title-cardProgressInset: 18.0px; + --title-tileProgressBottom: 10.0px; --title-ambientInset: 80.0px; --title-posterShadowRadius: 80.0px; --title-posterShadowY: 30.0px; @@ -422,6 +395,7 @@ --home-statePaddingY: 34.0px; --home-stateGap: 8.0px; --home-stateIcon: 36.0px; + --home-captionWidth: 640.0px; --home-plateTop: 14.0px; --home-plateSide: 16.0px; --home-plateFade: 46.0px; @@ -442,6 +416,8 @@ --home-heroSheetTop: 150.0px; --home-cardBlur: 26.0px; --home-heroBlur: 60.0px; + --home-progressInset: 18.0px; + --home-progressBottom: 10.0px; --home-endTop: 40.0px; --home-frameBottom: 80.0px; --home-toastPadding: 28.0px; @@ -461,6 +437,7 @@ --home-savedGap: 10.0px; --home-savedIcon: 28.0px; --home-savedRadius: 28.0px; + --home-posterProgressBottom: 12.0px; --discover-shelfHeadingBand: 104.0px; --discover-sheetPaddingX: 64.0px; --discover-sheetPaddingY: 80.0px; diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index d7db186e..70571dc3 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1,5 +1,5 @@ { - "$description": "Wolfskin TV design tokens, spec revision 6: approved loading/motion and option-B card metadata/state lines. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.", + "$description": "Wolfskin TV design tokens, spec revision 5: approved focus and Level 2 spacing, taller episode cards, roomier Discover headings and panel-resolution hero artwork. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.", "meta": { "density": 2.0, "stagePx": [ @@ -15,20 +15,6 @@ "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py", "feedbackDelta": "data/tv-v2/feedback-2026-11-06/delta.json" }, - "cardMetadata": { - "titleRest": {"$type":"typography","px":28,"sp":14,"lineHeight":1.1428571428571428,"weight":500,"$value":{"fontWeight":500}}, - "titleFocus": {"$type":"typography","px":28,"sp":14,"lineHeight":1.1428571428571428,"weight":700,"$value":{"fontWeight":700}}, - "subtitle": {"$type":"typography","px":24,"sp":12,"lineHeight":1.0833333333333333,"weight":400,"$value":{"fontWeight":400}}, - "check": {"$type":"dimension","px":24,"dp":12}, - "gap": {"$type":"dimension","px":12,"dp":6}, - "compactGap": {"$type":"dimension","px":8,"dp":4}, - "minimumGap": {"$type":"dimension","px":4,"dp":2}, - "bottom": {"$type":"dimension","px":2,"dp":1}, - "separator": {"$type":"dimension","px":2,"dp":1}, - "watchedLine": {"$type":"dimension","px":3,"dp":1.5}, - "resumeLine": {"$type":"dimension","px":6,"dp":3}, - "landscapeBand": {"$type":"dimension","px":64,"dp":32} - }, "color": { "bg": { "$type": "color", @@ -1059,7 +1045,7 @@ "px": 38, "dp": 19, "source": "index.html:1242", - "usedBy": "card-width captions start 38 px below unscaled art; library titles and subtitles remain visible, Home poster captions are focus-only" + "usedBy": "caption under a focused poster starts 38 px below the card bottom (capHtml top = headH + h + CAP_OFF; was 18, amended 2026-11-06); width 640" }, "plateInset": { "$type": "dimension", @@ -1216,7 +1202,7 @@ "ratio": "2:3", "fullSlotsPerRow": 6, "source": "index.html:656", - "usedBy": "every poster shelf and six-column library, See all and filmography grid; library captions always visible, shelf captions focus-only" + "usedBy": "every title shelf, library grid, See all grid, More like this, person filmography; art only at rest" }, "tile": { "$type": "card", @@ -1224,13 +1210,15 @@ "w": 400, "h": 225, "gap": 40, - "pitch": 440 + "pitch": 440, + "captionBand": 0 }, "dp": { "w": 200.0, "h": 112.5, "gap": 20.0, - "pitch": 220.0 + "pitch": 220.0, + "captionBand": 0.0 }, "ratio": "16:9", "fullSlotsPerRow": 4, @@ -1243,13 +1231,15 @@ "w": 544, "h": 306, "gap": 40, - "pitch": 584 + "pitch": 584, + "captionBand": 0 }, "dp": { "w": 272.0, "h": 153.0, "gap": 20.0, - "pitch": 292.0 + "pitch": 292.0, + "captionBand": 0.0 }, "ratio": "16:9", "fullSlotsPerRow": 3, @@ -1400,28 +1390,19 @@ } }, "motion": { - "skeletonCycle": { "$type": "duration", "$value": "1400ms" }, - "skeletonLowAlpha": { "$type": "number", "$value": 0.55 }, - "focusMove": { "$type": "duration", "$value": "180ms" }, - "captionDelay": { "$type": "duration", "$value": "60ms" }, - "captionOut": { "$type": "duration", "$value": "100ms" }, - "artReveal": { "$type": "duration", "$value": "160ms" }, - "heroFade": { "$type": "duration", "$value": "320ms" }, - "heroCopyOut": { "$type": "duration", "$value": "120ms" }, - "heroCopyDelay": { "$type": "duration", "$value": "140ms" }, - "heroCopyIn": { "$type": "duration", "$value": "200ms" }, - "rowEnter": { "$type": "duration", "$value": "180ms" }, - "lateLineFade": { "$type": "duration", "$value": "200ms" }, - "copyEntryRise": { "$type": "dimension", "$value": "16px", "px": 16, "dp": 8 }, - "reducedFade": { "$type": "duration", "$value": "80ms" }, - "rowRise": { "$type": "dimension", "$value": "24px", "px": 24, "dp": 12 }, - "captionRise": { "$type": "dimension", "$value": "10px", "px": 10, "dp": 5 }, - "copyRise": { "$type": "dimension", "$value": "14px", "px": 14, "dp": 7 }, - "readinessSlot": { "$type": "dimension", "$value": "56px", "px": 56, "dp": 28 }, - "ease": { "$type": "cubicBezier", "$value": [0.25, 0.1, 0.25, 1] }, - "easeOut": { "$type": "cubicBezier", "$value": [0.33, 1, 0.68, 1] }, - "easeInOut": { "$type": "cubicBezier", "$value": [0.65, 0, 0.35, 1] }, - "breatheEase": { "$type": "cubicBezier", "$value": [0.42, 0, 0.58, 1] }, + "fast": { + "$type": "duration", + "$value": "140ms", + "easing": "ease = cubic-bezier(.25,.1,.25,1)", + "source": "index.html:125", + "page": { + ".card/.btn/.chip/.pp/.prow/.crow/.srow/.pgear transform": "140ms", + ".cap opacity (caption fade-in)": "120ms", + ".dots svg opacity": "140ms" + }, + "usedBy": "focus scale + ring in and out on every focusable; caption fade under a focused poster (page 120 ms folds in)", + "note": "q1, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): the caption fade is 140 ms. Three durations in total (140, 160, 180) plus the blob spring." + }, "base": { "$type": "duration", "$value": "160ms", @@ -1494,9 +1475,9 @@ }, "reducedMotion": { "$type": "rule", - "$value": "no pulse or movement; fades at most 80ms", - "source": "tv-motion-speed direction A", - "usedBy": "system animator scale zero or Wolfskin reduced-motion flag" + "$value": "all transitions instant", + "source": "index.html:330", + "usedBy": "when Settings.Global ANIMATOR_DURATION_SCALE == 0: no scale animation, no scroll animation, blob snaps" } }, "image": { @@ -1511,8 +1492,8 @@ }, "tileRequest": { "px": [ - 400, - 225 + 416, + 234 ], "decode": "ARGB_8888", "source": "index.html:549", @@ -1520,8 +1501,8 @@ }, "wideRequest": { "px": [ - 544, - 306 + 560, + 315 ], "decode": "ARGB_8888", "source": "index.html:401", @@ -1555,6 +1536,11 @@ "source": "index.html:550", "usedBy": "cast portraits: decode min(source 185x278, slot)" }, + "crossfade": { + "$value": false, + "source": "index.html:536; answers p3", + "usedBy": "no crossfade on cards (Coil crossfade off)" + }, "memoryCacheMB": { "$value": 64, "source": "index.html:552; answers p3", diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index a45b7f17..713ae2f4 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,8 +1,8 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json -c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py -eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json -192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json -3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json -d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css -ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json +85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py +25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json +be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json +bb64e45654fce2cd3ba595b505fb8b5a540526cef696dfa974be25f028d3e119 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json +36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css +3226c6fb9c7e35c5bde90aaf01630b238cfcb9a768ff60c49461e19a200c9125 tokens.json